Compare commits

...

103 Commits
0.3.2 ... 0.3.3

Author SHA1 Message Date
Kippi00
d317111d20 Updates to ALTTP, SM, and SMZ3 guides (#703) 2022-06-27 09:40:01 +02:00
alwaysintreble
3f1d216d28 docs: add reference to text client and commands to a few setup guides (#694) 2022-06-26 21:52:24 -04:00
Joethepic
0ca3d73ae9 makes easier to find where to put the launch options for steam version v6 (#712)
* Update setup_en.md

* Update setup_en.md

* Update setup_en.md

* Update setup_en.md

* typo fix spaces clarification

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

* Grammar corrections, clarifications, removed redundant explanations

* Markdown syntax fix

Co-authored-by: Zach Parks <zach@alliware.com>
Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-06-26 19:08:16 -04:00
alwaysintreble
1972d531b9 MC: fix broken brewing image on minecraft tracker (#707) 2022-06-25 14:11:20 -05:00
alwaysintreble
5006c79a00 SM64: Add common mistake and troubleshooting to setup guide (#708) 2022-06-25 14:07:03 -05:00
Daniel Grace
8788ee1aa7 [HK] Further updates for White Palace logic, (#662) 2022-06-25 20:15:03 +02:00
Chris Wilson
17ba73b0b8 Rename author to authors for consistency 2022-06-25 19:10:20 +02:00
rsyh93
0407df83b7 SC2: add Linux setup to tutorial (#679)
also fixes some formatting
2022-06-25 14:12:30 +02:00
alwaysintreble
f140aadafe Alttp: fix broken msu es link (#702) 2022-06-25 13:15:57 +02:00
Grrmo
b41c6185e4 TS: Fix broken link to german setup guide (#700)
The German tutorial link pointed to the English version
2022-06-25 00:29:25 +02:00
NewSoupVi
aa3d7f5e21 Small Witness fixes (#698) 2022-06-24 19:25:23 +02:00
black-sliver
efadf6fdf4 UX: More errors (#697)
* SNIClient: adjuster, ignore missing Tk

* UI: add support for gtk/kde messagebox

* SNIClient: show error when patching fails
2022-06-23 19:26:30 +02:00
black-sliver
12863e9b04 CI: update enemizer and sni (#696) 2022-06-23 19:25:55 +02:00
Chris Wilson
1843618c99 Add stone theme to WebHost (#645)
* Add stone theme

* Fix h2 color, change rogue-legacy to stone theme (approved by Phar)

* Add stone theme preview to world api.md

* Different stone theme preview to match other images
2022-06-22 20:31:40 -04:00
alwaysintreble
4e5071fd68 core: add a link to FAQ to the repo readme 2022-06-22 16:30:43 +02:00
TheCondor07
6e918edce1 SC2: Updated apsc2 version required (#691) 2022-06-22 11:49:00 +02:00
Fabian Dill
80ff5a18b1 remove limit of 1000 Yotta-Joule in EnergyLink (#689) 2022-06-21 20:50:40 +02:00
Fabian Dill
d112cc585f Clients: fix /received calling a dict instead of indexing (#688) 2022-06-21 15:46:35 +02:00
Fabian Dill
3fec33f56c Clients: fix clients not requesting Archipelago DataPackage updates unless spectator is present. 2022-06-21 09:02:11 +02:00
Alchav
68674deb00 FF1 - classify some items as useful (#669) 2022-06-20 21:17:57 +02:00
PoryGone
a9e530721d SA2B v1.1.0 (#673)
Co-authored-by: RaspberrySpaceJam <tyler.summers@gmail.com>
2022-06-20 21:12:13 +02:00
black-sliver
03e9034a98 Server: minify cmd json
This saves between 7 and 15% where compression is unavailable.
2022-06-20 07:52:21 +02:00
Daniel Grace
6970c5ce97 HK: Bugfix shop requirements to be >= rather than >.
This was causing off-by-one errors, which were problematic if e.g. a Grubfather slot wanted all 46 grubs.
2022-06-20 07:46:25 +02:00
alwaysintreble
10b3803a7f ror2: correctly mark Dio's as progression and mark equipment as useful 2022-06-19 22:26:48 +02:00
Fabian Dill
a7e8c82633 Factorio: more condensed raw_recipes creation
(by black-sliver)
2022-06-19 21:55:03 +02:00
Fabian Dill
6d4c4295b3 Factorio: use resources data 2022-06-19 21:48:30 +02:00
black-sliver
47edc356ad api.md update and rename (#676)
* api.md: update for ItemClassification

* world api.md: rename from api.md
2022-06-19 15:19:46 +02:00
black-sliver
b551e3a2ad SoE: change default prog balancing to 30 2022-06-19 14:17:42 +02:00
black-sliver
a9c32bc2e2 MinecraftClient: Linux fixes (#668)
* MC: open file selector if client is run without apmc

* MC: linux fixes

* we don't use shell anymore
* use user_path for forge_dir. Unless read-only, this is the same as what cwd is set to.
2022-06-19 04:54:10 -07:00
alwaysintreble
60c7be87f8 lttp: update requirement version for lttp template yaml 2022-06-19 01:59:50 +02:00
Fabian Dill
2bac78b4a4 Factorio: manual crude-oil recipe seems no longer needed and actually messed with costs 2022-06-18 13:57:28 +02:00
Fabian Dill
c4769eeebb Factorio: load fluids from exported data 2022-06-18 13:40:10 +02:00
espeon65536
51341f6255 MC client: use user_path to fix appimage permissions 2022-06-18 13:21:54 +02:00
Daniel Grace
c7a32dc91b Sort hints by found/not found and then other world/own world. (#642)
This updates notify_hints() as follows:

  - Sort hints by their 'found' attribute in reverse during the first
    iteration, so items not found will show at the bottom.
  - Store a tuple of (hint, hint.as_network_message()) in concerns rather
    than just the hint so the raw hint data remains available for later
    sorting.
  - Do the logging.info call as part of this iteration instead of doing
    a second iteration pass that does nothing but logging.
  - Iterate over concerns (and look up connected clients) rather than
    iterating over all clients (and checking for concerns)
2022-06-18 09:19:08 +02:00
black-sliver
3623678c93 Launcher: always use kvui 2022-06-18 09:17:10 +02:00
Fabian Dill
a5d516e179 Factorio: fix impossible recipes requiring stacking non-stacking items
Factorio: speedup load time
2022-06-18 09:15:14 +02:00
black-sliver
2045905c9b setup.py: fix setuptools>=61 compatibility
Closes ArchipelagoMW/Archipelago#391
2022-06-17 15:09:58 +02:00
Fabian Dill
26c027a075 Core: downgrade item classification to int before writing to file 2022-06-17 06:10:30 +02:00
Fabian Dill
b86ee20f3f Core: fix ItemLinks setting advancement flag 2022-06-17 05:26:11 +02:00
Fabian Dill
50c75e9684 Core: increment version 2022-06-17 03:57:02 +02:00
Fabian Dill
d87c3d5323 LttP: update manual yaml 2022-06-17 03:48:54 +02:00
Fabian Dill
247f674749 Network remove roominfo players (#661) 2022-06-17 03:34:50 +02:00
Fabian Dill
74fe03414c HK: extractor now needs to check for BOM 2022-06-17 03:25:08 +02:00
Fabian Dill
65d213c494 kivy: include in frozen library zip 2022-06-17 03:24:38 +02:00
Fabian Dill
05a51346f9 LttP: fix Ganon's Tower trash prefill ignoring item_rules (#648) 2022-06-17 03:24:15 +02:00
Fabian Dill
6c525e1fe6 Core: move multiple Item properties into a single Flag (#638) 2022-06-17 03:23:27 +02:00
Fabian Dill
5be00e28dd Tests: always display all warnings
WebHost: fix a warning about new cache names
2022-06-17 03:22:43 +02:00
Fabian Dill
d81dbbd951 CommonClient: revamp DataPackage handling 2022-06-17 03:22:20 +02:00
Fabian Dill
83dee9d667 MultiServer: introduce LocationScouts create_as_hint -> only_new 2022-06-17 03:21:33 +02:00
NewSoupVi
7d79cff66f The Witness - 0.3.3 features and fixes (#617)
New option: "Early Secret Area" (Opens a door to the Challenge Area from the start of the game)
New option: Victory Conditions "Mountaintop Box Short" and "Mountaintop Box Long"
New options: Number of Lasers of Mountain, Number of Lasers for Challenge
New option & item: Add some number of "Puzzle Skips", which let you skip one puzzle in the game

Many logic fixes
2022-06-16 03:04:45 +02:00
Alchav
0a63bd0fc6 Meritous get_filler_item_name 2022-06-15 19:05:48 +02:00
Fabian Dill
55d8c8c928 Generate: ignore files starting with ., something about Macs having a .DS_STORE or something. (#656)
* Generate: ignore files starting with ., something about Macs having a .DS_STORE or something.

* Generate: .name is important
2022-06-14 18:10:41 -07:00
Fabian Dill
681f7041dc Tracker: fix order received column being empty 2022-06-14 08:13:02 -07:00
Kono Tyran
d5f15e6408 fix spaces in folder names failing to launch forge. 2022-06-14 06:56:47 -07:00
Fabian Dill
70d510dff8 Options: fix all games templates breaking due to invalid progression balancing 2022-06-14 03:56:02 +02:00
CaitSith2
2a5c128267 ChecksFinder Client refactored to import CommonClient components. 2022-06-14 01:38:10 +02:00
Daniel Grace
e5a1052089 Hollow Knight updates (goals, WP/POP, etc.) (#438)
* Hollow Knight updates:

- Add configurable goals (Any, THK, Siblings, Radiance)
  - Change base logic to require Opened_Black_Egg_Temple instead of
    requiring 3 dreamers.  This is future-proof for transition rando,
    where Black Egg might not have been located yet.
  - Add combat logic for THK and Radiance on par with Rando4's boss logic,
    so itemless HK shouldn't be required.
- Existing completion logic now uses Black_Egg_te

- Add White Palace options
  (Exclude, King Fragment Only, No Path of Pain, Include)
  - Excluded WP may still be required for King Fragment if Charms are
    not randomized
  - Simply don't place WP locations that are excluded
  - Distinguish between POP locations (required for POP), WP checks (
    actual item locations), WP transitions (relevant for future transition
    rando), and WP events (logically required to reach King Fragment)
  - Many transitions were listed twice.  Remove duplicates.
  - Sort transitions by scene

- For randomizable locations that have no logical significance when not
    randomized, simply skip adding them to the pool entirely for
    theoretically faster generation.

* Hollow Knight updates

  - Support random starting geo up to 1000 geo.
  - Always include locations rather than dropping unrandomized "logicless"
    ones, as it is required to best support same-slot coop.
2022-06-13 08:23:03 +02:00
Fabian Dill
8c64f6221e WebHost: update Flask-Limiter 2022-06-13 08:20:17 +02:00
Fabian Dill
0869a2acc3 SNIClient: prevent hang on exit if waiting on devices from SNI 2022-06-13 08:18:52 +02:00
Fabian Dill
e7ea827f02 Options: introduce SpecialRange (#630)
* Options: introduce SpecialRange

* Include SpecialRange data in player-settings and weighted-settings JSON files

* Add support for SpecialRange to player-settings pages

* Add support for SpecialRange options to weighted-settings. Also fixed a bug which would cause the page to crash if an unknown setting was detected.

Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-06-12 17:33:14 -04:00
Joethepic
84b6ece31d Itemlink tutorial improvement (#611)
* Update Items.py

* Update advanced_settings_en.md

* Update Items.py

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* improve consistency

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

* fix formating on game setting in example

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

* change version

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

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* tutorials: add description for game weight and properly document item links

* tutorials: add description for null replacement

* Update worlds/generic/docs/advanced_settings_en.md

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

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* Update worlds/generic/docs/advanced_settings_en.md

Co-authored-by: Hussein Farran <hmfarran@gmail.com>

* Update worlds/generic/docs/advanced_settings_en.md

Co-authored-by: Hussein Farran <hmfarran@gmail.com>

* Update worlds/generic/docs/advanced_settings_en.md

Co-authored-by: Hussein Farran <hmfarran@gmail.com>

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-06-12 17:24:19 -04:00
Zach Parks
1bcc5b6582 WebHost: Allow "random" to be default option for toggles and choices. (#640) 2022-06-12 07:48:52 +02:00
KonoTyran
c8c025ac34 Minecraft 1.19 (#623) 2022-06-11 23:22:16 +02:00
CaitSith2
d82d70ac97 Fix the possibility of manually assigning 'random' via alias_random 2022-06-11 23:20:56 +02:00
alwaysintreble
3e86fd4e57 Tutorials: hide ArchipIDLE (#622)
* Don't copy files of hidden worlds

* tutorials: hardcode not generating ArchipIDLE tutorial files outside april

* tutorials: ignore hidden worlds unless it's 'Archipelago'

* add parenthesis to prevent ambiguity
2022-06-10 19:49:12 -04:00
Alchav
964eda13cc Fix LTTP filler items (#621) 2022-06-10 13:23:03 +02:00
CaitSith2
c16815b16d Fix Room log 2022-06-10 13:20:35 +02:00
Colin Lenzen
74ee8ec459 [Timespinner] Add Boss Randomization Settings (#598)
* [Timespinner] Add Boss Randomization Settings
2022-06-10 01:07:47 +02:00
t3hf1gm3nt
22ea72c1b2 OOT: Add note about common issue with lua option in the configuration step (#629)
* OOT: Add note about common issue with lua option in the configuration step

More and more people have issues with connecting with OoT because fresh installs of newer versions of Bizhawk show having "Lua+LuaInterface" selected when it actually loads "Nlua+KopiLua" instead until you toggle between the two options. Hopefully adding this bolded note will help new users avoid this problem in the future.
2022-06-10 00:48:05 +02:00
Zach Parks
613dc4184a ALTTP: Updates to setup documents (#628)
Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
2022-06-10 00:47:01 +02:00
Fabian Dill
9a471aff1b WebHost: request maximum amount of file handles from the system for autolauncher. (#625)
* WebHost: request maximum amount of file handles from the system for autolauncher.

* WebHostLib: wrap resource import into try to restore windows compatibility
2022-06-09 13:14:12 -07:00
Fabian Dill
e69e42cabc SNIClient: sort devices for consistent key
SNIClient: get rid of * import
2022-06-09 13:05:30 -07:00
Fabian Dill
1281426075 HK: allow shuffling charm costs, instead of randomizing. (#441) 2022-06-09 00:27:43 +02:00
Fabian Dill
8b1baafddf SC2: send ItemLink messages to ingame as well 2022-06-09 00:20:36 +02:00
Kippi00
ee65d7e5fa Document multi-game YAMLs (#619) 2022-06-08 18:15:47 -04:00
Chris Wilson
df0ae205cd Update LICENSE files for WebHost assets (#616) 2022-06-08 17:17:50 -04:00
Fabian Dill
1cbd384569 Generate: sort input files, preventing arbitrary order from OS layer. 2022-06-08 00:36:13 +02:00
Fabian Dill
e47527087e WebHost: some updates (#603)
* WebHost: Make custom server prefer ipv4 for display

* WebHost: Make server retry saving in case of connection issues

* WebHost: fix autolaunch guardians getting stuck waiting for the oldest two rooms.
Probably not related to the issues of the system itself getting stuck, but should be fixed anyway.

* WebHost: logfile is meant to be guarded by access cookie

* WebHost: set patch target to null if port is not valid, disabling auto-connect
2022-06-08 00:35:35 +02:00
Fabian Dill
517a2db9d8 Clients: some improvements (#602)
* Clients: some improvements
SNIClient is the only client that uses slow_mode, so its definition should be moved there.
type info for CommandProcessor was int for some reason.
Moved a lot of type info from init to class body, making it easier for type checkers to find.
getLogger("") and getLogger(None) is technically different, just happens that our root logger is "", fixed it in case of future confusion though.

* Logging: log that init_logging was run and what the current AP version is.
2022-06-08 00:34:45 +02:00
black-sliver
fbf993566d Clients: UX improvements (#615) 2022-06-07 00:15:08 +02:00
black-sliver
25bea47872 Appimage: include libssl (#613) 2022-06-05 22:52:16 +02:00
black-sliver
78f22e895e requirements: update cx-Freeze, fix compatibility
this conflicts with and replaces commit #f9b12b51080c7bbbf3d52c79453ac6c8222a03c5
2022-06-04 21:12:45 +02:00
black-sliver
fa3925cd74 Ui: add open_filename helper
* native look & feel on Linux (Gnome and KDE)
* falls back to tkinter
2022-06-04 21:12:45 +02:00
black-sliver
d9418d5ce1 Core: move is_linux, _macos, _windows to Utils.py 2022-06-04 21:12:45 +02:00
black-sliver
103f9e0b85 UI: add Utils.messagebox
automatically uses either new kvui.MessageBox or tkinter.messagebox
2022-06-04 21:12:45 +02:00
black-sliver
a2fc3d5b71 AppImage: better compatibility
* old startup script did not work with dash
* add missing libcrypt in cx_freeze
2022-06-04 21:12:45 +02:00
Kono Tyran
c66d64b9d8 update minecraft_en.md wording slightly and minecraft version 2022-06-04 11:32:51 -07:00
TheCondor07
0dd67f40ba SC2: UI update, Relegate No Build Option, and Filler Item Update (#606) 2022-06-03 20:18:36 +02:00
Fabian Dill
f5dc39ddf0 kvui: fix warning about "X missing in __all__" when importing from kivy.base instead of correct module 2022-06-03 07:57:57 -07:00
t3hf1gm3nt
6b47776b11 TS: Add region names to location names, and other location name clarifications (#570)
* Add region names to location names, and other location name clarification changes
2022-06-03 12:27:02 +02:00
strotlog
2b73c7f9e4 config: Use valid default enemizer_path on Linux (and Windows) 2022-06-02 02:15:05 +02:00
Fabian Dill
4558ac66fa SNIClient: run adjuster for new aplttp file type 2022-06-01 08:30:28 -07:00
Fabian Dill
d0a98949f5 LttP: split Retro into Retro Bows and Retro Caves (#588) 2022-06-01 08:29:21 -07:00
Fabian Dill
e13e7f286c Tracker: fix ItemLinks items not being attributed to inventory 2022-06-01 08:28:16 -07:00
Fabian Dill
0045e3f9f7 WebHost: update flask-caching 2022-06-01 08:26:30 -07:00
Fabian Dill
ff608b72a2 Tests: add test to check for typo'd item name group definitions (#594)
* Tests: add test to check for typo'd item name group definitions
Factorio: item *name* group was pointing to IDs instead.
Server: prevent crash when using Event-filled item name group

* Server: prevent crash when /hint'ing for an item name group with events
2022-06-01 08:25:40 -07:00
Fabian Dill
19c3c8056b Server: remove compat to ~0.2 unversioned save data
If the savegame was loaded in the last few months, it will have already been upgraded.
2022-06-01 08:21:54 -07:00
black-sliver
d31c24bbf7 Doc: deprecate datapackage_version 2022-05-30 09:52:12 +02:00
lordlou
768f9497fd Sm remote item fix (#592) 2022-05-30 07:12:01 +02:00
TheCondor07
20be691f36 SC2: GUI Mission Launcher (#586)
* SC2: Functioning Starcraft 2 Mission Launcher UI

* AutoWorld: add .__file__ attribute to AutoWorlds
This tries to help with a recurring easy to make mistake, where ./worlds/myworld does not exist in frozen form and is instead ./lib/worlds/myworld

* SC2: get .kv file path correctly when frozen too

Co-authored-by: TheCondor07 <TheCondorian07@gmail.com>
Co-authored-by: Fabian Dill <fabian.dill@web.de>
2022-05-30 07:11:01 +02:00
Berserker66
3dd3f045e6 WebHost: use non-blocking file lock on unix, just like windows 2022-05-29 08:00:28 -07:00
black-sliver
6d3538a35b AppImage: fix build (#589)
* CI: build: use ARCH= for AppImage

* WebHost: pin flask-caching

until https://github.com/pallets-eco/flask-caching/pull/352 is merged or fixed otherwise
2022-05-28 23:20:46 +02:00
Fabian Dill
1a0bfecb5f LttP: convert vendors hint into separate scams option 2022-05-28 20:08:06 +02:00
155 changed files with 4101 additions and 2400 deletions

View File

@@ -17,13 +17,13 @@ jobs:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.79/sni-v0.0.79-windows-amd64.zip -OutFile sni.zip
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/6.4/win-x64.zip -OutFile enemizer.zip
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
python -m pip install --upgrade pip setuptools==60.10.0 # 61 does not work with the current layout
python -m pip install --upgrade pip setuptools
pip install -r requirements.txt
python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1]
@@ -63,16 +63,16 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-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/6.4/ubuntu.16.04-x64.7z
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0/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==60.10.0 # setuptools same as windows
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt

View File

@@ -51,11 +51,11 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-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/6.4/ubuntu.16.04-x64.7z
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import copy
from enum import Enum, unique
from enum import unique, IntEnum, IntFlag
import logging
import json
import functools
@@ -790,7 +790,7 @@ class CollectionState():
or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool:
if self.world.retro[player]:
if self.world.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)
@@ -911,7 +911,7 @@ class CollectionState():
@unique
class RegionType(int, Enum):
class RegionType(IntEnum):
Generic = 0
LightWorld = 1
DarkWorld = 2
@@ -1066,7 +1066,7 @@ class Boss():
return f"Boss({self.name})"
class LocationProgressType(Enum):
class LocationProgressType(IntEnum):
DEFAULT = 1
PRIORITY = 2
EXCLUDED = 3
@@ -1138,19 +1138,29 @@ class Location:
return "at " + self.name.replace("_", " ").replace("-", " ")
class Item():
class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
trap = 0b0100 # detrimental or entirely useless (nothing) item
skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items.
progression_skip_balancing = 0b1001 # only progression gets balanced
def as_flag(self) -> int:
"""As Network API flag int."""
return int(self & 0b0111)
class Item:
location: Optional[Location] = None
world: Optional[MultiWorld] = None
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
name: str
game: str = "Generic"
type: str = None
# indicates if this is a negative impact item. Causes these to be handled differently by various games.
trap: bool = False
# change manually to ensure that a specific non-progression item never goes on an excluded location
never_exclude = False
# item is not considered by progression balancing despite being progression
skip_in_prog_balancing: bool = False
classification: ItemClassification
# need to find a decent place for these to live and to allow other games to register texts if they want.
pedestal_credit_text: str = "and the Unknown Item"
@@ -1165,9 +1175,9 @@ class Item():
map: bool = False
compass: bool = False
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
self.name = name
self.advancement = advancement
self.classification = classification
self.player = player
self.code = code
@@ -1179,9 +1189,25 @@ class Item():
def pedestal_hint_text(self):
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
def advancement(self) -> bool:
return bool(self.classification & ItemClassification.progression)
@property
def skip_in_prog_balancing(self) -> bool:
return self.classification == ItemClassification.progression_skip_balancing
@property
def useful(self) -> bool:
return bool(self.classification & ItemClassification.useful)
@property
def trap(self) -> bool:
return bool(self.classification & ItemClassification.trap)
@property
def flags(self) -> int:
return self.advancement + (self.never_exclude << 1) + (self.trap << 2)
return self.classification.as_flag()
def __eq__(self, other):
return self.name == other.name and self.player == other.player
@@ -1490,7 +1516,7 @@ class Tutorial(NamedTuple):
language: str
file_name: str
link: str
author: List[str]
authors: List[str]
seeddigits = 20

View File

@@ -1,229 +1,54 @@
from __future__ import annotations
import os
import logging
import asyncio
import urllib.parse
import sys
import typing
import time
import websockets
import ModuleUpdate
ModuleUpdate.update()
import Utils
if __name__ == "__main__":
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \
keep_alive
from worlds.checksfinder import ChecksFinderWorld
from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
def output(self, text: str):
logger.info(text)
def _cmd_exit(self) -> bool:
"""Close connections and client"""
self.ctx.exit_event.set()
return True
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_name_getter(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"""
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 location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
if location_id in self.ctx.missing_locations:
self.output('Missing: ' + location)
count += 1
elif location_id in self.ctx.checked_locations:
self.output('Checked: ' + location)
count += 1
checked_count += 1
if count:
self.output(
f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}")
else:
self.output("No missing location checks found.")
return True
def _cmd_items(self):
"""List all item names for the currently running game."""
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
class ChecksFinderClientCommandProcessor(ClientCommandProcessor):
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
self.ctx.syncing = True
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
state = ClientStatus.CLIENT_READY
self.output("Readied up.")
else:
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str):
raw = self.ctx.on_user_say(raw)
if raw:
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext():
tags: typing.Set[str] = {"AP"}
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: int = ClientCommandProcessor
game = None
ui = None
keep_alive_task = None
items_handling: typing.Optional[int] = None
current_energy_link_value = 0 # to display in UI, gets set by server
class ChecksFinderContext(CommonContext):
command_processor: int = ChecksFinderClientCommandProcessor
game = "ChecksFinder"
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
# server state
super(ChecksFinderContext, self).__init__(server_address, password)
self.send_index: int = 0
self.server_address = server_address
self.password = password
self.syncing = False
self.awaiting_bridge = False
self.server_task = None
self.server: typing.Optional[Endpoint] = None
self.server_version = Version(0, 0, 0)
self.hint_cost: typing.Optional[int] = None
self.games: typing.Dict[int, str] = {}
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
# own state
self.finished_game = False
self.ready = False
self.team = None
self.slot = None
self.auth = None
self.seed_name = None
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(ChecksFinderContext, self).server_auth(password_requested)
if not self.auth: # TODO: Replace this if block with await self.getusername() once that PR is merged in.
logger.info('Enter slot name:')
self.auth = await self.console_input()
self.locations_checked: typing.Set[int] = set() # local state
self.locations_scouted: typing.Set[int] = set()
self.items_received = []
self.missing_locations: typing.Set[int] = set()
self.checked_locations: typing.Set[int] = set() # server state
self.locations_info = {}
self.input_queue = asyncio.Queue()
self.input_requests = 0
self.last_death_link: float = time.time() # last send/received death link on AP layer
# game state
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
self.slow_mode = False
self.jsontotextparser = JSONtoTextParser(self)
self.set_getters(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected."""
if self.checked_locations or self.missing_locations:
return len(self.checked_locations | self.missing_locations)
await self.send_connect()
async def connection_closed(self):
self.auth = None
self.items_received = []
self.locations_info = {}
self.server_version = Version(0, 0, 0)
if self.server and self.server.socket is not None:
await self.server.socket.close()
self.server = None
self.server_task = None
await super(ChecksFinderContext, self).connection_closed()
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files:
if file.find("obtain") <= -1:
os.remove(root+"/"+file)
# noinspection PyAttributeOutsideInit
def set_getters(self, data_package: dict, network=False):
if not network: # local data; check if newer data was already downloaded
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
if local_package and local_package["version"] > network_data_package["version"]:
data_package: dict = local_package
elif network: # check if data from server is newer
if data_package["version"] > network_data_package["version"]:
Utils.persistent_store("datapackage", "latest", network_data_package)
item_lookup: dict = {}
locations_lookup: dict = {}
for game, gamedata in data_package["games"].items():
for item_name, item_id in gamedata["item_name_to_id"].items():
item_lookup[item_id] = item_name
for location_name, location_id in gamedata["location_name_to_id"].items():
locations_lookup[location_id] = location_name
def get_item_name_from_id(code: int):
return item_lookup.get(code, f'Unknown item (ID:{code})')
self.item_name_getter = get_item_name_from_id
def get_location_name_from_address(address: int):
return locations_lookup.get(address, f'Unknown location (ID:{address})')
self.location_name_getter = get_location_name_from_address
os.remove(root + "/" + file)
@property
def endpoints(self):
@@ -232,346 +57,53 @@ class CommonContext():
else:
return []
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
async def send_msgs(self, msgs):
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(encode(msgs))
def consume_players_package(self, package: typing.List[tuple]):
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
self.player_names[0] = "Archipelago"
def event_invalid_slot(self):
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
def event_invalid_game(self):
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
logger.info('Enter the password required to join this game:')
self.password = await self.console_input()
return self.password
async def send_connect(self, **kwargs):
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address=None):
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def on_print(self, args: dict):
logger.info(args["text"])
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
def on_package(self, cmd: str, args: dict):
pass
def on_user_say(self, text: str) -> typing.Optional[str]:
"""Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned."""
return text
def update_permissions(self, permissions: typing.Dict[str, int]):
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
self.permissions[permission_name] = flag.name
except Exception as e: # safeguard against permissions that may be implemented in the future
logger.exception(e)
async def shutdown(self):
self.server_address = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
await self.server_task
while self.input_requests > 0:
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
await super(ChecksFinderContext, self).shutdown()
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files:
if file.find("obtain") <= -1:
os.remove(root+"/"+file)
# DeathLink hooks
def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "")
if text:
logger.info(f"DeathLink: {text}")
else:
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
await self.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"],
"data": {
"time": self.last_death_link,
"source": self.player_names[self.slot],
"cause": death_text
}
}])
async def update_death_link(self, death_link):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
else:
self.tags -= {"DeathLink"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
async def server_loop(ctx: CommonContext, address=None):
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
return
if address is None: # set through CLI or APBP
address = ctx.server_address
# Wait for the user to provide a multiworld server address
if not address:
logger.info('Please connect to an Archipelago server.')
return
address = f"ws://{address}" if "://" not in address else address
port = urllib.parse.urlparse(address).port or 38281
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
async for data in ctx.server.socket:
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError:
if cached_address:
logger.error('Unable to connect to multiworld server at cached address. '
'Please use the connect button above.')
else:
logger.exception('Connection refused by the multiworld server')
except websockets.InvalidURI:
logger.exception('Failed to connect to the multiworld server (invalid URI)')
except (OSError, websockets.InvalidURI):
logger.exception('Failed to connect to the multiworld server')
except Exception as e:
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
finally:
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2
async def process_server_cmd(ctx: CommonContext, args: dict):
try:
cmd = args["cmd"]
except:
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
else:
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
ctx.update_permissions(args.get("permissions", {}))
if "games" in args:
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
if len(args['players']) < 1:
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
logger.info('Connected Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")
ctx.set_getters(args['data'], network=True)
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.event_invalid_game()
elif 'SlotAlreadyTaken' in errors:
raise Exception('Player slot already in use for that team')
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem
elif 'InvalidPassword' in errors:
logger.error('Invalid password')
ctx.password = None
await ctx.server_auth(True)
elif errors:
raise Exception("Unknown connection errors: " + str(errors))
else:
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
ctx.team = args["team"]
ctx.slot = args["slot"]
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
if ctx.locations_scouted:
msgs.append({"cmd": "LocationScouts",
"locations": list(ctx.locations_scouted)})
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
# Get the server side view of missing as of time of connecting.
# This list is used to only send to the server what is reported as ACTUALLY Missing.
# This also serves to allow an easy visual of what locations were already checked previously
# when /missing is used for the client side view of what is missing.
ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"])
for ss in ctx.checked_locations:
filename = f"send{ss}"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
f.close()
elif cmd == 'ReceivedItems':
start_index = args["index"]
if start_index == 0:
ctx.items_received = []
elif start_index != len(ctx.items_received):
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
for item in args['items']:
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
f.write(str(NetworkItem(*item).item))
f.close()
ctx.items_received.append(NetworkItem(*item))
ctx.watcher_event.set()
elif cmd == 'LocationInfo':
for item, location, player in args['locations']:
if location not in ctx.locations_info:
ctx.locations_info[location] = (item, player)
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
if "players" in args:
ctx.consume_players_package(args["players"])
if "hint_points" in args:
ctx.hint_points = args['hint_points']
if "checked_locations" in args:
checked = set(args["checked_locations"])
ctx.checked_locations |= checked
ctx.missing_locations -= checked
for ss in ctx.checked_locations:
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.close()
if "permissions" in args:
ctx.update_permissions(args["permissions"])
if cmd in {"ReceivedItems"}:
start_index = args["index"]
if start_index != len(self.items_received):
for item in args['items']:
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.write(str(NetworkItem(*item).item))
f.close()
elif cmd == 'Print':
ctx.on_print(args)
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
f.close()
elif cmd == 'PrintJSON':
ctx.on_print_json(args)
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
elif cmd == 'InvalidPacket':
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
class ChecksFinderManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago ChecksFinder Client"
elif cmd == "Bounced":
tags = args.get("tags", [])
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
ctx.on_deathlink(args["data"])
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
else:
logger.debug(f"unknown command {cmd}")
ctx.on_package(cmd, args)
self.ui = ChecksFinderManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def game_watcher(ctx: CommonContext):
async def game_watcher(ctx: ChecksFinderContext):
from worlds.checksfinder.Locations import lookup_id_to_name
while not ctx.exit_event.is_set():
if ctx.syncing == True:
@@ -600,38 +132,12 @@ async def game_watcher(ctx: CommonContext):
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
game = "ChecksFinder"
items_handling = 0b111 # full remote
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.games.get(self.slot, None)
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx = ChecksFinderContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
input_task = None
if gui_enabled:
from kvui import ChecksFinderManager
ctx.ui = ChecksFinderManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
@@ -641,11 +147,6 @@ if __name__ == '__main__':
await progression_watcher
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
@@ -653,8 +154,5 @@ if __name__ == '__main__':
args, rest = parser.parse_known_args()
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -56,7 +56,7 @@ class ClientCommandProcessor(CommandProcessor):
"""List all received items"""
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
@@ -114,29 +114,55 @@ class ClientCommandProcessor(CommandProcessor):
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext():
class CommonContext:
# Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
# datapackage
# 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})')
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: int = ClientCommandProcessor
game: typing.Optional[str] = None
command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None
ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None
items_handling: typing.Optional[int] = None
slot_info: typing.Dict[int, NetworkSlot]
server_task: typing.Optional[asyncio.Task] = None
server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0)
current_energy_link_value: int = 0 # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: str
password: typing.Optional[str]
hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str]
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
missing_locations: typing.Set[int]
checked_locations: typing.Set[int] # server state
locations_info: typing.Dict[int, NetworkItem]
# internals
# current message box through kvui
_messagebox = None
def __init__(self, server_address, password):
# server state
self.server_address = server_address
self.password = password
self.server_task = None
self.server: typing.Optional[Endpoint] = None
self.server_version = Version(0, 0, 0)
self.hint_cost: typing.Optional[int] = None
self.games: typing.Dict[int, str] = {}
self.hint_cost = None
self.slot_info = {}
self.permissions = {
"forfeit": "disabled",
@@ -152,26 +178,23 @@ class CommonContext():
self.auth = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set() # local state
self.locations_scouted: typing.Set[int] = set()
self.locations_checked = set() # local state
self.locations_scouted = set()
self.items_received = []
self.missing_locations: typing.Set[int] = set()
self.checked_locations: typing.Set[int] = set() # server state
self.locations_info: typing.Dict[int, NetworkItem] = {}
self.missing_locations = set()
self.checked_locations = set() # server state
self.locations_info = {}
self.input_queue = asyncio.Queue()
self.input_requests = 0
self.last_death_link: float = time.time() # last send/received death link on AP layer
# game state
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.player_names = {0: "Archipelago"}
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
self.slow_mode = False
self.jsontotextparser = JSONtoTextParser(self)
self.set_getters(network_data_package)
self.update_datapackage(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@@ -196,7 +219,6 @@ class CommonContext():
self.server_version = Version(0, 0, 0)
self.server = None
self.server_task = None
self.games = {}
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
@@ -204,35 +226,6 @@ class CommonContext():
"remaining": "disabled",
}
# noinspection PyAttributeOutsideInit
def set_getters(self, data_package: dict, network=False):
if not network: # local data; check if newer data was already downloaded
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
if local_package and local_package["version"] > network_data_package["version"]:
data_package: dict = local_package
elif network: # check if data from server is newer
if data_package["version"] > network_data_package["version"]:
Utils.persistent_store("datapackage", "latest", network_data_package)
item_lookup: dict = {}
locations_lookup: dict = {}
for game, gamedata in data_package["games"].items():
for item_name, item_id in gamedata["item_name_to_id"].items():
item_lookup[item_id] = item_name
for location_name, location_id in gamedata["location_name_to_id"].items():
locations_lookup[location_id] = location_name
def get_item_name_from_id(code: int) -> str:
return item_lookup.get(code, f'Unknown item (ID:{code})')
self.item_name_getter = get_item_name_from_id
def get_location_name_from_address(address: int) -> str:
return locations_lookup.get(address, f'Unknown location (ID:{address})')
self.location_name_getter = get_location_name_from_address
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
@@ -279,6 +272,13 @@ class CommonContext():
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def slot_concerns_self(self, slot) -> bool:
if slot == self.slot:
return True
if slot in self.slot_info:
return self.slot in self.slot_info[slot].group_members
return False
def on_print(self, args: dict):
logger.info(args["text"])
@@ -308,7 +308,7 @@ class CommonContext():
logger.exception(e)
async def shutdown(self):
self.server_address = None
self.server_address = ""
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
@@ -323,6 +323,50 @@ class CommonContext():
if self.input_task:
self.input_task.cancel()
# DataPackage
async def prepare_datapackage(self, relevant_games: typing.Set[str],
remote_datepackage_versions: typing.Dict[str, int]):
"""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:
remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game
needed_updates.add(game)
continue
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
# no action required if local version is new enough
if remote_version > local_version:
cache_version: int = cache_package.get(game, {}).get("version", 0)
# download remote version if cache is not new enough
if remote_version > cache_version:
needed_updates.add(game)
else:
self.update_game(cache_package[game])
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
def update_game(self, game_package: dict):
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
def update_datapackage(self, data_package: dict):
for game, gamedata in data_package["games"].items():
self.update_game(gamedata)
def consume_network_datapackage(self, data_package: dict):
self.update_datapackage(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
# DeathLink hooks
def on_deathlink(self, data: dict):
@@ -356,6 +400,27 @@ class CommonContext():
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]):
"""Displays an error messagebox"""
if not self.ui:
return
title = title or "Error"
from kvui import MessageBox
if self._messagebox:
self._messagebox.dismiss()
# make "Multiple exceptions" look nice
text = str(text).replace('[Errno', '\n[Errno').strip()
# split long messages into title and text
parts = title.split('. ', 1)
if len(parts) == 1:
parts = title.split(', ', 1)
if len(parts) > 1:
text = parts[1] + '\n\n' + text
title = parts[0]
# display error
self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
@@ -418,14 +483,22 @@ async def server_loop(ctx: CommonContext, address=None):
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError:
logger.exception('Connection refused by the server. May not be running Archipelago on that address or port.')
except websockets.InvalidURI:
logger.exception('Failed to connect to the multiworld server (invalid URI)')
except OSError:
logger.exception('Failed to connect to the multiworld server')
except Exception:
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
except ConnectionRefusedError as e:
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except websockets.InvalidURI as e:
msg = 'Failed to connect to the multiworld server (invalid URI)'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
finally:
await ctx.connection_closed()
if ctx.server_address:
@@ -448,7 +521,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
raise
if cmd == 'RoomInfo':
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
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)
else:
logger.info('--------------------------------')
logger.info('Room Information:')
@@ -462,33 +537,32 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if args['password']:
logger.info('Password required')
ctx.update_permissions(args.get("permissions", {}))
if "games" in args:
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
if len(args['players']) < 1:
players = args.get("players", [])
if len(players) < 1:
logger.info('No player connected')
else:
args['players'].sort()
players.sort()
current_team = -1
logger.info('Connected Players:')
for network_player in args['players']:
for network_player in players:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
# update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")
ctx.set_getters(args['data'], network=True)
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_datapackage(args['data'])
elif cmd == 'ConnectionRefused':
errors = args["errors"]
@@ -642,7 +716,7 @@ if __name__ == '__main__':
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.games.get(self.slot, None)
self.game = self.slot_info[self.slot].game
async def main(args):

View File

@@ -39,6 +39,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
@@ -48,7 +49,6 @@ class FF1Context(CommonContext):
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'Final Fantasy'
self.awaiting_rom = False
self.display_msgs = True
@@ -68,14 +68,13 @@ class FF1Context(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.game = self.games.get(self.slot, None)
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)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}"
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == 'PrintJSON':
print_type = args['type']
@@ -85,20 +84,20 @@ class FF1Context(CommonContext):
sending_player_id = item.player
sending_player_name = self.player_names[item.player]
if print_type == 'Hint':
msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \
f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}"
msg = f"Hint: Your {self.item_names[item.item]} is at" \
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
self._set_message(msg, item.item)
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
if sending_player_id == self.slot:
if receiving_player_id == self.slot:
msg = f"You found your own {self.item_name_getter(item.item)}"
msg = f"You found your own {self.item_names[item.item]}"
else:
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
else:
if receiving_player_id == sending_player_id:
msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}"
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
else:
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
f"{receiving_player_name}"
self._set_message(msg, item.item)
@@ -151,13 +150,13 @@ async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bo
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_name_getter(location)}")
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_name_getter(location) for location in locations_checked])
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}

View File

@@ -150,7 +150,9 @@ async def game_watcher(ctx: FactorioContext):
next_bridge = time.perf_counter() + 1
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if data["slot_name"] != ctx.auth:
if not ctx.auth:
pass # auth failed, wait for new attempt
elif data["slot_name"] != ctx.auth:
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
elif data["seed_name"] != ctx.seed_name:
bridge_logger.warning(
@@ -342,8 +344,10 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
await asyncio.sleep(0.01)
except Exception as e:
logger.exception(e)
logger.error("Aborted Factorio Server Bridge")
logger.exception(e, extra={"compact_gui": True})
msg = "Aborted Factorio Server Bridge"
logger.error(msg)
ctx.gui_error(msg, e)
ctx.exit_event.set()
else:

View File

@@ -144,7 +144,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
for item in itempool:
if item.advancement:
progitempool.append(item)
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player].value:
localrestitempool[item.player].append(item)

View File

@@ -108,18 +108,22 @@ 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 os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
if file.is_file() and not file.name.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:
weights_cache[fname] = read_weights_yamls(path)
except Exception as e:
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
else:
for yaml in weights_cache[fname]:
print(f"P{player_id} Weights: {fname} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = fname
player_id += 1
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items():
for yaml in yaml_data:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
args.multi = max(player_id-1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "

View File

@@ -15,16 +15,11 @@ import sys
from typing import Iterable, Sequence, Callable, Union, Optional
import subprocess
import itertools
from Utils import is_frozen, user_path, local_path, init_logging
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
is_windows, is_macos, is_linux
from shutil import which
import shlex
from enum import Enum, auto
import logging
is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys")
def open_host_yaml():
@@ -42,22 +37,16 @@ def open_host_yaml():
def open_patch():
suffixes = []
for c in components:
if isfile(get_exe(c)[-1]):
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) else []
try:
import tkinter
import tkinter.filedialog
filename = open_filename('Select patch', (('Patches', suffixes),))
except Exception as e:
logging.error("Could not load tkinter, which is likely not installed. "
"This attempt was made because Launcher.open_patch was used.")
raise e
messagebox('Error', str(e), error=True)
else:
root = tkinter.Tk()
root.withdraw()
suffixes = []
for c in components:
if isfile(get_exe(c)[-1]):
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) else []
filename = tkinter.filedialog.askopenfilename(filetypes=(('Patches', ' '.join(suffixes)),))
file, _, component = identify(filename)
if file and component:
launch([*get_exe(component), file], component.cli)
@@ -217,14 +206,7 @@ def launch(exe, in_terminal=False):
def run_gui():
if not sys.stdout:
from kvui import App, ContainerLayout, GridLayout, Button, Label # this kills stdout
else:
from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout as ContainerLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kvui import App, ContainerLayout, GridLayout, Button, Label
class Launcher(App):
base_title: str = "Archipelago Launcher"

18
Main.py
View File

@@ -1,4 +1,3 @@
import copy
import collections
from itertools import zip_longest, chain
import logging
@@ -145,13 +144,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# 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]):
advancement = set()
classifications = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
if item.advancement:
advancement.add(item.name)
classifications[item.name] |= item.classification
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
@@ -169,18 +167,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
for player in players:
del(counters[player][item])
return counters, advancement
return counters, classifications
common_item_count, common_advancement_items = find_common_pool(group["players"], group["item_pool"])
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items():
advancement = item_name in common_advancement_items
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
new_item.advancement = advancement
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
@@ -265,7 +263,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# collect ER hint info
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
world.shuffle[player] != "vanilla" or world.retro[player]}
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
for region in world.regions:
if region.player in er_hint_data and region.locations:
@@ -305,7 +303,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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[player]]:
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)

View File

@@ -13,12 +13,12 @@ import logging
import requests
import Utils
from Utils import is_windows
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
is_windows = sys.platform in ("win32", "cygwin", "msys")
def prompt_yes_no(prompt):
@@ -196,8 +196,8 @@ def download_java(java: str):
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
jdk = find_jdk(java_version)
if jdk is not None:
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
@@ -208,8 +208,7 @@ def install_forge(directory: str, forge_version: str, java_version: str):
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar + "\"", "--installServer", "\"" + directory + "\""])
install_process = Popen(argstring, shell=not is_windows)
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
@@ -228,15 +227,15 @@ def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
win_args = []
forge_args = []
with open(args_file) as argfile:
for line in argfile:
win_args.append(line.strip())
forge_args.extend(line.strip().split(" "))
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
logging.info(f"Running Forge server: {argstring}")
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(argstring, shell=not is_windows)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
@@ -254,10 +253,10 @@ def get_minecraft_versions(version, release_channel="release"):
local = True
if local:
with open(Utils.local_path("minecraft_versions.json"), 'r') as f:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.local_path("minecraft_versions.json"), 'w') as f:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
@@ -299,13 +298,16 @@ if __name__ == '__main__':
apmc_data = None
data_version = 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:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
@@ -313,11 +315,13 @@ if __name__ == '__main__':
if args.install:
if is_windows:
print("Installing Java and Minecraft Forge")
print("Installing Java")
download_java(java_version)
else:
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:

View File

@@ -23,6 +23,11 @@ ModuleUpdate.update()
import websockets
import colorama
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
except ImportError:
OperationalError = ConnectionError
import NetUtils
from worlds.AutoWorld import AutoWorldRegister
@@ -404,12 +409,16 @@ class Context:
def save_regularly():
import time
while not self.exit_event.is_set():
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving via thread.")
try:
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving via thread.")
self._save()
except OperationalError as e:
logging.exception(e)
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
self._save()
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
@@ -446,22 +455,9 @@ class Context:
def set_save(self, savedata: dict):
if self.connect_names != savedata["connect_names"]:
raise Exception("This savegame does not appear to match the loaded multiworld.")
if "version" not in savedata:
# upgrade from version 1
# this is not perfect but good enough for old games to continue
for old, items in savedata["received_items"].items():
self.received_items[(*old, True)] = items
self.received_items[(*old, False)] = items.copy()
for (team, slot, remote) in self.received_items:
# remove start inventory from items, since this is separate now
start_inventory = get_start_inventory(self, slot, slot in self.remote_start_inventory)
if start_inventory:
del self.received_items[team, slot, remote][:len(start_inventory)]
logging.info("Upgraded save data")
elif savedata["version"] > self.save_version:
if savedata["version"] > self.save_version:
raise Exception("This savegame is newer than the server.")
else:
self.received_items = savedata["received_items"]
self.received_items = savedata["received_items"]
self.hints_used.update(savedata["hints_used"])
self.hints.update(savedata["hints"])
@@ -514,6 +510,11 @@ class Context:
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
def slot_set(self, slot) -> typing.Set[int]:
"""Returns the slot IDs that concern that slot,
as in expands groups out and returns back the input for solo."""
return self.groups.get(slot, {slot})
def _set_options(self, server_options: dict):
for key, value in server_options.items():
data_type = self.simple_options.get(key, None)
@@ -551,35 +552,37 @@ class Context:
collect_player(self, client.team, client.slot)
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
"""Send and remember hints"""
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in ctx.hints[team, hint.finding_player]]
if not hints:
return
concerns = collections.defaultdict(list)
for hint in hints:
net_msg = hint.as_network_message()
if hint.receiving_player in ctx.groups:
for player in ctx.groups[hint.receiving_player]:
concerns[player].append(net_msg)
else:
concerns[hint.receiving_player].append(net_msg)
if not hint.local and net_msg not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(net_msg)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message())
for player in ctx.slot_set(hint.receiving_player):
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
if not hint.found:
ctx.hints[team, hint.finding_player].add(hint)
if hint.receiving_player in ctx.groups:
for player in ctx.groups[hint.receiving_player]:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in ctx.hints[team, hint.finding_player]:
ctx.hints[team, hint.finding_player].add(hint)
for player in ctx.slot_set(hint.receiving_player):
ctx.hints[team, player].add(hint)
else:
ctx.hints[team, hint.receiving_player].add(hint)
for text in (format_hint(ctx, team, hint) for hint in hints):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
if hints:
for slot, clients in ctx.clients[team].items():
client_hints = concerns[slot]
if client_hints:
for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints))
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(ctx, team, hint)))
for slot, hint_data in concerns.items():
clients = ctx.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int):
@@ -628,9 +631,9 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
# TODO remove around 0.4
'players': players,
# TODO remove around 0.2.5 in favor of slot_info ?
# Maybe convert into a list of games that are present to fetch relevant datapackage entries before Connect?
# TODO convert to list of games present in 0.4
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
@@ -799,8 +802,7 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
targets = ctx.groups.get(target_slot, [target_slot])
for target in targets:
for target in ctx.slot_set(target_slot):
for item in items:
if item.player != target_slot:
get_received_items(ctx, team, target, False).append(item)
@@ -838,16 +840,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
slots = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
if slot in group:
slots.append(group_id)
slots.add(group_id)
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
item_id, receiving_player, item_flags = result
if (receiving_player == slot or receiving_player in slots) and item_id == seeked_item_id:
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
@@ -1276,7 +1276,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
elif not for_location and hint_name in world.item_name_groups: # item group name
hints = []
for item in world.item_name_groups[hint_name]:
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
if item in world.item_name_to_id: # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
elif not for_location and hint_name in world.item_names: # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name
@@ -1537,7 +1538,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == 'LocationScouts':
locs = []
create_as_hint = args.get("create_as_hint", False)
create_as_hint: int = int(args.get("create_as_hint", 0))
hints = []
for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name:
@@ -1550,7 +1551,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
notify_hints(ctx, client.team, hints)
notify_hints(ctx, client.team, hints, only_new=create_as_hint == 2)
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@@ -1777,7 +1778,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
if item in world.item_name_groups:
hints = []
for item in world.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item))
if item in world.item_name_to_id: # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item))
else: # item name
hints = collect_hints(self.ctx, team, slot, item)
@@ -1956,18 +1958,8 @@ async def main(args: argparse.Namespace):
try:
if not data_filename:
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error("Could not load tkinter, which is likely not installed. "
"This attempt was made because no .archipelago file was provided as argument. "
"Either provide a file or ensure the tkinter package is installed.")
raise e
else:
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
data_filename = Utils.open_filename("Select multiworld data", filetypes)
ctx.load(data_filename, args.use_embedded_options)

View File

@@ -96,6 +96,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
separators=(',', ':'),
).encode
@@ -235,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
node["color"] = 'cyan'
elif flags & 0b001: # advancement
node["color"] = 'plum'
elif flags & 0b010: # never_exclude
elif flags & 0b010: # useful
node["color"] = 'slateblue'
elif flags & 0b100: # trap
node["color"] = 'salmon'
@@ -245,7 +246,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.item_name_getter(item_id)
node["text"] = self.ctx.item_names[item_id]
return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart):
@@ -254,7 +255,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_location_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.location_name_getter(item_id)
node["text"] = self.ctx.location_names[item_id]
return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):

View File

@@ -28,8 +28,12 @@ class AssembleOptions(abc.ABCMeta):
options.update(new_options)
# apply aliases, without name_lookup
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")})
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
options.update(aliases)
# auto-validate schema on __init__
if "schema" in attrs.keys():
@@ -379,35 +383,7 @@ class Range(NumericOption):
def from_text(cls, text: str) -> Range:
text = text.lower()
if text.startswith("random"):
if text == "random-low":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
elif text == "random-high":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
elif text == "random-middle":
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
elif text.startswith("random-range-"):
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
elif text.startswith("random-range-middle"):
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
elif text.startswith("random-range-high"):
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
else:
return cls(int(round(random.randint(random_range[0], random_range[1]))))
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"):
return cls(cls.default)
elif text == "high":
@@ -425,6 +401,45 @@ class Range(NumericOption):
return cls(0)
return cls(int(text))
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
@classmethod
def custom_range(cls, text) -> Range:
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
else:
return cls(random.randint(random_range[0], random_range[1]))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
if type(data) == int:
@@ -438,6 +453,41 @@ class Range(NumericOption):
def __str__(self) -> str:
return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
return int(round(random.triangular(lower, end, tri), 0))
class SpecialRange(Range):
special_range_cutoff = 0
special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
if text in cls.special_range_names:
return cls(cls.special_range_names[text])
return super().from_text(text)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
elif text == "random-high":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class VerifyKeys:
valid_keys = frozenset()
@@ -581,13 +631,18 @@ class Accessibility(Choice):
default = 1
class ProgressionBalancing(Range):
class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
range_start = 0
range_end = 99
display_name = "Progression Balancing"
special_range_names = {
"disabled": 0,
"normal": 50,
"extreme": 99,
}
common_options = {
@@ -705,8 +760,6 @@ class ItemLinks(OptionList):
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
per_game_common_options = {
**common_options, # can be overwritten per-game
"local_items": LocalItems,

View File

@@ -66,7 +66,10 @@ Contributions are welcome. We have a few asks of any new contributors.
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
For adding a new game to Archipelago please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
## FAQ
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
## Code of Conduct
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:

View File

@@ -10,19 +10,23 @@ import base64
import shutil
import logging
import asyncio
import enum
import typing
from json import loads, dumps
import ModuleUpdate
ModuleUpdate.update()
from Utils import init_logging
from Utils import init_logging, messagebox
if __name__ == "__main__":
init_logging("SNIClient", exception_logger="Client")
import colorama
import websockets
from NetUtils import *
from NetUtils import ClientStatus, color
from worlds.alttp import Regions, Shops
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
@@ -74,7 +78,10 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
snes_device_number = int(options[1])
self.ctx.snes_reconnect_address = None
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
if self.ctx.snes_connect_task:
self.ctx.snes_connect_task.cancel()
self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number),
name="SNES Connect")
return True
def _cmd_snes_close(self) -> bool:
@@ -111,6 +118,7 @@ class Context(CommonContext):
command_processor = SNIClientCommandProcessor
game = "A Link to the Past"
items_handling = None # set in game_watcher
snes_connect_task: typing.Optional[asyncio.Task] = None
def __init__(self, snes_address, server_address, password):
super(Context, self).__init__(server_address, password)
@@ -128,6 +136,7 @@ class Context(CommonContext):
self.death_state = DeathState.alive # for death link flop behaviour
self.killing_player_task = None
self.allow_collect = False
self.slow_mode = False
self.awaiting_rom = False
self.rom = None
@@ -176,6 +185,11 @@ class Context(CommonContext):
if not currently_dead:
self.death_state = DeathState.alive
async def shutdown(self):
await super(Context, self).shutdown()
if self.snes_connect_task:
await self.snes_connect_task
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}:
if "checked_locations" in args and args["checked_locations"]:
@@ -640,7 +654,7 @@ async def _snes_connect(ctx: Context, address: str):
return snes_socket
async def get_snes_devices(ctx: Context):
async def get_snes_devices(ctx: Context) -> typing.List[str]:
socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll
DeviceList_Request = {
"Opcode": "DeviceList",
@@ -648,19 +662,20 @@ async def get_snes_devices(ctx: Context):
}
await socket.send(dumps(DeviceList_Request))
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
reply: dict = loads(await socket.recv())
devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
if not devices:
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
while not devices:
await asyncio.sleep(1)
while not devices and not ctx.exit_event.is_set():
await asyncio.sleep(0.1)
await socket.send(dumps(DeviceList_Request))
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
await verify_snes_app(socket)
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
if devices:
await verify_snes_app(socket)
await socket.close()
return devices
return sorted(devices)
async def verify_snes_app(socket):
@@ -878,7 +893,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
def new_check(location_id):
new_locations.append(location_id)
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
location = ctx.location_names[location_id]
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
@@ -1111,9 +1126,9 @@ async def game_watcher(ctx: Context):
item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'),
color(ctx.item_names[item.item], 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
@@ -1168,7 +1183,7 @@ async def game_watcher(ctx: Context):
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
location = ctx.location_names[location_id]
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
@@ -1185,7 +1200,10 @@ async def game_watcher(ctx: Context):
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
locationId = (item.location - locations_start_id) if item.location >= 0 and bool(ctx.items_handling & 0b010) else 0x00
if bool(ctx.items_handling & 0b010):
locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF
else:
locationId = 0x00 #backward compat
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
@@ -1194,9 +1212,9 @@ async def game_watcher(ctx: Context):
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'),
color(ctx.item_names[item.item], 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
elif ctx.game == GAME_SMZ3:
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
@@ -1237,7 +1255,7 @@ async def game_watcher(ctx: Context):
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
location = ctx.location_names[location_id]
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
@@ -1258,8 +1276,8 @@ async def game_watcher(ctx: Context):
itemOutPtr += 1
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
@@ -1285,7 +1303,11 @@ async def main():
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating sfc rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
try:
meta, romfile = Patch.create_rom_file(args.diff_file)
except Exception as e:
messagebox('Error', str(e), True)
raise
if "server" in meta:
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
@@ -1297,7 +1319,7 @@ async def main():
import time
time.sleep(3)
sys.exit()
elif args.diff_file.endswith((".apbp", "apz3")):
elif args.diff_file.endswith((".apbp", ".apz3", ".aplttp")):
adjustedromfile, adjusted = get_alttp_settings(romfile)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
else:
@@ -1311,7 +1333,7 @@ async def main():
ctx.run_gui()
ctx.run_cli()
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait()
@@ -1320,15 +1342,12 @@ async def main():
ctx.snes_reconnect_address = None
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
if snes_connect_task:
snes_connect_task.cancel()
await watcher_task
await ctx.shutdown()
def get_alttp_settings(romfile: str):
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
adjustedromfile = ''
if lastSettings:
choice = 'no'
@@ -1351,8 +1370,13 @@ def get_alttp_settings(romfile: str):
if gui_enabled:
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
applyPromptWindow = Tk()
try:
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
applyPromptWindow = Tk()
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed.')
return '', False
applyPromptWindow.resizable(False, False)
applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick())
logo = PhotoImage(file=Utils.local_path('data', 'icon.png'))

View File

@@ -3,18 +3,21 @@ from __future__ import annotations
import multiprocessing
import logging
import asyncio
import nest_asyncio
import os.path
import nest_asyncio
import sc2
from sc2.main import run_game
from sc2.data import Race
from sc2.bot_ai import BotAI
from sc2.player import Bot
from worlds.sc2wol.Regions import MissionInfo
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Items import lookup_id_to_name, item_table
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol import SC2WoLWorld
from Utils import init_logging
@@ -33,13 +36,12 @@ nest_asyncio.apply()
class StarcraftClientProcessor(ClientCommandProcessor):
ctx: Context
missions_unlocked = False
ctx: SC2Context
def _cmd_disable_mission_check(self) -> bool:
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
the next mission in a chain the other player is doing."""
self.missions_unlocked = True
self.ctx.missions_unlocked = True
sc2_logger.info("Mission check has been disabled")
def _cmd_play(self, mission_id: str = "") -> bool:
@@ -51,20 +53,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
if num_options > 0:
mission_number = int(options[0])
if self.missions_unlocked or \
is_mission_available(mission_number, self.ctx.checked_locations, self.ctx.mission_req_table):
if self.ctx.sc2_run_task:
if not self.ctx.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!")
self.ctx.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
if self.ctx.slot is None:
sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.")
self.ctx.sc2_run_task = asyncio.create_task(starcraft_launch(self.ctx, mission_number),
name="Starcraft 2 Launch")
else:
sc2_logger.info(
"This mission is not currently unlocked. Use /unfinished or /available to see what is available.")
self.ctx.play_mission(mission_number)
else:
sc2_logger.info(
@@ -85,7 +74,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
return True
class Context(CommonContext):
class SC2Context(CommonContext):
command_processor = StarcraftClientProcessor
game = "Starcraft 2 Wings of Liberty"
items_handling = 0b111
@@ -99,10 +88,13 @@ class Context(CommonContext):
announcements = []
announcement_pos = 0
sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked = False
current_tooltip = None
last_loc_list = None
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(Context, self).server_auth(password_requested)
await super(SC2Context, self).server_auth(password_requested)
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
@@ -115,21 +107,75 @@ class Context(CommonContext):
self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"]
self.mission_req_table = {}
# Compatibility for 0.3.2 server data.
if "category" not in next(iter(slot_req_table)):
for i, mission_data in enumerate(slot_req_table.values()):
mission_data["category"] = wol_default_categories[i]
for mission in slot_req_table:
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
if cmd in {"PrintJSON"}:
noted = False
if "receiving" in args:
if args["receiving"] == self.slot:
if self.slot_concerns_self(args["receiving"]):
self.announcements.append(args["data"])
noted = True
if not noted and "item" in args:
if args["item"].player == self.slot:
return
if "item" in args:
if self.slot_concerns_self(args["item"].player):
self.announcements.append(args["data"])
def run_gui(self):
from kvui import GameManager
from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.uix.gridlayout import GridLayout
from kivy.lang import Builder
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
from kivy.properties import StringProperty
import Utils
class HoverableButton(HoverBehavior, Button):
pass
class MissionButton(HoverableButton):
tooltip_text = StringProperty("Test")
def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
self.popuplabel = ServerToolTip(text=self.text)
self.layout.add_widget(self.popuplabel)
def on_enter(self):
self.popuplabel.text = self.tooltip_text
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
if self.tooltip_text == "":
self.ctx.current_tooltip = None
else:
App.get_running_app().root.add_widget(self.layout)
self.ctx.current_tooltip = self.layout
def on_leave(self):
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
@property
def ctx(self) -> CommonContext:
return App.get_running_app().ctx
class MissionLayout(GridLayout):
pass
class MissionCategory(GridLayout):
pass
class SC2Manager(GameManager):
logging_pairs = [
@@ -138,14 +184,138 @@ class Context(CommonContext):
]
base_title = "Archipelago Starcraft 2 Client"
mission_panel = None
last_checked_locations = {}
mission_id_to_button = {}
launching = False
refresh_from_launching = True
first_check = True
def __init__(self, ctx):
super().__init__(ctx)
def build(self):
container = super().build()
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
self.mission_panel = panel.content = MissionLayout()
self.tabs.add_widget(panel)
Clock.schedule_interval(self.build_mission_table, 0.5)
return container
def build_mission_table(self, dt):
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
not self.refresh_from_launching)) or self.first_check:
self.refresh_from_launching = True
self.mission_panel.clear_widgets()
if self.ctx.mission_req_table:
self.last_checked_locations = self.ctx.checked_locations.copy()
self.first_check = False
self.mission_id_to_button = {}
categories = {}
available_missions = []
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
self.ctx.mission_req_table,
self.ctx, available_missions=available_missions,
unfinished_locations=unfinished_locations)
# separate missions into categories
for mission in self.ctx.mission_req_table:
if not self.ctx.mission_req_table[mission].category in categories:
categories[self.ctx.mission_req_table[mission].category] = []
categories[self.ctx.mission_req_table[mission].category].append(mission)
for category in categories:
category_panel = MissionCategory()
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
# Map is completed
for mission in categories[category]:
text = mission
tooltip = ""
# Map has uncollected locations
if mission in unfinished_missions:
text = f"[color=6495ED]{text}[/color]"
tooltip = f"Uncollected locations:\n"
tooltip += "\n".join(location for location in unfinished_locations[mission])
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
else:
text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: "
if len(self.ctx.mission_req_table[mission].required_world) > 0:
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
req_mission in
self.ctx.mission_req_table[mission].required_world)
if self.ctx.mission_req_table[mission].number > 0:
tooltip += " and "
if self.ctx.mission_req_table[mission].number > 0:
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
mission_button.tooltip_text = tooltip
mission_button.bind(on_press=self.mission_callback)
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
category_panel.add_widget(mission_button)
category_panel.add_widget(Label(text=""))
self.mission_panel.add_widget(category_panel)
elif self.launching:
self.refresh_from_launching = False
self.mission_panel.clear_widgets()
self.mission_panel.add_widget(Label(text="Launching Mission"))
def mission_callback(self, button):
if not self.launching:
self.ctx.play_mission(list(self.mission_id_to_button.keys())
[list(self.mission_id_to_button.values()).index(button)])
self.launching = True
Clock.schedule_once(self.finish_launching, 10)
def finish_launching(self, dt):
self.launching = False
self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
async def shutdown(self):
await super(Context, self).shutdown()
await super(SC2Context, self).shutdown()
if self.sc2_run_task:
self.sc2_run_task.cancel()
def play_mission(self, mission_id):
if self.missions_unlocked or \
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
if self.sc2_run_task:
if not self.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!")
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
if self.slot is None:
sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.")
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
name="Starcraft 2 Launch")
else:
sc2_logger.info(
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
f"Use /unfinished or /available to see what is available.")
async def main():
multiprocessing.freeze_support()
@@ -153,7 +323,7 @@ async def main():
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
args = parser.parse_args()
ctx = Context(args.connect, args.password)
ctx = SC2Context(args.connect, args.password)
ctx.auth = args.name
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
@@ -177,6 +347,13 @@ maps_table = [
"ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
]
wol_default_categories = [
"Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
"Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
"Char", "Char", "Char", "Char"
]
def calculate_items(items):
unit_unlocks = 0
@@ -189,6 +366,7 @@ def calculate_items(items):
protoss_unlock = 0
minerals = 0
vespene = 0
supply = 0
for item in items:
data = lookup_id_to_name[item.item]
@@ -213,9 +391,11 @@ def calculate_items(items):
minerals += item_table[data].number
elif item_table[data].type == "Vespene":
vespene += item_table[data].number
elif item_table[data].type == "Supply":
supply += item_table[data].number
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
lab_unlocks, protoss_unlock, minerals, vespene]
lab_unlocks, protoss_unlock, minerals, vespene, supply]
def calc_difficulty(difficulty):
@@ -231,7 +411,7 @@ def calc_difficulty(difficulty):
return 'X'
async def starcraft_launch(ctx: Context, mission_id):
async def starcraft_launch(ctx: SC2Context, mission_id):
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
ctx.announcements_pos = len(ctx.announcements)
@@ -253,14 +433,14 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
sixth_bonus = False
seventh_bonus = False
eight_bonus = False
ctx: Context = None
ctx: SC2Context = None
mission_id = 0
can_read_game = False
last_received_update = 0
def __init__(self, ctx: Context, mission_id):
def __init__(self, ctx: SC2Context, mission_id):
self.ctx = ctx
self.mission_id = mission_id
@@ -271,11 +451,11 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if iteration == 0:
start_items = calculate_items(self.ctx.items_received)
difficulty = calc_difficulty(self.ctx.difficulty)
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {}".format(
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
difficulty,
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
self.ctx.all_in_choice))
self.ctx.all_in_choice, start_items[10]))
self.last_received_update = len(self.ctx.items_received)
else:
@@ -404,39 +584,6 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
await self.chat_send("LostConnection - Lost connection to game.")
mission_req_table = {
"Liberation Day": MissionInfo(1, 7, [], completion_critical=True),
"The Outlaws": MissionInfo(2, 2, [1], completion_critical=True),
"Zero Hour": MissionInfo(3, 4, [2], completion_critical=True),
"Evacuation": MissionInfo(4, 4, [3]),
"Outbreak": MissionInfo(5, 3, [4]),
"Safe Haven": MissionInfo(6, 1, [5], number=7),
"Haven's Fall": MissionInfo(7, 1, [5], number=7),
"Smash and Grab": MissionInfo(8, 5, [3], completion_critical=True),
"The Dig": MissionInfo(9, 4, [8], number=8, completion_critical=True),
"The Moebius Factor": MissionInfo(10, 9, [9], number=11, completion_critical=True),
"Supernova": MissionInfo(11, 5, [10], number=14, completion_critical=True),
"Maw of the Void": MissionInfo(12, 6, [11], completion_critical=True),
"Devil's Playground": MissionInfo(13, 3, [3], number=4),
"Welcome to the Jungle": MissionInfo(14, 4, [13]),
"Breakout": MissionInfo(15, 3, [14], number=8),
"Ghost of a Chance": MissionInfo(16, 6, [14], number=8),
"The Great Train Robbery": MissionInfo(17, 4, [3], number=6),
"Cutthroat": MissionInfo(18, 5, [17]),
"Engine of Destruction": MissionInfo(19, 6, [18]),
"Media Blitz": MissionInfo(20, 5, [19]),
"Piercing the Shroud": MissionInfo(21, 6, [20]),
"Whispers of Doom": MissionInfo(22, 4, [9]),
"A Sinister Turn": MissionInfo(23, 4, [22]),
"Echoes of the Future": MissionInfo(24, 3, [23]),
"In Utter Darkness": MissionInfo(25, 3, [24]),
"Gates of Hell": MissionInfo(26, 2, [12], completion_critical=True),
"Belly of the Beast": MissionInfo(27, 4, [26], completion_critical=True),
"Shatter the Sky": MissionInfo(28, 5, [26], completion_critical=True),
"All-In": MissionInfo(29, -1, [27, 28], completion_critical=True, or_requirements=True)
}
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
objectives_complete = 0
@@ -445,8 +592,8 @@ def calc_objectives_completed(mission, missions_info, locations_done, unfinished
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
objectives_complete += 1
else:
unfinished_locations[mission].append(ctx.location_name_getter(
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i))
unfinished_locations[mission].append(ctx.location_names[
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
return objectives_complete
@@ -460,7 +607,8 @@ def request_unfinished_missions(locations_done, location_table, ui, ctx):
unlocks = initialize_blank_mission_dict(location_table)
unfinished_locations = initialize_blank_mission_dict(location_table)
unfinished_missions = calc_unfinished_missions(locations_done, location_table, unlocks, unfinished_locations, ctx)
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
unfinished_locations=unfinished_locations)
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
mark_up_objectives(
@@ -477,10 +625,21 @@ def request_unfinished_missions(locations_done, location_table, ui, ctx):
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_unfinished_missions(locations_done, locations, unlocks, unfinished_locations, ctx):
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
available_missions=[]):
unfinished_missions = []
locations_completed = []
available_missions = calc_available_missions(locations_done, locations, unlocks)
if not unlocks:
unlocks = initialize_blank_mission_dict(locations)
if not unfinished_locations:
unfinished_locations = initialize_blank_mission_dict(locations)
if len(available_missions) > 0:
available_missions = []
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
for name in available_missions:
if not locations[name].extra_locations == -1:

103
Utils.py
View File

@@ -12,6 +12,7 @@ import io
import collections
import importlib
import logging
import decimal
if typing.TYPE_CHECKING:
from tkinter import Tk
@@ -29,9 +30,13 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.2"
__version__ = "0.3.3"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys")
import jellyfish
from yaml import load, load_all, dump, SafeLoader
@@ -255,7 +260,7 @@ def get_default_options() -> dict:
},
"generator": {
"teams": 1,
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
@@ -426,7 +431,8 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s at %(asctime)s]: %(message)s", exception_logger: str = ""):
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
exception_logger: typing.Optional[str] = None):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
@@ -462,6 +468,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
sys.excepthook = handle_exception
logging.info(f"Archipelago ({__version__}) logging initialized.")
def stream_input(stream, queue):
def queuer():
@@ -487,17 +495,25 @@ class VersionException(Exception):
pass
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
text = ""
max_label = len(labels) - 1
while index > max_label:
text += labels[-1]
index -= max_label
return labels[index] + text
# noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
n = 0
while value > power:
value = decimal.Decimal(value)
while value >= power:
value /= power
n += 1
if type(value) == int:
return f"{value} {power_labels[n]}"
else:
return f"{value:0.3f} {power_labels[n]}"
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
@@ -519,3 +535,72 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
reverse=True)[0:limit]
)
)
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
if is_linux:
# prefer native dialog
kdialog = shutil.which('kdialog')
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
zenity = shutil.which('zenity')
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
return run(zenity, f'--title={title}', '--file-selection', *z_filters)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because open_filename was used for "{title}".')
raise e
else:
root = tkinter.Tk()
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
def is_kivy_running():
if 'kivy' in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
return
if is_linux and not 'tkinter' in sys.modules:
# prefer native dialog
kdialog = shutil.which('kdialog')
if kdialog:
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
zenity = shutil.which('zenity')
if zenity:
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
# fall back to tk
try:
import tkinter
from tkinter.messagebox import showerror, showinfo
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because messagebox was used for "{title}".')
raise e
else:
root = tkinter.Tk()
root.withdraw()
showerror(title, text) if error else showinfo(title, text)
root.update()

View File

@@ -22,7 +22,7 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister, WebWorld
from worlds.AutoWorld import AutoWorldRegister
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
@@ -46,7 +46,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials'):
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
@@ -67,7 +67,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.author
'authors': tutorial.authors
}]
}
@@ -75,7 +75,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
added = True
break
else:
game_data['tutorials'].append(current_tutorial)
@@ -109,7 +108,6 @@ if __name__ == "__main__":
autogen(app.config)
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
if app.config["DEBUG"]:
autohost(app.config)
app.run(debug=True, port=app.config["PORT"])
else:
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

@@ -46,7 +46,7 @@ app.config["PONY"] = {
'create_db': True
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "simple"
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
@@ -170,7 +170,12 @@ def _read_log(path: str):
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
room = Room.get(id=room)
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")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
import logging
import json
import multiprocessing
import threading
from datetime import timedelta, datetime
import concurrent.futures
import sys
import typing
import time
@@ -17,6 +18,7 @@ from Utils import restricted_loads
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
@@ -53,7 +55,7 @@ else: # unix
def __enter__(self):
try:
self.fp = open(self.lockfile, "wb")
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
raise AlreadyRunningException() from e
@@ -110,6 +112,7 @@ def autohost(config: dict):
def keep_running():
try:
with Locker("autohost"):
run_guardian()
while 1:
time.sleep(0.1)
with db_session:
@@ -162,16 +165,15 @@ def autogen(config: dict):
threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds = {}
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance():
def __init__(self, room: Room, config: dict):
self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None
multiworlds[self.room_id] = self
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"]
def start(self):
@@ -179,21 +181,58 @@ class MultiworldInstance():
return False
logging.info(f"Spinning up {self.room_id}")
self.process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig),
name="MultiHost")
self.process.start()
self.guardian = guardians.submit(self._collect)
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.
self.process = process
def stop(self):
if self.process:
self.process.terminate()
self.process = None
def _collect(self):
def done(self):
return self.process and not self.process.is_alive()
def collect(self):
self.process.join() # wait for process to finish
self.process = None
self.guardian = None
guardian = None
guardian_lock = threading.Lock()
def run_guardian():
global guardian
global multiworlds
with guardian_lock:
if not guardian:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
def guard():
while 1:
time.sleep(1)
done = []
with guardian_lock:
for key, instance in multiworlds.items():
if instance.done():
instance.collect()
done.append(key)
for key in done:
del (multiworlds[key])
guardian = threading.Thread(name="Guardian", target=guard)
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import functools
import logging
import websockets
import asyncio
import socket
@@ -9,6 +8,7 @@ import threading
import time
import random
import pickle
import logging
import Utils
from .models import *
@@ -128,15 +128,21 @@ def run_server_process(room_id, ponyconfig: dict):
ping_interval=None)
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]}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = 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:
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
@@ -146,6 +152,3 @@ def run_server_process(room_id, ponyconfig: dict):
from .autolauncher import Locker
with Locker(room_id):
asyncio.run(main())
from WebHostLib import LOGS_FOLDER

View File

@@ -25,7 +25,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}"
manifest["server"] = f"{app.config['PATCH_TARGET']}:{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":
@@ -55,7 +55,7 @@ def download_spoiler(seed_id):
def download_slot_file(room_id, player_id: int):
room = Room.get(id=room_id)
slot_data: Slot = select(patch for patch in room.seed.slots if
patch.player_id == player_id).first()
patch.player_id == player_id).first()
if not slot_data:
return "Slot Data not found"
@@ -71,7 +71,7 @@ def download_slot_file(room_id, player_id: int):
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip"
fname = name.rsplit("/", 1)[0] + ".zip"
elif slot_data.game == "Ocarina of Time":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV":
@@ -82,6 +82,7 @@ def download_slot_file(room_id, player_id: int):
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
@app.route("/templates")
@cache.cached()
def list_yaml_templates():
@@ -90,4 +91,4 @@ def list_yaml_templates():
for world_name, world in AutoWorldRegister.world_types.items():
if not world.hidden:
files.append(world_name)
return render_template("templates.html", files=files)
return render_template("templates.html", files=files)

View File

@@ -4,6 +4,7 @@ from Utils import __version__
from jinja2 import Template
import yaml
import json
import typing
from worlds.AutoWorld import AutoWorldRegister
import Options
@@ -17,13 +18,30 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
def create():
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
def dictify_range(option):
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
option.default: 50}
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {}
special = getattr(option, "special_range_cutoff", None)
if special is not None:
data[special] = 0
data.update({
option.range_start: 0,
option.range_end: 0,
"random": 0, "random-low": 0, "random-high": 0,
option.default: 50
})
notes = {
special: "minimum value without special meaning",
option.range_start: "minimum value",
option.range_end: "maximum value"
}
for name, number in getattr(option, "special_range_names", {}).items():
if number in data:
data[name] = data[number]
del data[number]
else:
data[name] = 0
return data, notes
def default_converter(default_value):
@@ -89,16 +107,26 @@ def create():
"value": "random",
})
if option.default == "random":
this_option["defaultValue"] = "random"
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = {
"type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
"defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
if hasattr(option, "special_range_names"):
game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif getattr(option, "verify_item_name", False):
game_options[option_name] = {
"type": "items-list",

View File

@@ -1,7 +1,7 @@
flask>=2.1.2
pony>=0.7.16
waitress>=2.1.1
flask-caching>=1.10.1
flask-caching>=1.11.1
Flask-Compress>=1.12
Flask-Limiter>=2.4.5.1
Flask-Limiter>=2.4.6
bokeh>=2.4.3

View File

@@ -36,7 +36,8 @@ window.addEventListener('load', () => {
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch(() => {
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
@@ -158,6 +159,70 @@ const buildOptionsTable = (settings, romOpts = false) => {
element.appendChild(rangeVal);
break;
case 'special_range':
element = document.createElement('div');
element.classList.add('special-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', setting);
specialRange.setAttribute('min', settings[setting].min);
specialRange.setAttribute('max', settings[setting].max);
specialRange.value = currentSettings[gameName][setting];
// Build rage value element
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
break;
default:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;

View File

@@ -77,6 +77,7 @@ const createDefaultSettings = (settingData) => {
});
break;
case 'range':
case 'special_range':
for (let i = setting.min; i <= setting.max; ++i){
newSettings[game][gameSetting][i] =
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
@@ -285,6 +286,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
break;
case 'range':
case 'special_range':
const rangeTable = document.createElement('table');
const rangeTbody = document.createElement('tbody');
@@ -325,6 +327,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
`below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` +
`Maximum value: ${setting.max}`;
if (setting.hasOwnProperty('value_names')) {
hintText.innerHTML += '<br /><br />Certain values have special meaning:';
Object.keys(setting.value_names).forEach((specialName) => {
hintText.innerHTML += `<br />${specialName}: ${setting.value_names[specialName]}`;
});
}
settingWrapper.appendChild(hintText);
const addOptionDiv = document.createElement('div');
@@ -487,7 +497,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
break;
default:
console.error(`Unknown setting type for ${game} setting ${setting}: ${settings[setting].type}`);
console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
return;
}

View File

@@ -1,4 +1,3 @@
Copyright 2022 Berserker66 (Fabian Dill)
Copyright 2022 LegendaryLinux (Chris Wilson)
All rights reserved.

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

@@ -0,0 +1,3 @@
Copyright 2022 LegendaryLinux (Chris Wilson)
All rights reserved.

View File

@@ -1,4 +1,3 @@
Copyright 2022 Berserker66 (Fabian Dill)
Copyright 2022 LegendaryLinux (Chris Wilson)
All rights reserved.

View File

@@ -1,4 +1,3 @@
Copyright 2022 Berserker66 (Fabian Dill)
Copyright 2022 LegendaryLinux (Chris Wilson)
All rights reserved.

View File

@@ -49,7 +49,6 @@ html{
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
@@ -58,20 +57,14 @@ html{
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-settings a{
color: #ffef00;
}
#player-settings input:not([type]){
border: 1px solid #000000;
padding: 3px;
@@ -137,6 +130,20 @@ html{
margin-left: 0.25rem;
}
#player-settings table .special-range-container{
display: flex;
flex-direction: column;
}
#player-settings table .special-range-wrapper{
display: flex;
flex-direction: row;
}
#player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1;
}
#player-settings table label{
display: block;
min-width: 200px;
@@ -148,7 +155,7 @@ html{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: middle;
vertical-align: top;
}
@media all and (max-width: 1000px), all and (orientation: portrait){

View File

@@ -0,0 +1,65 @@
html{
background-image: url('../../static/backgrounds/stone.png');
background-repeat: repeat;
background-size: 275px 275px;
}
body{
color: #ffffff;
}
#base-header {
background: url('../../static/backgrounds/header/stone-header.png') repeat-x;
}
.markdown {
background-color: rgba(0, 0, 0, 0.66) !important;
}
h1{
color: #cccbc3;
}
h2{
color: #aad79c;
}
h3, h4, h5,h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
table th{
}
table td{
}
a{
color: #96e2ff;
}
pre{
margin-top: 0;
padding: 0.5rem 0.25rem;
border-radius: 6px;
color: #000000;
}
pre code{
border: none;
}
code{
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
pre, code{
background-color: #e4ffdb;
border: 1px solid #2d3435;
}

View File

@@ -25,11 +25,11 @@
</thead>
<tbody>
{% for name, count in inventory.items() %}
{% for id, count in inventory.items() %}
<tr>
<td>{{ name | item_name }}</td>
<td>{{ id | item_name }}</td>
<td>{{ count }}</td>
<td>{{received_items[name]}}</td>
<td>{{received_items[id]}}</td>
</tr>
{%- endfor -%}

View File

@@ -0,0 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/themes/stone.css") }}" />
{% endblock %}
{% include 'header/baseHeader.html' %}

View File

@@ -46,6 +46,9 @@ requires:
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%}
{% if option.default == "random" %}
random: 50
{%- endif -%}
{%- else %}
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
{%- endif -%}

View File

@@ -316,6 +316,11 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
else:
multisave: Dict[str, Any] = {}
slots_aimed_at_player = {tracked_player}
for group_id, group_members in groups.items():
if tracked_player in group_members:
slots_aimed_at_player.add(group_id)
# Add items to player inventory
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
# Skip teams and players not matching the request
@@ -325,7 +330,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
for location in locations_checked:
if location in player_locations:
item, recipient, flags = player_locations[location]
if recipient == tracked_player: # a check done for the tracked player
if recipient in slots_aimed_at_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
@@ -424,7 +429,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fa/Brewing_Stand.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
@@ -884,7 +889,6 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
for item_name, item_id in multi_items.items():
base_name = item_name.split()[0].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id]
# Victory condition

View File

@@ -8,7 +8,7 @@ There are two key steps to incorporating a game into Archipelago:
Refer to the following documents as well:
- [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) for network communication between client and server.
- [api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/api.md) for documentation on server side code and creating a world package.
- [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md) for documentation on server side code and creating a world package.
# Game Modification
@@ -337,6 +337,7 @@ fields in the class being extended.
This is also a good place to put game-specific quirky behavior that needs to be managed, as it tends to make things a bit
cluttered if you put these things elsewhere.
The various methods and attributes are documented in `/worlds/AutoWorld.py[World]`,
The various methods and attributes are documented in `/worlds/AutoWorld.py[World]` and
[world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md),
though it is also recommended to look at existing implementations to see how all this works first-hand.
Once you get all that, all that remains to do is test the game and publish your work.

BIN
docs/img/theme_stone.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -63,10 +63,9 @@ Sent to clients when they connect to an Archipelago server.
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". |
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Sent only if the client is properly authenticated (see [Archipelago Connection Handshake](#Archipelago-Connection-Handshake)). Information on the players currently connected to the server. |
| games | list\[str\] | sorted list of game names for the players, so first player's game will be games\[0\]. Matches game names in datapackage. |
| datapackage_version | int | Data version of the [data package](#Data-Package-Contents) the server will send. Used to update the client's (optional) local cache. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. |
| games | list\[str\] | List of games present in this multiworld. |
| datapackage_version | int | Sum of individual games' datapackage version. Deprecated. Use `datapackage_versions` instead. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
| seed_name | str | uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
@@ -146,7 +145,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
| Name | Type | Notes |
| ---- | ---- | ----- |
| hint_points | int | New argument. The client's current hint points. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Changed argument. Always sends all players, whether connected or not. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Send in the event of an alias rename. Always sends all players, whether connected or not. |
| checked_locations | list\[int\] | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. |
| missing_locations | list\[int\] | Should never be sent as an update, if needed is the inverse of checked_locations. |
@@ -238,7 +237,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags.
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
#### items_handling flags
@@ -259,7 +258,7 @@ Update arguments from the Connect package, currently only updating tags and item
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| items_handling | int | Flags configuring which items should be sent by the server.
| items_handling | int | Flags configuring which items should be sent by the server. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
### Sync
@@ -282,7 +281,7 @@ Sent to the server to inform it of locations the client has seen, but not checke
| Name | Type | Notes |
| ---- | ---- | ----- |
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
| create_as_hint | bool | If True, the scouted locations get created and broadcasted as a player-visible hint. |
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
### StatusUpdate
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
@@ -344,7 +343,7 @@ Additional arguments sent in this package will also be added to the [SetReply](#
#### DataStorageOperation
A DataStorageOperation manipulates or alters the value of a key in the data storage. If the operation transforms the value from one state to another then the current value of the key is used as the starting point otherwise the [Set](#Set)'s package `default` is used if the key does not exist on the server already.
DataStorageOperations consist of an object containing both the operation to be applied, provided in the form of a string, as well as the value to be used for that operation, Example:
```js
```json
{"operation": "add", "value": 12}
```
@@ -399,7 +398,7 @@ class NetworkPlayer(NamedTuple):
```
Example:
```js
```json
[
{"team": 0, "slot": 1, "alias": "Lord MeowsiePuss", "name": "Meow"},
{"team": 0, "slot": 2, "alias": "Doggo", "name": "Bork"},
@@ -419,7 +418,7 @@ class NetworkItem(NamedTuple):
flags: int
```
In JSON this may look like:
```js
```json
[
{"item": 1, "location": 1, "player": 1, "flags": 1},
{"item": 2, "location": 2, "player": 2, "flags": 2},

View File

@@ -61,9 +61,9 @@ for your world specifically on the webhost.
`settings_page` which can be changed to a link instead of an AP generated settings page.
`theme` to be used for your game specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime |
|---|---|---|---|---|---|---|
| <img src="img/theme_dirt.JPG" width="100"> | <img src="img/theme_grass.JPG" width="100"> | <img src="img/theme_grassFlowers.JPG" width="100"> | <img src="img/theme_ice.JPG" width="100"> | <img src="img/theme_jungle.JPG" width="100"> | <img src="img/theme_ocean.JPG" width="100"> | <img src="img/theme_partyTime.JPG" width="100"> |
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
|---|---|---|---|---|---|---|---|
| <img src="img/theme_dirt.JPG" width="100"> | <img src="img/theme_grass.JPG" width="100"> | <img src="img/theme_grassFlowers.JPG" width="100"> | <img src="img/theme_ice.JPG" width="100"> | <img src="img/theme_jungle.JPG" width="100"> | <img src="img/theme_ocean.JPG" width="100"> | <img src="img/theme_partyTime.JPG" width="100"> | <img src="img/theme_stone.JPG" width="100"> |
`bug_report_page` (optional) can be a link to a bug reporting page, most likely a GitHub issue page, that will be placed by the site to help direct users to report bugs.
@@ -114,14 +114,21 @@ Special locations with ID `None` can hold events.
Items are all things that can "drop" for your game. This may be RPG items like
weapons, could as well be technologies you normally research in a research tree.
Each item has a `name`, an `id` (can be known as "code"), and an `advancement`
flag. An advancement item is an item which a player may require to advance in
their world. Advancement items will be assigned to locations with higher
Each item has a `name`, an `id` (can be known as "code"), and a classification.
The most important classification is `progression` (formerly advancement).
Progression items are items which a player may require to progress in
their world. Progression items will be assigned to locations with higher
priority and moved around to meet defined rules and accomplish progression
balancing.
Special items with ID `None` can mark events (read below).
Other classifications include
* filler: a regular item or trash item
* useful: generally quite useful, but not required for anything logical
* trap: negative impact on the player
* skip_balancing: add to progression to skip balancing; e.g. currency or tokens
### Events
Events will mark some progress. You define an event location, an
@@ -346,7 +353,7 @@ from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from ..AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
from Utils import get_options, output_path
class MyGameItem(Item): # or from Items import MyGameItem
@@ -453,7 +460,9 @@ from .Items import is_progression # this is just a dummy
def create_item(self, item: str):
# This is called when AP wants to create an item by name (for plando) or
# when you call it from your own code.
return MyGameItem(item, is_progression(item), self.item_name_to_id[item],
classification = ItemClassification.progression if is_progression(item) else \
ItemClassification.filler
return MyGameItem(item, classification, self.item_name_to_id[item],
self.player)
def create_event(self, event: str):

View File

@@ -56,7 +56,7 @@ server_options:
# Options for Generation
generator:
# Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases
enemizer_path: "EnemizerCLI/EnemizerCLI.Core.exe"
enemizer_path: "EnemizerCLI/EnemizerCLI.Core" # + ".exe" is implied on Windows
# Folder from which the player yaml files are pulled from
player_files_path: "Players"
#amount of players, 0 to infer from player files
@@ -126,4 +126,4 @@ smz3_options:
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
rom_start: true

56
kvui.py
View File

@@ -8,7 +8,11 @@ os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
from kivy.base import Config
import Utils
if Utils.is_frozen():
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set('kivy', 'exit_on_escape', '0')
@@ -18,7 +22,8 @@ from kivy.app import App
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel
from kivy.base import ExceptionHandler, ExceptionManager, Clock
from kivy.base import ExceptionHandler, ExceptionManager
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
from kivy.uix.button import Button
@@ -37,10 +42,11 @@ from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.animation import Animation
from kivy.uix.popup import Popup
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
if typing.TYPE_CHECKING:
@@ -267,6 +273,25 @@ class ConnectBarTextInput(TextInput):
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
class MessageBox(Popup):
class MessageBoxLabel(Label):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
self.size = self._label.texture.size
if self.width + 50 > Window.width:
self.text_size[0] = Window.width - 50
self._label.refresh()
self.size = self._label.texture.size
def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width)+40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class GameManager(App):
logging_pairs = [
("Client", "Archipelago"),
@@ -363,7 +388,8 @@ class GameManager(App):
return self.container
def update_texts(self, dt):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if hasattr(self.tabs.content.children[0], 'fix_heights'):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \
@@ -430,20 +456,24 @@ class GameManager(App):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
class ChecksFinderManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago ChecksFinder Client"
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.INFO)
self.on_log = on_log
@staticmethod
def format_compact(record: logging.LogRecord) -> str:
if isinstance(record.msg, Exception):
return str(record.msg)
return (f'{record.exc_info[1]}\n' if record.exc_info else '') + str(record.msg).split("\n")[0]
def handle(self, record: logging.LogRecord) -> None:
self.on_log(self.format(record))
if getattr(record, 'skip_gui', False):
pass # skip output
elif getattr(record, 'compact_gui', False):
self.on_log(self.format_compact(record))
else:
self.on_log(self.format(record))
class UILog(RecycleView):
@@ -485,7 +515,7 @@ class KivyJSONtoTextParser(JSONtoTextParser):
flags = node.get("flags", 0)
if flags & 0b001: # advancement
itemtype = "progression"
elif flags & 0b010: # never_exclude
elif flags & 0b010: # useful
itemtype = "useful"
elif flags & 0b100: # trap
itemtype = "trap"

View File

@@ -26,7 +26,7 @@ name: YourName{number} # Your name in-game. Spaces will be replaced with undersc
game: # Pick a game to play
A Link to the Past: 1
requires:
version: 0.2.3 # Version of Archipelago required for this yaml to work as expected.
version: 0.3.3 # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
accessibility:
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
@@ -169,8 +169,11 @@ A Link to the Past:
standard: 0 # Begin the game by rescuing Zelda from her cell and escorting her to the Sanctuary
open: 50 # Begin the game from your choice of Link's House or the Sanctuary
inverted: 0 # Begin in the Dark World. The Moon Pearl is required to avoid bunny-state in Light World, and the Light World game map is altered
retro:
on: 0 # you must buy a quiver to use the bow, take-any caves and an old-man cave are added to the world. You may need to find your sword from the old man's cave
retro_bow:
on: 0 # Zelda-1 like mode. You have to purchase a quiver to shoot arrows using rupees.
off: 50
retro_caves:
on: 0 # Zelda-1 like mode. There are randomly placed take-any caves that contain one Sword and choices of Heart Container/Blue Potion.
off: 50
hints: # Vendors: King Zora and Bottle Merchant say what they're selling.
# On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints.
@@ -533,4 +536,4 @@ triggers:
percentage: 0 # AND has a 0 percent chance (meaning this is default disabled, just to show how it works)
options: # then inserts these options
A Link to the Past:
swordless: off
swordless: off

View File

@@ -2,11 +2,12 @@ import os
import shutil
import sys
import sysconfig
import platform
from pathlib import Path
from hashlib import sha3_512
import base64
import datetime
from Utils import version_tuple
from Utils import version_tuple, is_windows, is_linux
from collections.abc import Iterable
import typing
import setuptools
@@ -16,7 +17,7 @@ from Launcher import components, icon_paths
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
import subprocess
import pkg_resources
requirement = 'cx-Freeze>=6.10'
requirement = 'cx-Freeze>=6.11'
try:
pkg_resources.require(requirement)
import cx_Freeze
@@ -36,10 +37,11 @@ else:
signtool = None
arch_folder = "exe.{platform}-{version}".format(platform=sysconfig.get_platform(),
build_platform = sysconfig.get_platform()
arch_folder = "exe.{platform}-{version}".format(platform=build_platform,
version=sysconfig.get_python_version())
buildfolder = Path("build", arch_folder)
is_windows = sys.platform in ("win32", "cygwin", "msys")
build_arch = build_platform.split('-')[-1] if '-' in build_platform else platform.machine()
# see Launcher.py on how to add scripts to setup.py
@@ -68,7 +70,7 @@ def _threaded_hash(filepath):
# cx_Freeze's build command runs other commands. Override to accept --yes and store that.
class BuildCommand(cx_Freeze.dist.build):
class BuildCommand(cx_Freeze.command.build.Build):
user_options = [
('yes', 'y', 'Answer "yes" to all questions.'),
]
@@ -85,8 +87,8 @@ class BuildCommand(cx_Freeze.dist.build):
# Override cx_Freeze's build_exe command for pre and post build steps
class BuildExeCommand(cx_Freeze.dist.build_exe):
user_options = cx_Freeze.dist.build_exe.user_options + [
class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
user_options = cx_Freeze.command.build_exe.BuildEXE.user_options + [
('yes', 'y', 'Answer "yes" to all questions.'),
('extra-data=', None, 'Additional files to add.'),
]
@@ -109,8 +111,10 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
self.libfolder = Path(self.buildfolder, "lib")
self.library = Path(self.libfolder, "library.zip")
def installfile(self, path, keep_content=False):
def installfile(self, path, subpath=None, keep_content: bool = False):
folder = self.buildfolder
if subpath:
folder /= subpath
print('copying', path, '->', folder)
if path.is_dir():
folder /= path.name
@@ -156,6 +160,11 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
self.buildtime = datetime.datetime.utcnow()
super().run()
# include_files seems to be broken with this setup. implement here
for src, dst in self.include_files:
print('copying', src, '->', self.buildfolder / dst)
shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False)
# post build steps
if sys.platform == "win32": # kivy_deps is win32 only, linux picks them up automatically
from kivy_deps import sdl2, glew
@@ -166,6 +175,12 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
for data in self.extra_data:
self.installfile(Path(data))
# kivi data files
import kivy
shutil.copytree(os.path.join(os.path.dirname(kivy.__file__), "data"),
self.buildfolder / "data",
dirs_exist_ok=True)
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
from WebHostLib.options import create
create()
@@ -182,7 +197,6 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
from maseya import z3pr
except ImportError:
print("Maseya Palette Shuffle not found, skipping data files.")
z3pr = None
else:
# maseya Palette Shuffle exists and needs its data files
print("Maseya Palette Shuffle found, including data files...")
@@ -219,7 +233,6 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
host_yaml = self.buildfolder / 'host.yaml'
with host_yaml.open('r+b') as f:
data = f.read()
data = data.replace(b'EnemizerCLI.Core.exe', b'EnemizerCLI.Core')
data = data.replace(b'factorio\\\\bin\\\\x64\\\\factorio', b'factorio/bin/x64/factorio')
f.seek(0, os.SEEK_SET)
f.write(data)
@@ -268,7 +281,7 @@ match="${{1#--executable=}}"
if [ "${{#match}}" -lt "${{#1}}" ]; then
exe="$match"
shift
elif [ "$1" == "-executable" ] || [ "$1" == "--executable" ]; then
elif [ "$1" = "-executable" ] || [ "$1" = "--executable" ]; then
exe="$2"
shift; shift
fi
@@ -333,7 +346,61 @@ $APPDIR/$exe "$@"
self.write_desktop()
self.write_launcher(self.app_exec)
print(f'{self.app_dir} -> {self.dist_file}')
subprocess.call(f'./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
def find_libs(*args: str) -> typing.Sequence[typing.Tuple[str, str]]:
"""Try to find system libraries to be included."""
arch = build_arch.replace('_', '-')
libc = 'libc6' # we currently don't support musl
def parse(line):
lib, path = line.strip().split(' => ')
lib, typ = lib.split(' ', 1)
for test_arch in ('x86-64', 'i386', 'aarch64'):
if test_arch in typ:
lib_arch = test_arch
break
else:
lib_arch = ''
for test_libc in ('libc6',):
if test_libc in typ:
lib_libc = test_libc
break
else:
lib_libc = ''
return (lib, lib_arch, lib_libc), path
if not hasattr(find_libs, "cache"):
data = subprocess.run([shutil.which('ldconfig'), '-p'], capture_output=True, text=True).stdout.split('\n')[1:]
find_libs.cache = {k: v for k, v in (parse(line) for line in data if '=>' in line)}
def find_lib(lib, arch, libc):
for k, v in find_libs.cache.items():
if k == (lib, arch, libc):
return v
for k, v, in find_libs.cache.items():
if k[0].startswith(lib) and k[1] == arch and k[2] == libc:
return v
return None
res = []
for arg in args:
# try exact match, empty libc, empty arch, empty arch and libc
file = find_lib(arg, arch, libc)
file = file or find_lib(arg, arch, '')
file = file or find_lib(arg, '', libc)
file = file or find_lib(arg, '', '')
# resolve symlinks
for n in range(0, 5):
res.append((file, os.path.join('lib', os.path.basename(file))))
if not os.path.islink(file):
break
dirname = os.path.dirname(file)
file = os.readlink(file)
if not os.path.isabs(file):
file = os.path.join(dirname, file)
return res
cx_Freeze.setup(
@@ -341,6 +408,7 @@ cx_Freeze.setup(
version=f"{version_tuple.major}.{version_tuple.minor}.{version_tuple.build}",
description="Archipelago",
executables=exes,
ext_modules=[], # required to disable auto-discovery with setuptools>=61
options={
"build_exe": {
"packages": ["websockets", "worlds", "kivy"],
@@ -348,14 +416,14 @@ cx_Freeze.setup(
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "kivy", "sc2"],
"include_files": [],
"zip_exclude_packages": ["worlds", "sc2"],
"include_files": find_libs("libssl.so", "libcrypto.so") if is_linux else [],
"include_msvcr": False,
"replace_paths": [("*", "")],
"optimize": 1,
"build_exe": buildfolder,
"extra_data": extra_data,
"bin_includes": [] if is_windows else ["libffi.so"]
"bin_includes": ["libffi.so", "libcrypt.so"] if is_linux else []
},
"bdist_appimage": {
"build_folder": buildfolder,

View File

@@ -6,7 +6,7 @@ import Utils
file_path = pathlib.Path(__file__).parent.parent
Utils.local_path.cached_path = file_path
from BaseClasses import MultiWorld, CollectionState
from BaseClasses import MultiWorld, CollectionState, ItemClassification
from worlds.alttp.Items import ItemFactory
@@ -19,7 +19,7 @@ class TestBase(unittest.TestCase):
return self._state_cache[self.world, tuple(items)]
state = CollectionState(self.world)
for item in items:
item.advancement = True
item.classification = ItemClassification.progression
state.collect(item)
state.sweep_for_events()
self._state_cache[self.world, tuple(items)] = state

View File

@@ -0,0 +1,2 @@
import warnings
warnings.simplefilter("always")

View File

@@ -1,7 +1,7 @@
import unittest
from argparse import Namespace
from BaseClasses import MultiWorld, CollectionState
from BaseClasses import MultiWorld, CollectionState, ItemClassification
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import mandatory_connections, connect_simple
from worlds.alttp.ItemPool import difficulties, generate_itempool
@@ -60,7 +60,7 @@ class TestDungeon(unittest.TestCase):
state.blocked_connections[1].add(exit)
for item in items:
item.advancement = True
item.classification = ItemClassification.progression
state.collect(item)
self.assertEqual(self.world.get_location(location, 1).can_reach(state), access)

View File

@@ -1,8 +1,9 @@
from typing import List
from typing import List, Iterable
import unittest
from worlds.AutoWorld import World
from Fill import FillError, balance_multiworld_progression, fill_restrictive, distribute_items_restrictive
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
ItemClassification
from worlds.generic.Rules import CollectionRule, locality_rules, set_rule
@@ -108,14 +109,16 @@ def generate_locations(count: int, player_id: int, address: int = None, region:
def generate_items(count: int, player_id: int, advancement: bool = False, code: int = None) -> List[Item]:
items = []
type = "prog" if advancement else ""
item_type = "prog" if advancement else ""
for i in range(count):
name = "player" + str(player_id) + "_" + type + "item" + str(i)
items.append(Item(name, advancement, code, player_id))
name = "player" + str(player_id) + "_" + item_type + "item" + str(i)
items.append(Item(name,
ItemClassification.progression if advancement else ItemClassification.filler,
code, player_id))
return items
def names(objs: list) -> List[str]:
def names(objs: list) -> Iterable[str]:
return map(lambda o: o.name, objs)
@@ -185,7 +188,7 @@ class TestFillRestrictive(unittest.TestCase):
items = player1.prog_items
locations = player1.locations
multi_world.accessibility[player1.id] = 'minimal'
multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal
multi_world.completion_condition[player1.id] = lambda state: state.has(
items[1].name, player1.id)
set_rule(locations[1], lambda state: state.has(
@@ -400,7 +403,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
basic_items = player1.basic_items
locations[1].progress_type = LocationProgressType.EXCLUDED
basic_items[1].never_exclude = True
basic_items[1].classification = ItemClassification.useful
distribute_items_restrictive(multi_world)
@@ -427,8 +430,8 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
locations[1].progress_type = LocationProgressType.EXCLUDED
locations[2].progress_type = LocationProgressType.EXCLUDED
basic_items[0].never_exclude = True
basic_items[1].never_exclude = True
basic_items[0].classification = ItemClassification.useful
basic_items[1].classification = ItemClassification.useful
self.assertRaises(FillError, distribute_items_restrictive, multi_world)
@@ -569,7 +572,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
multi_world, 2, location_count=5, basic_item_count=5)
for item in multi_world.get_items():
item.never_exclude = True
item.classification = ItemClassification.useful
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
@@ -625,8 +628,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
# Sphere 3
region = player2.generate_region(
player2.menu, 20, lambda state: state.has(player2.prog_items[0].name, player2.id))
items = fillRegion(multi_world, region, [
player2.prog_items[1]] + items)
fillRegion(multi_world, region, [player2.prog_items[1]] + items)
def test_balances_progression(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 50

View File

@@ -10,3 +10,22 @@ class TestBase(unittest.TestCase):
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name)
self.assertEqual(item.name, item_name)
def testItemNameGroupHasValidItem(self):
"""Test that all item name groups contain valid items. """
# This cannot test for Event names that you may have declared for logic, only sendable Items.
# In such a case, you can add your entries to this Exclusion dict. Game Name -> Group Names
exclusion_dict = {
"A Link to the Past":
{"Pendants", "Crystals"},
"Starcraft 2 Wings of Liberty":
{"Missions"},
}
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game_name, game_name=game_name):
exclusions = exclusion_dict.get(game_name, frozenset())
for group_name, items in world_type.item_name_groups.items():
if group_name not in exclusions:
with self.subTest(group_name, group_name=group_name):
for item in items:
self.assertIn(item, world_type.item_name_to_id)

View File

@@ -1,6 +1,6 @@
import worlds.minecraft.Options
from test.TestBase import TestBase
from BaseClasses import MultiWorld
from BaseClasses import MultiWorld, ItemClassification
from worlds import AutoWorld
from worlds.minecraft import MinecraftWorld
from worlds.minecraft.Items import MinecraftItem, item_table
@@ -16,7 +16,10 @@ def MCItemFactory(items, player: int):
singleton = True
for item in items:
if item in item_table:
ret.append(MinecraftItem(item, item_table[item].progression, item_table[item].code, player))
ret.append(MinecraftItem(
item, ItemClassification.progression if item_table[item].progression else ItemClassification.filler,
item_table[item].code, player
))
else:
raise Exception(f"Unknown item {item}")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import sys
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
@@ -41,6 +42,7 @@ class AutoWorldRegister(type):
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
AutoWorldRegister.world_types[dct["game"]] = new_class
new_class.__file__ = sys.modules[new_class.__module__].__file__
return new_class
@@ -98,7 +100,7 @@ class WebWorld:
tutorials: List[Tutorial]
# Choose a theme for your /game/* pages
# Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime
# Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone
theme = "grass"
# display a link to a bug report page, most likely a link to a GitHub issue page.

View File

@@ -1,7 +1,7 @@
from collections import namedtuple
import logging
from BaseClasses import Region, RegionType
from BaseClasses import Region, RegionType, ItemClassification
from worlds.alttp.SubClasses import ALttPLocation
from worlds.alttp.Shops import TakeAny, total_shop_slots, set_up_shops, shuffle_shops
from worlds.alttp.Bosses import place_bosses
@@ -395,11 +395,11 @@ def generate_itempool(world):
# rather than making all hearts/heart pieces progression items (which slows down generation considerably)
# We mark one random heart container as an advancement item (or 4 heart pieces in expert mode)
if world.goal[player] != 'icerodhunt' and world.difficulty[player] in ['easy', 'normal', 'hard'] and not (world.custom and world.customitemarray[30] == 0):
next(item for item in items if item.name == 'Boss Heart Container').advancement = True
next(item for item in items if item.name == 'Boss Heart Container').classification = ItemClassification.progression
elif world.goal[player] != 'icerodhunt' and world.difficulty[player] in ['expert'] and not (world.custom and world.customitemarray[29] < 4):
adv_heart_pieces = (item for item in items if item.name == 'Piece of Heart')
for i in range(4):
next(adv_heart_pieces).advancement = True
next(adv_heart_pieces).classification = ItemClassification.progression
progressionitems = []
@@ -440,7 +440,7 @@ def generate_itempool(world):
world.itempool += progressionitems + nonprogressionitems
if world.retro[player]:
if world.retro_caves[player]:
set_up_take_anys(world, player) # depends on world.itempool to be set
@@ -531,7 +531,7 @@ def get_pool_core(world, player: int):
goal = world.goal[player]
mode = world.mode[player]
swordless = world.swordless[player]
retro = world.retro[player]
retro_bow = world.retro_bow[player]
logic = world.logic[player]
pool = []
@@ -647,7 +647,7 @@ def get_pool_core(world, player: int):
place_item('Master Sword Pedestal', 'Triforce')
pool.remove("Rupees (20)")
if retro:
if retro_bow:
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)'}
pool = ['Rupees (5)' if item in replace else item for item in pool]
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal:
@@ -812,7 +812,7 @@ def make_custom_item_pool(world, player):
pool.extend(['Moon Pearl'] * customitemarray[28])
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal:
itemtotal = itemtotal - 28 # Corrects for small keys not being in item pool in Retro Mode
itemtotal = itemtotal - 28 # Corrects for small keys not being in item pool in universal mode
if itemtotal < total_items_to_place:
pool.extend(['Nothing'] * (total_items_to_place - itemtotal))
logging.warning(f"Pool was filled up with {total_items_to_place - itemtotal} Nothing's for player {player}")

View File

@@ -1,5 +1,6 @@
import typing
from BaseClasses import ItemClassification as IC
def GetBeemizerItem(world, player: int, item):
item_name = item if isinstance(item, str) else item.name
@@ -39,7 +40,7 @@ def ItemFactory(items, player: int):
class ItemData(typing.NamedTuple):
advancement: bool
classification: IC
type: typing.Optional[str]
item_code: typing.Union[typing.Optional[int], typing.Iterable[int]]
pedestal_hint: typing.Optional[str]
@@ -49,174 +50,172 @@ class ItemData(typing.NamedTuple):
witch_credit: typing.Optional[str]
flute_boy_credit: typing.Optional[str]
hint_text: typing.Optional[str]
trap: bool = False
# Format: Name: (Advancement, Type, ItemCode, Pedestal Hint Text, Pedestal Credit Text, Sick Kid Credit Text, Zora Credit Text, Witch Credit Text, Flute Boy Credit Text, Hint Text)
item_table = {'Bow': ItemData(True, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'),
'Progressive Bow': ItemData(True, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
'Progressive Bow (Alt)': ItemData(True, None, 0x65, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
'Silver Arrows': ItemData(True, None, 0x58, 'Do you fancy\nsilver tipped\narrows?', 'and the ganonsbane','ganon-killing kid', 'ganon doom for sale', 'fungus for pork','archer boy shines again', 'the Silver Arrows'),
'Silver Bow': ItemData(True, None, 0x3B, 'Buy 1 Silver\nget Archery\nfor free.', 'the baconmaker', 'ganon-killing kid', 'ganon doom for sale', 'fungus for pork', 'archer boy shines again', 'the Silver Bow'),
'Book of Mudora': ItemData(True, None, 0x1D, 'Hylian\nfor\nDingusses.', 'and the story book', 'the scholarly kid', 'moon runes for sale', 'drugs for literacy', 'book-worm boy can read again', 'the Book'),
'Hammer': ItemData(True, None, 0x09, 'stop\nhammer time!', 'and m c hammer', 'hammer-smashing kid', 'm c hammer for sale', 'stop... hammer time', 'stop, hammer time', 'the Hammer'),
'Hookshot': ItemData(True, None, 0x0A, 'BOING!!!\nBOING!!!\nBOING!!!', 'and the tickle beam', 'tickle-monster kid', 'tickle beam for sale', 'witch and tickle boy', 'beam boy tickles again', 'the Hookshot'),
'Magic Mirror': ItemData(True, None, 0x1A, 'Isn\'t your\nreflection so\npretty?', 'the face reflector', 'the narcissistic kid', 'your face for sale', 'trades looking-glass', 'narcissistic boy is happy again', 'the Mirror'),
'Flute': ItemData(True, None, 0x14, 'Save the duck\nand fly to\nfreedom!', 'and the duck call', 'the duck-call kid', 'duck call for sale', 'duck-calls for trade', 'flute boy plays again', 'the Flute'),
'Pegasus Boots': ItemData(True, None, 0x4B, 'Gotta go fast!', 'and the sprint shoes', 'the running-man kid', 'sprint shoe for sale', 'shrooms for speed', 'gotta-go-fast boy runs again', 'the Boots'),
'Power Glove': ItemData(True, None, 0x1B, 'Now you can\nlift weak\nstuff!', 'and the grey mittens', 'body-building kid', 'lift glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'the Glove'),
'Cape': ItemData(True, None, 0x19, 'Wear this to\nbecome\ninvisible!', 'the camouflage cape', 'red riding-hood kid', 'red hood for sale', 'hood from a hood', 'dapper boy hides again', 'the Cape'),
'Mushroom': ItemData(True, None, 0x29, 'I\'m a fun guy!\n\nI\'m a funghi!', 'and the legal drugs', 'the drug-dealing kid', 'legal drugs for sale', 'shroom swap', 'shroom boy sells drugs again', 'the Mushroom'),
'Shovel': ItemData(True, None, 0x13, 'Can\n You\n Dig it?', 'and the spade', 'archaeologist kid', 'dirt spade for sale', 'can you dig it', 'shovel boy digs again', 'the Shovel'),
'Lamp': ItemData(True, None, 0x12, 'Baby, baby,\nbaby.\nLight my way!', 'and the flashlight', 'light-shining kid', 'flashlight for sale', 'fungus for illumination', 'illuminated boy can see again', 'the Lamp'),
'Magic Powder': ItemData(True, None, 0x0D, 'you can turn\nanti-faeries\ninto faeries', 'and the magic sack', 'the sack-holding kid', 'magic sack for sale', 'the witch and assistant', 'magic boy plays marbles again', 'the Powder'),
'Moon Pearl': ItemData(True, None, 0x1F, ' Bunny Link\n be\n gone!', 'and the jaw breaker', 'fortune-telling kid', 'lunar orb for sale', 'shrooms for moon rock', 'moon boy plays ball again', 'the Moon Pearl'),
'Cane of Somaria': ItemData(True, None, 0x15, 'I make blocks\nto hold down\nswitches!', 'and the red blocks', 'the block-making kid', 'block stick for sale', 'block stick for trade', 'cane boy makes blocks again', 'the Red Cane'),
'Fire Rod': ItemData(True, None, 0x07, 'I\'m the hot\nrod. I make\nthings burn!', 'and the flamethrower', 'fire-starting kid', 'rage rod for sale', 'fungus for rage-rod', 'firestarter boy burns again', 'the Fire Rod'),
'Flippers': ItemData(True, None, 0x1E, 'fancy a swim?', 'and the toewebs', 'the swimming kid', 'finger webs for sale', 'shrooms let you swim', 'swimming boy swims again', 'the Flippers'),
'Ice Rod': ItemData(True, None, 0x08, 'I\'m the cold\nrod. I make\nthings freeze!', 'and the freeze ray', 'the ice-bending kid', 'freeze ray for sale', 'fungus for ice-rod', 'ice-cube boy freezes again', 'the Ice Rod'),
'Titans Mitts': ItemData(True, None, 0x1C, 'Now you can\nlift heavy\nstuff!', 'and the golden glove', 'body-building kid', 'carry glove for sale', 'fungus for bling-gloves', 'body-building boy has gold again', 'the Mitts'),
'Bombos': ItemData(True, None, 0x0F, 'Burn, baby,\nburn! Fear my\nring of fire!', 'and the swirly coin', 'coin-collecting kid', 'swirly coin for sale', 'shrooms for swirly-coin', 'medallion boy melts room again', 'Bombos'),
'Ether': ItemData(True, None, 0x10, 'This magic\ncoin freezes\neverything!', 'and the bolt coin', 'coin-collecting kid', 'bolt coin for sale', 'shrooms for bolt-coin', 'medallion boy sees floor again', 'Ether'),
'Quake': ItemData(True, None, 0x11, 'Maxing out the\nRichter scale\nis what I do!', 'and the wavy coin', 'coin-collecting kid', 'wavy coin for sale', 'shrooms for wavy-coin', 'medallion boy shakes dirt again', 'Quake'),
'Bottle': ItemData(True, None, 0x16, 'Now you can\nstore potions\nand stuff!', 'and the terrarium', 'the terrarium kid', 'terrarium for sale', 'special promotion', 'bottle boy has terrarium again', 'a bottle'),
'Bottle (Red Potion)': ItemData(True, None, 0x2B, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a bottle'),
'Bottle (Green Potion)': ItemData(True, None, 0x2C, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a bottle'),
'Bottle (Blue Potion)': ItemData(True, None, 0x2D, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a bottle'),
'Bottle (Fairy)': ItemData(True, None, 0x3D, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a bottle'),
'Bottle (Bee)': ItemData(True, None, 0x3C, 'I will sting your foes a few times', 'and the sting buddy', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has mad bee again', 'a bottle'),
'Bottle (Good Bee)': ItemData(True, None, 0x48, 'I will sting your foes a whole lot!', 'and the sparkle sting', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has beetor again', 'a bottle'),
'Master Sword': ItemData(True, 'Sword', 0x50, 'I beat barries and pigs alike', 'and the master sword', 'sword-wielding kid', 'glow sword for sale', 'fungus for blue slasher', 'sword boy fights again', 'the Master Sword'),
'Tempered Sword': ItemData(True, 'Sword', 0x02, 'I stole the\nblacksmith\'s\njob!', 'the tempered sword', 'sword-wielding kid', 'flame sword for sale', 'fungus for red slasher', 'sword boy fights again', 'the Tempered Sword'),
'Fighter Sword': ItemData(True, 'Sword', 0x49, 'A pathetic\nsword rests\nhere!', 'the tiny sword', 'sword-wielding kid', 'tiny sword for sale', 'fungus for tiny slasher', 'sword boy fights again', 'the Small Sword'),
'Golden Sword': ItemData(True, 'Sword', 0x03, 'The butter\nsword rests\nhere!', 'and the butter sword', 'sword-wielding kid', 'butter for sale', 'cap churned to butter', 'sword boy fights again', 'the Golden Sword'),
'Progressive Sword': ItemData(True, 'Sword', 0x5E, 'a better copy\nof your sword\nfor your time', 'the unknown sword', 'sword-wielding kid', 'sword for sale', 'fungus for some slasher', 'sword boy fights again', 'a Sword'),
'Progressive Glove': ItemData(True, None, 0x61, 'a way to lift\nheavier things', 'and the lift upgrade', 'body-building kid', 'some glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'a Glove'),
'Green Pendant': ItemData(True, 'Crystal', (0x04, 0x38, 0x62, 0x00, 0x69, 0x01), None, None, None, None, None, None, "the green pendant"),
'Blue Pendant': ItemData(True, 'Crystal', (0x02, 0x34, 0x60, 0x00, 0x69, 0x02), None, None, None, None, None, None, "the blue pendant"),
'Red Pendant': ItemData(True, 'Crystal', (0x01, 0x32, 0x60, 0x00, 0x69, 0x03), None, None, None, None, None, None, "the red pendant"),
'Triforce': ItemData(True, None, 0x6A, '\n YOU WIN!', 'and the triforce', 'victorious kid', 'victory for sale', 'fungus for the win', 'greedy boy wins game again', 'the Triforce'),
'Power Star': ItemData(True, None, 0x6B, 'a small victory', 'and the power star', 'star-struck kid', 'star for sale', 'see stars with shroom', 'mario powers up again', 'a Power Star'),
'Triforce Piece': ItemData(True, None, 0x6C, 'a small victory', 'and the thirdforce', 'triangular kid', 'triangle for sale', 'fungus for triangle', 'wise boy has triangle again', 'a Triforce Piece'),
'Crystal 1': ItemData(True, 'Crystal', (0x02, 0x34, 0x64, 0x40, 0x7F, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 2': ItemData(True, 'Crystal', (0x10, 0x34, 0x64, 0x40, 0x79, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 3': ItemData(True, 'Crystal', (0x40, 0x34, 0x64, 0x40, 0x6C, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 4': ItemData(True, 'Crystal', (0x20, 0x34, 0x64, 0x40, 0x6D, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 5': ItemData(True, 'Crystal', (0x04, 0x32, 0x64, 0x40, 0x6E, 0x06), None, None, None, None, None, None, "a red crystal"),
'Crystal 6': ItemData(True, 'Crystal', (0x01, 0x32, 0x64, 0x40, 0x6F, 0x06), None, None, None, None, None, None, "a red crystal"),
'Crystal 7': ItemData(True, 'Crystal', (0x08, 0x34, 0x64, 0x40, 0x7C, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Single Arrow': ItemData(False, None, 0x43, 'a lonely arrow\nsits here.', 'and the arrow', 'stick-collecting kid', 'sewing needle for sale', 'fungus for arrow', 'archer boy sews again', 'an arrow'),
'Arrows (10)': ItemData(False, None, 0x44, 'This will give\nyou ten shots\nwith your bow!', 'and the arrow pack','stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again','ten arrows'),
'Arrow Upgrade (+10)': ItemData(False, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (+5)': ItemData(False, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Single Bomb': ItemData(False, None, 0x27, 'I make things\ngo BOOM! But\njust once.', 'and the explosion', 'the bomb-holding kid', 'firecracker for sale', 'blend fungus into bomb', '\'splosion boy explodes again', 'a bomb'),
'Bombs (3)': ItemData(False, None, 0x28, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'),
'Bombs (10)': ItemData(False, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),
'Bomb Upgrade (+10)': ItemData(False, None, 0x52, 'increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'),
'Bomb Upgrade (+5)': ItemData(False, None, 0x51, 'increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'),
'Blue Mail': ItemData(False, None, 0x22, 'Now you\'re a\nblue elf!', 'and the banana hat', 'the protected kid', 'banana hat for sale', 'the clothing store', 'tailor boy banana hatted again', 'the Blue Mail'),
'Red Mail': ItemData(False, None, 0x23, 'Now you\'re a\nred elf!', 'and the eggplant hat', 'well-protected kid', 'purple hat for sale', 'the nice clothing store', 'tailor boy fears nothing again', 'the Red Mail'),
'Progressive Mail': ItemData(False, None, 0x60, 'time for a\nchange of\nclothes?', 'and the unknown hat', 'the protected kid', 'new hat for sale', 'the clothing store', 'tailor boy has threads again', 'some armor'),
'Blue Boomerang': ItemData(True, None, 0x0C, 'No matter what\nyou do, blue\nreturns to you', 'and the bluemarang', 'the bat-throwing kid', 'bent stick for sale', 'fungus for puma-stick', 'throwing boy plays fetch again', 'the Blue Boomerang'),
'Red Boomerang': ItemData(True, None, 0x2A, 'No matter what\nyou do, red\nreturns to you', 'and the badmarang', 'the bat-throwing kid', 'air foil for sale', 'fungus for return-stick', 'magical boy plays fetch again', 'the Red Boomerang'),
'Blue Shield': ItemData(False, None, 0x04, 'Now you can\ndefend against\npebbles!', 'and the stone blocker', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'the Blue Shield'),
'Red Shield': ItemData(False, None, 0x05, 'Now you can\ndefend against\nfireballs!', 'and the shot blocker', 'shield-wielding kid', 'fire shield for sale', 'fungus for fire shield', 'shield boy defends again', 'the Red Shield'),
'Mirror Shield': ItemData(True, None, 0x06, 'Now you can\ndefend against\nlasers!', 'and the laser blocker', 'shield-wielding kid', 'face shield for sale', 'fungus for face shield', 'shield boy defends again', 'the Mirror Shield'),
'Progressive Shield': ItemData(True, None, 0x5F, 'have a better\nblocker in\nfront of you', 'and the new shield', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'a shield'),
'Bug Catching Net': ItemData(True, None, 0x21, 'Let\'s catch\nsome bees and\nfaeries!', 'and the bee catcher', 'the bug-catching kid', 'stick web for sale', 'fungus for butterflies', 'wrong boy catches bees again', 'the Bug Net'),
'Cane of Byrna': ItemData(True, None, 0x18, 'Use this to\nbecome\ninvincible!', 'and the bad cane', 'the spark-making kid', 'spark stick for sale', 'spark-stick for trade', 'cane boy encircles again', 'the Blue Cane'),
'Boss Heart Container': ItemData(False, None, 0x3E, 'Maximum health\nincreased!\nYeah!', 'and the full heart', 'the life-giving kid', 'love for sale', 'fungus for life', 'life boy feels love again', 'a heart'),
'Sanctuary Heart Container': ItemData(False, None, 0x3F, 'Maximum health\nincreased!\nYeah!', 'and the full heart', 'the life-giving kid', 'love for sale', 'fungus for life', 'life boy feels love again', 'a heart'),
'Piece of Heart': ItemData(False, None, 0x17, 'Just a little\npiece of love!', 'and the broken heart', 'the life-giving kid', 'little love for sale', 'fungus for life', 'life boy feels some love again', 'a heart piece'),
'Rupee (1)': ItemData(False, None, 0x34, 'Just pocket\nchange. Move\nright along.', 'the pocket change', 'poverty-struck kid', 'life lesson for sale', 'buying cheap drugs', 'destitute boy has snack again', 'a green rupee'),
'Rupees (5)': ItemData(False, None, 0x35, 'Just pocket\nchange. Move\nright along.', 'the pocket change', 'poverty-struck kid', 'life lesson for sale', 'buying cheap drugs', 'destitute boy has snack again', 'a blue rupee'),
'Rupees (20)': ItemData(False, None, 0x36, 'Just couch\ncash. Move\nright along.', 'and the couch cash', 'the piggy-bank kid', 'life lesson for sale', 'the witch buying drugs', 'destitute boy has lunch again', 'a red rupee'),
'Rupees (50)': ItemData(False, None, 0x41, 'A rupee pile!\nOkay?', 'and the rupee pile', 'the well-off kid', 'life lesson for sale', 'buying okay drugs', 'destitute boy has dinner again', 'fifty rupees'),
'Rupees (100)': ItemData(False, None, 0x40, 'A rupee stash!\nHell yeah!', 'and the rupee stash', 'the kind-of-rich kid', 'life lesson for sale', 'buying good drugs', 'affluent boy goes drinking again', 'one hundred rupees'),
'Rupees (300)': ItemData(False, None, 0x46, 'A rupee hoard!\nHell yeah!', 'and the rupee hoard', 'the really-rich kid', 'life lesson for sale', 'buying the best drugs', 'fat-cat boy is rich again', 'three hundred rupees'),
'Rupoor': ItemData(False, None, 0x59, 'a debt collector', 'and the toll-booth', 'the toll-booth kid', 'double loss for sale', 'witch stole your rupees', 'affluent boy steals rupees', 'a rupoor', True),
'Red Clock': ItemData(False, None, 0x5B, 'a waste of time', 'the ruby clock', 'the ruby-time kid', 'red time for sale', 'for ruby time', 'moment boy travels time again', 'a red clock', True),
'Blue Clock': ItemData(False, None, 0x5C, 'a bit of time', 'the sapphire clock', 'sapphire-time kid', 'blue time for sale', 'for sapphire time', 'moment boy time travels again', 'a blue clock'),
'Green Clock': ItemData(False, None, 0x5D, 'a lot of time', 'the emerald clock', 'the emerald-time kid', 'green time for sale', 'for emerald time', 'moment boy adjusts time again', 'a red clock'),
'Single RNG': ItemData(False, None, 0x62, 'something you don\'t yet have', None, None, None, None, 'unknown boy somethings again', 'a new mystery'),
'Multi RNG': ItemData(False, None, 0x63, 'something you may already have', None, None, None, None, 'unknown boy somethings again', 'a total mystery'),
'Magic Upgrade (1/2)': ItemData(True, None, 0x4E, 'Your magic\npower has been\ndoubled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'Half Magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Magic Upgrade (1/4)': ItemData(True, None, 0x4F, 'Your magic\npower has been\nquadrupled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'Quarter Magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Small Key (Eastern Palace)': ItemData(True, 'SmallKey', 0xA2, 'A small key to the eastern palace', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Eastern Palace'),
'Big Key (Eastern Palace)': ItemData(True, 'BigKey', 0x9D, 'A big key to the eastern palace', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Eastern Palace'),
'Compass (Eastern Palace)': ItemData(False, 'Compass', 0x8D, 'Now you can find the the boss of the eastern palace!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Eastern Palace'),
'Map (Eastern Palace)': ItemData(False, 'Map', 0x7D, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Eastern Palace'),
'Small Key (Desert Palace)': ItemData(True, 'SmallKey', 0xA3, 'A small key to the desert', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Desert Palace'),
'Big Key (Desert Palace)': ItemData(True, 'BigKey', 0x9C, 'A big key to the desert', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Desert Palace'),
'Compass (Desert Palace)': ItemData(False, 'Compass', 0x8C, 'Now you can find the boss of the desert!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Desert Palace'),
'Map (Desert Palace)': ItemData(False, 'Map', 0x7C, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Desert Palace'),
'Small Key (Tower of Hera)': ItemData(True, 'SmallKey', 0xAA, 'A small key to Hera', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Tower of Hera'),
'Big Key (Tower of Hera)': ItemData(True, 'BigKey', 0x95, 'A big key to Hera', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Tower of Hera'),
'Compass (Tower of Hera)': ItemData(False, 'Compass', 0x85, 'Now you can find the boss of Hera!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Tower of Hera'),
'Map (Tower of Hera)': ItemData(False, 'Map', 0x75, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Tower of Hera'),
'Small Key (Hyrule Castle)': ItemData(True, 'SmallKey', 0xA0, 'A small key to the castle', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Hyrule Castle'),
'Big Key (Hyrule Castle)': ItemData(True, 'BigKey', 0x9F, 'A big key to the castle', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Hyrule Castle'),
'Compass (Hyrule Castle)': ItemData(False, 'Compass', 0x8F, 'Now you can find no boss!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Hyrule Castle'),
'Map (Hyrule Castle)': ItemData(False, 'Map', 0x7F, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Hyrule Castle'),
'Small Key (Agahnims Tower)': ItemData(True, 'SmallKey', 0xA4, 'A small key to the castle tower', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Castle Tower'),
item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'),
'Progressive Bow': ItemData(IC.progression, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
'Progressive Bow (Alt)': ItemData(IC.progression, None, 0x65, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
'Silver Arrows': ItemData(IC.progression, None, 0x58, 'Do you fancy\nsilver tipped\narrows?', 'and the ganonsbane','ganon-killing kid', 'ganon doom for sale', 'fungus for pork','archer boy shines again', 'the Silver Arrows'),
'Silver Bow': ItemData(IC.progression, None, 0x3B, 'Buy 1 Silver\nget Archery\nfor free.', 'the baconmaker', 'ganon-killing kid', 'ganon doom for sale', 'fungus for pork', 'archer boy shines again', 'the Silver Bow'),
'Book of Mudora': ItemData(IC.progression, None, 0x1D, 'Hylian\nfor\nDingusses.', 'and the story book', 'the scholarly kid', 'moon runes for sale', 'drugs for literacy', 'book-worm boy can read again', 'the Book'),
'Hammer': ItemData(IC.progression, None, 0x09, 'stop\nhammer time!', 'and m c hammer', 'hammer-smashing kid', 'm c hammer for sale', 'stop... hammer time', 'stop, hammer time', 'the Hammer'),
'Hookshot': ItemData(IC.progression, None, 0x0A, 'BOING!!!\nBOING!!!\nBOING!!!', 'and the tickle beam', 'tickle-monster kid', 'tickle beam for sale', 'witch and tickle boy', 'beam boy tickles again', 'the Hookshot'),
'Magic Mirror': ItemData(IC.progression, None, 0x1A, 'Isn\'t your\nreflection so\npretty?', 'the face reflector', 'the narcissistic kid', 'your face for sale', 'trades looking-glass', 'narcissistic boy is happy again', 'the Mirror'),
'Flute': ItemData(IC.progression, None, 0x14, 'Save the duck\nand fly to\nfreedom!', 'and the duck call', 'the duck-call kid', 'duck call for sale', 'duck-calls for trade', 'flute boy plays again', 'the Flute'),
'Pegasus Boots': ItemData(IC.progression, None, 0x4B, 'Gotta go fast!', 'and the sprint shoes', 'the running-man kid', 'sprint shoe for sale', 'shrooms for speed', 'gotta-go-fast boy runs again', 'the Boots'),
'Power Glove': ItemData(IC.progression, None, 0x1B, 'Now you can\nlift weak\nstuff!', 'and the grey mittens', 'body-building kid', 'lift glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'the Glove'),
'Cape': ItemData(IC.progression, None, 0x19, 'Wear this to\nbecome\ninvisible!', 'the camouflage cape', 'red riding-hood kid', 'red hood for sale', 'hood from a hood', 'dapper boy hides again', 'the Cape'),
'Mushroom': ItemData(IC.progression, None, 0x29, 'I\'m a fun guy!\n\nI\'m a funghi!', 'and the legal drugs', 'the drug-dealing kid', 'legal drugs for sale', 'shroom swap', 'shroom boy sells drugs again', 'the Mushroom'),
'Shovel': ItemData(IC.progression, None, 0x13, 'Can\n You\n Dig it?', 'and the spade', 'archaeologist kid', 'dirt spade for sale', 'can you dig it', 'shovel boy digs again', 'the Shovel'),
'Lamp': ItemData(IC.progression, None, 0x12, 'Baby, baby,\nbaby.\nLight my way!', 'and the flashlight', 'light-shining kid', 'flashlight for sale', 'fungus for illumination', 'illuminated boy can see again', 'the Lamp'),
'Magic Powder': ItemData(IC.progression, None, 0x0D, 'you can turn\nanti-faeries\ninto faeries', 'and the magic sack', 'the sack-holding kid', 'magic sack for sale', 'the witch and assistant', 'magic boy plays marbles again', 'the Powder'),
'Moon Pearl': ItemData(IC.progression, None, 0x1F, ' Bunny Link\n be\n gone!', 'and the jaw breaker', 'fortune-telling kid', 'lunar orb for sale', 'shrooms for moon rock', 'moon boy plays ball again', 'the Moon Pearl'),
'Cane of Somaria': ItemData(IC.progression, None, 0x15, 'I make blocks\nto hold down\nswitches!', 'and the red blocks', 'the block-making kid', 'block stick for sale', 'block stick for trade', 'cane boy makes blocks again', 'the Red Cane'),
'Fire Rod': ItemData(IC.progression, None, 0x07, 'I\'m the hot\nrod. I make\nthings burn!', 'and the flamethrower', 'fire-starting kid', 'rage rod for sale', 'fungus for rage-rod', 'firestarter boy burns again', 'the Fire Rod'),
'Flippers': ItemData(IC.progression, None, 0x1E, 'fancy a swim?', 'and the toewebs', 'the swimming kid', 'finger webs for sale', 'shrooms let you swim', 'swimming boy swims again', 'the Flippers'),
'Ice Rod': ItemData(IC.progression, None, 0x08, 'I\'m the cold\nrod. I make\nthings freeze!', 'and the freeze ray', 'the ice-bending kid', 'freeze ray for sale', 'fungus for ice-rod', 'ice-cube boy freezes again', 'the Ice Rod'),
'Titans Mitts': ItemData(IC.progression, None, 0x1C, 'Now you can\nlift heavy\nstuff!', 'and the golden glove', 'body-building kid', 'carry glove for sale', 'fungus for bling-gloves', 'body-building boy has gold again', 'the Mitts'),
'Bombos': ItemData(IC.progression, None, 0x0F, 'Burn, baby,\nburn! Fear my\nring of fire!', 'and the swirly coin', 'coin-collecting kid', 'swirly coin for sale', 'shrooms for swirly-coin', 'medallion boy melts room again', 'Bombos'),
'Ether': ItemData(IC.progression, None, 0x10, 'This magic\ncoin freezes\neverything!', 'and the bolt coin', 'coin-collecting kid', 'bolt coin for sale', 'shrooms for bolt-coin', 'medallion boy sees floor again', 'Ether'),
'Quake': ItemData(IC.progression, None, 0x11, 'Maxing out the\nRichter scale\nis what I do!', 'and the wavy coin', 'coin-collecting kid', 'wavy coin for sale', 'shrooms for wavy-coin', 'medallion boy shakes dirt again', 'Quake'),
'Bottle': ItemData(IC.progression, None, 0x16, 'Now you can\nstore potions\nand stuff!', 'and the terrarium', 'the terrarium kid', 'terrarium for sale', 'special promotion', 'bottle boy has terrarium again', 'a bottle'),
'Bottle (Red Potion)': ItemData(IC.progression, None, 0x2B, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a bottle'),
'Bottle (Green Potion)': ItemData(IC.progression, None, 0x2C, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a bottle'),
'Bottle (Blue Potion)': ItemData(IC.progression, None, 0x2D, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a bottle'),
'Bottle (Fairy)': ItemData(IC.progression, None, 0x3D, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a bottle'),
'Bottle (Bee)': ItemData(IC.progression, None, 0x3C, 'I will sting your foes a few times', 'and the sting buddy', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has mad bee again', 'a bottle'),
'Bottle (Good Bee)': ItemData(IC.progression, None, 0x48, 'I will sting your foes a whole lot!', 'and the sparkle sting', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has beetor again', 'a bottle'),
'Master Sword': ItemData(IC.progression, 'Sword', 0x50, 'I beat barries and pigs alike', 'and the master sword', 'sword-wielding kid', 'glow sword for sale', 'fungus for blue slasher', 'sword boy fights again', 'the Master Sword'),
'Tempered Sword': ItemData(IC.progression, 'Sword', 0x02, 'I stole the\nblacksmith\'s\njob!', 'the tempered sword', 'sword-wielding kid', 'flame sword for sale', 'fungus for red slasher', 'sword boy fights again', 'the Tempered Sword'),
'Fighter Sword': ItemData(IC.progression, 'Sword', 0x49, 'A pathetic\nsword rests\nhere!', 'the tiny sword', 'sword-wielding kid', 'tiny sword for sale', 'fungus for tiny slasher', 'sword boy fights again', 'the Small Sword'),
'Golden Sword': ItemData(IC.progression, 'Sword', 0x03, 'The butter\nsword rests\nhere!', 'and the butter sword', 'sword-wielding kid', 'butter for sale', 'cap churned to butter', 'sword boy fights again', 'the Golden Sword'),
'Progressive Sword': ItemData(IC.progression, 'Sword', 0x5E, 'a better copy\nof your sword\nfor your time', 'the unknown sword', 'sword-wielding kid', 'sword for sale', 'fungus for some slasher', 'sword boy fights again', 'a Sword'),
'Progressive Glove': ItemData(IC.progression, None, 0x61, 'a way to lift\nheavier things', 'and the lift upgrade', 'body-building kid', 'some glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'a Glove'),
'Green Pendant': ItemData(IC.progression, 'Crystal', (0x04, 0x38, 0x62, 0x00, 0x69, 0x01), None, None, None, None, None, None, "the green pendant"),
'Blue Pendant': ItemData(IC.progression, 'Crystal', (0x02, 0x34, 0x60, 0x00, 0x69, 0x02), None, None, None, None, None, None, "the blue pendant"),
'Red Pendant': ItemData(IC.progression, 'Crystal', (0x01, 0x32, 0x60, 0x00, 0x69, 0x03), None, None, None, None, None, None, "the red pendant"),
'Triforce': ItemData(IC.progression, None, 0x6A, '\n YOU WIN!', 'and the triforce', 'victorious kid', 'victory for sale', 'fungus for the win', 'greedy boy wins game again', 'the Triforce'),
'Power Star': ItemData(IC.progression, None, 0x6B, 'a small victory', 'and the power star', 'star-struck kid', 'star for sale', 'see stars with shroom', 'mario powers up again', 'a Power Star'),
'Triforce Piece': ItemData(IC.progression, None, 0x6C, 'a small victory', 'and the thirdforce', 'triangular kid', 'triangle for sale', 'fungus for triangle', 'wise boy has triangle again', 'a Triforce Piece'),
'Crystal 1': ItemData(IC.progression, 'Crystal', (0x02, 0x34, 0x64, 0x40, 0x7F, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 2': ItemData(IC.progression, 'Crystal', (0x10, 0x34, 0x64, 0x40, 0x79, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 3': ItemData(IC.progression, 'Crystal', (0x40, 0x34, 0x64, 0x40, 0x6C, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 4': ItemData(IC.progression, 'Crystal', (0x20, 0x34, 0x64, 0x40, 0x6D, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Crystal 5': ItemData(IC.progression, 'Crystal', (0x04, 0x32, 0x64, 0x40, 0x6E, 0x06), None, None, None, None, None, None, "a red crystal"),
'Crystal 6': ItemData(IC.progression, 'Crystal', (0x01, 0x32, 0x64, 0x40, 0x6F, 0x06), None, None, None, None, None, None, "a red crystal"),
'Crystal 7': ItemData(IC.progression, 'Crystal', (0x08, 0x34, 0x64, 0x40, 0x7C, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Single Arrow': ItemData(IC.filler, None, 0x43, 'a lonely arrow\nsits here.', 'and the arrow', 'stick-collecting kid', 'sewing needle for sale', 'fungus for arrow', 'archer boy sews again', 'an arrow'),
'Arrows (10)': ItemData(IC.filler, None, 0x44, 'This will give\nyou ten shots\nwith your bow!', 'and the arrow pack','stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again','ten arrows'),
'Arrow Upgrade (+10)': ItemData(IC.filler, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (+5)': ItemData(IC.filler, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Single Bomb': ItemData(IC.filler, None, 0x27, 'I make things\ngo BOOM! But\njust once.', 'and the explosion', 'the bomb-holding kid', 'firecracker for sale', 'blend fungus into bomb', '\'splosion boy explodes again', 'a bomb'),
'Bombs (3)': ItemData(IC.filler, None, 0x28, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'),
'Bombs (10)': ItemData(IC.filler, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),
'Bomb Upgrade (+10)': ItemData(IC.filler, None, 0x52, 'increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'),
'Bomb Upgrade (+5)': ItemData(IC.filler, None, 0x51, 'increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'),
'Blue Mail': ItemData(IC.useful, None, 0x22, 'Now you\'re a\nblue elf!', 'and the banana hat', 'the protected kid', 'banana hat for sale', 'the clothing store', 'tailor boy banana hatted again', 'the Blue Mail'),
'Red Mail': ItemData(IC.useful, None, 0x23, 'Now you\'re a\nred elf!', 'and the eggplant hat', 'well-protected kid', 'purple hat for sale', 'the nice clothing store', 'tailor boy fears nothing again', 'the Red Mail'),
'Progressive Mail': ItemData(IC.useful, None, 0x60, 'time for a\nchange of\nclothes?', 'and the unknown hat', 'the protected kid', 'new hat for sale', 'the clothing store', 'tailor boy has threads again', 'some armor'),
'Blue Boomerang': ItemData(IC.progression, None, 0x0C, 'No matter what\nyou do, blue\nreturns to you', 'and the bluemarang', 'the bat-throwing kid', 'bent stick for sale', 'fungus for puma-stick', 'throwing boy plays fetch again', 'the Blue Boomerang'),
'Red Boomerang': ItemData(IC.progression, None, 0x2A, 'No matter what\nyou do, red\nreturns to you', 'and the badmarang', 'the bat-throwing kid', 'air foil for sale', 'fungus for return-stick', 'magical boy plays fetch again', 'the Red Boomerang'),
'Blue Shield': ItemData(IC.filler, None, 0x04, 'Now you can\ndefend against\npebbles!', 'and the stone blocker', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'the Blue Shield'),
'Red Shield': ItemData(IC.filler, None, 0x05, 'Now you can\ndefend against\nfireballs!', 'and the shot blocker', 'shield-wielding kid', 'fire shield for sale', 'fungus for fire shield', 'shield boy defends again', 'the Red Shield'),
'Mirror Shield': ItemData(IC.progression, None, 0x06, 'Now you can\ndefend against\nlasers!', 'and the laser blocker', 'shield-wielding kid', 'face shield for sale', 'fungus for face shield', 'shield boy defends again', 'the Mirror Shield'),
'Progressive Shield': ItemData(IC.progression, None, 0x5F, 'have a better\nblocker in\nfront of you', 'and the new shield', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'a shield'),
'Bug Catching Net': ItemData(IC.progression, None, 0x21, 'Let\'s catch\nsome bees and\nfaeries!', 'and the bee catcher', 'the bug-catching kid', 'stick web for sale', 'fungus for butterflies', 'wrong boy catches bees again', 'the Bug Net'),
'Cane of Byrna': ItemData(IC.progression, None, 0x18, 'Use this to\nbecome\ninvincible!', 'and the bad cane', 'the spark-making kid', 'spark stick for sale', 'spark-stick for trade', 'cane boy encircles again', 'the Blue Cane'),
'Boss Heart Container': ItemData(IC.useful, None, 0x3E, 'Maximum health\nincreased!\nYeah!', 'and the full heart', 'the life-giving kid', 'love for sale', 'fungus for life', 'life boy feels love again', 'a heart'),
'Sanctuary Heart Container': ItemData(IC.useful, None, 0x3F, 'Maximum health\nincreased!\nYeah!', 'and the full heart', 'the life-giving kid', 'love for sale', 'fungus for life', 'life boy feels love again', 'a heart'),
'Piece of Heart': ItemData(IC.useful, None, 0x17, 'Just a little\npiece of love!', 'and the broken heart', 'the life-giving kid', 'little love for sale', 'fungus for life', 'life boy feels some love again', 'a heart piece'),
'Rupee (1)': ItemData(IC.filler, None, 0x34, 'Just pocket\nchange. Move\nright along.', 'the pocket change', 'poverty-struck kid', 'life lesson for sale', 'buying cheap drugs', 'destitute boy has snack again', 'a green rupee'),
'Rupees (5)': ItemData(IC.filler, None, 0x35, 'Just pocket\nchange. Move\nright along.', 'the pocket change', 'poverty-struck kid', 'life lesson for sale', 'buying cheap drugs', 'destitute boy has snack again', 'a blue rupee'),
'Rupees (20)': ItemData(IC.filler, None, 0x36, 'Just couch\ncash. Move\nright along.', 'and the couch cash', 'the piggy-bank kid', 'life lesson for sale', 'the witch buying drugs', 'destitute boy has lunch again', 'a red rupee'),
'Rupees (50)': ItemData(IC.filler, None, 0x41, 'A rupee pile!\nOkay?', 'and the rupee pile', 'the well-off kid', 'life lesson for sale', 'buying okay drugs', 'destitute boy has dinner again', 'fifty rupees'),
'Rupees (100)': ItemData(IC.filler, None, 0x40, 'A rupee stash!\nHell yeah!', 'and the rupee stash', 'the kind-of-rich kid', 'life lesson for sale', 'buying good drugs', 'affluent boy goes drinking again', 'one hundred rupees'),
'Rupees (300)': ItemData(IC.filler, None, 0x46, 'A rupee hoard!\nHell yeah!', 'and the rupee hoard', 'the really-rich kid', 'life lesson for sale', 'buying the best drugs', 'fat-cat boy is rich again', 'three hundred rupees'),
'Rupoor': ItemData(IC.trap, None, 0x59, 'a debt collector', 'and the toll-booth', 'the toll-booth kid', 'double loss for sale', 'witch stole your rupees', 'affluent boy steals rupees', 'a rupoor'),
'Red Clock': ItemData(IC.trap, None, 0x5B, 'a waste of time', 'the ruby clock', 'the ruby-time kid', 'red time for sale', 'for ruby time', 'moment boy travels time again', 'a red clock'),
'Blue Clock': ItemData(IC.filler, None, 0x5C, 'a bit of time', 'the sapphire clock', 'sapphire-time kid', 'blue time for sale', 'for sapphire time', 'moment boy time travels again', 'a blue clock'),
'Green Clock': ItemData(IC.useful, None, 0x5D, 'a lot of time', 'the emerald clock', 'the emerald-time kid', 'green time for sale', 'for emerald time', 'moment boy adjusts time again', 'a red clock'),
'Single RNG': ItemData(IC.filler, None, 0x62, 'something you don\'t yet have', None, None, None, None, 'unknown boy somethings again', 'a new mystery'),
'Multi RNG': ItemData(IC.filler, None, 0x63, 'something you may already have', None, None, None, None, 'unknown boy somethings again', 'a total mystery'),
'Magic Upgrade (1/2)': ItemData(IC.progression, None, 0x4E, 'Your magic\npower has been\ndoubled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'Half Magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Magic Upgrade (1/4)': ItemData(IC.progression, None, 0x4F, 'Your magic\npower has been\nquadrupled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'Quarter Magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Small Key (Eastern Palace)': ItemData(IC.progression, 'SmallKey', 0xA2, 'A small key to the eastern palace', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Eastern Palace'),
'Big Key (Eastern Palace)': ItemData(IC.progression, 'BigKey', 0x9D, 'A big key to the eastern palace', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Eastern Palace'),
'Compass (Eastern Palace)': ItemData(IC.filler, 'Compass', 0x8D, 'Now you can find the the boss of the eastern palace!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Eastern Palace'),
'Map (Eastern Palace)': ItemData(IC.filler, 'Map', 0x7D, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Eastern Palace'),
'Small Key (Desert Palace)': ItemData(IC.progression, 'SmallKey', 0xA3, 'A small key to the desert', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Desert Palace'),
'Big Key (Desert Palace)': ItemData(IC.progression, 'BigKey', 0x9C, 'A big key to the desert', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Desert Palace'),
'Compass (Desert Palace)': ItemData(IC.filler, 'Compass', 0x8C, 'Now you can find the boss of the desert!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Desert Palace'),
'Map (Desert Palace)': ItemData(IC.filler, 'Map', 0x7C, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Desert Palace'),
'Small Key (Tower of Hera)': ItemData(IC.progression, 'SmallKey', 0xAA, 'A small key to Hera', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Tower of Hera'),
'Big Key (Tower of Hera)': ItemData(IC.progression, 'BigKey', 0x95, 'A big key to Hera', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Tower of Hera'),
'Compass (Tower of Hera)': ItemData(IC.filler, 'Compass', 0x85, 'Now you can find the boss of Hera!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Tower of Hera'),
'Map (Tower of Hera)': ItemData(IC.filler, 'Map', 0x75, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Tower of Hera'),
'Small Key (Hyrule Castle)': ItemData(IC.progression, 'SmallKey', 0xA0, 'A small key to the castle', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Hyrule Castle'),
'Big Key (Hyrule Castle)': ItemData(IC.progression, 'BigKey', 0x9F, 'A big key to the castle', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Hyrule Castle'),
'Compass (Hyrule Castle)': ItemData(IC.filler, 'Compass', 0x8F, 'Now you can find no boss!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Hyrule Castle'),
'Map (Hyrule Castle)': ItemData(IC.filler, 'Map', 0x7F, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Hyrule Castle'),
'Small Key (Agahnims Tower)': ItemData(IC.progression, 'SmallKey', 0xA4, 'A small key to the castle tower', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Castle Tower'),
# doors-specific items, baserom will not be able to understand these
'Big Key (Agahnims Tower)': ItemData(True, 'BigKey', 0x9B, 'A big key to the castle tower', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Castle Tower'),
'Compass (Agahnims Tower)': ItemData(False, 'Compass', 0x8B, 'Now you can find the boss of the castle tower!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds null again', 'a compass to Castle Tower'),
'Map (Agahnims Tower)': ItemData(False, 'Map', 0x7B, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Castle Tower'),
'Big Key (Agahnims Tower)': ItemData(IC.progression, 'BigKey', 0x9B, 'A big key to the castle tower', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Castle Tower'),
'Compass (Agahnims Tower)': ItemData(IC.filler, 'Compass', 0x8B, 'Now you can find the boss of the castle tower!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds null again', 'a compass to Castle Tower'),
'Map (Agahnims Tower)': ItemData(IC.filler, 'Map', 0x7B, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Castle Tower'),
# end of doors-specific items
'Small Key (Palace of Darkness)': ItemData(True, 'SmallKey', 0xA6, 'A small key to darkness', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Palace of Darkness'),
'Big Key (Palace of Darkness)': ItemData(True, 'BigKey', 0x99, 'A big key to darkness', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Palace of Darkness'),
'Compass (Palace of Darkness)': ItemData(False, 'Compass', 0x89, 'Now you can find the boss of darkness!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Palace of Darkness'),
'Map (Palace of Darkness)': ItemData(False, 'Map', 0x79, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Palace of Darkness'),
'Small Key (Thieves Town)': ItemData(True, 'SmallKey', 0xAB, 'A small key to thievery', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Thieves\' Town'),
'Big Key (Thieves Town)': ItemData(True, 'BigKey', 0x94, 'A big key to thievery', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Thieves\' Town'),
'Compass (Thieves Town)': ItemData(False, 'Compass', 0x84, 'Now you can find the boss of thievery!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Thieves\' Town'),
'Map (Thieves Town)': ItemData(False, 'Map', 0x74, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Thieves\' Town'),
'Small Key (Skull Woods)': ItemData(True, 'SmallKey', 0xA8, 'A small key to the woods', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Skull Woods'),
'Big Key (Skull Woods)': ItemData(True, 'BigKey', 0x97, 'A big key to the woods', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Skull Woods'),
'Compass (Skull Woods)': ItemData(False, 'Compass', 0x87, 'Now you can find the boss of the woods!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Skull Woods'),
'Map (Skull Woods)': ItemData(False, 'Map', 0x77, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Skull Woods'),
'Small Key (Swamp Palace)': ItemData(True, 'SmallKey', 0xA5, 'A small key to the swamp', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Swamp Palace'),
'Big Key (Swamp Palace)': ItemData(True, 'BigKey', 0x9A, 'A big key to the swamp', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Swamp Palace'),
'Compass (Swamp Palace)': ItemData(False, 'Compass', 0x8A, 'Now you can find the boss of the swamp!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Swamp Palace'),
'Map (Swamp Palace)': ItemData(False, 'Map', 0x7A, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Swamp Palace'),
'Small Key (Ice Palace)': ItemData(True, 'SmallKey', 0xA9, 'A small key to the iceberg', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Ice Palace'),
'Big Key (Ice Palace)': ItemData(True, 'BigKey', 0x96, 'A big key to the iceberg', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Ice Palace'),
'Compass (Ice Palace)': ItemData(False, 'Compass', 0x86, 'Now you can find the boss of the iceberg!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Ice Palace'),
'Map (Ice Palace)': ItemData(False, 'Map', 0x76, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Ice Palace'),
'Small Key (Misery Mire)': ItemData(True, 'SmallKey', 0xA7, 'A small key to the mire', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Misery Mire'),
'Big Key (Misery Mire)': ItemData(True, 'BigKey', 0x98, 'A big key to the mire', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Misery Mire'),
'Compass (Misery Mire)': ItemData(False, 'Compass', 0x88, 'Now you can find the boss of the mire!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Misery Mire'),
'Map (Misery Mire)': ItemData(False, 'Map', 0x78, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Misery Mire'),
'Small Key (Turtle Rock)': ItemData(True, 'SmallKey', 0xAC, 'A small key to the pipe maze', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Turtle Rock'),
'Big Key (Turtle Rock)': ItemData(True, 'BigKey', 0x93, 'A big key to the pipe maze', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Turtle Rock'),
'Compass (Turtle Rock)': ItemData(False, 'Compass', 0x83, 'Now you can find the boss of the pipe maze!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Turtle Rock'),
'Map (Turtle Rock)': ItemData(False, 'Map', 0x73, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Turtle Rock'),
'Small Key (Ganons Tower)': ItemData(True, 'SmallKey', 0xAD, 'A small key to the evil tower', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Ganon\'s Tower'),
'Big Key (Ganons Tower)': ItemData(True, 'BigKey', 0x92, 'A big key to the evil tower', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Ganon\'s Tower'),
'Compass (Ganons Tower)': ItemData(False, 'Compass', 0x82, 'Now you can find the boss of the evil tower!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Ganon\'s Tower'),
'Map (Ganons Tower)': ItemData(False, 'Map', 0x72, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Ganon\'s Tower'),
'Small Key (Universal)': ItemData(False, None, 0xAF, 'A small key for any door', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key'),
'Nothing': ItemData(False, None, 0x5A, 'Some Hot Air', 'and the Nothing', 'the zen kid', 'outright theft', 'shroom theft', 'empty boy is bored again', 'nothing'),
'Bee Trap': ItemData(False, None, 0xB0, 'We will sting your face a whole lot!', 'and the sting buddies', 'the beekeeper kid', 'insects for sale', 'shroom pollenation', 'bottle boy has mad bees again', 'Friendship', True),
'Faerie': ItemData(False, None, 0xB1, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a faerie'),
'Good Bee': ItemData(False, None, 0xB2, 'Save me and I will sting you (sometimes)', 'and the captive', 'the tingle kid','hostage for sale', 'good dust and shrooms', 'bottle boy has friend again', 'a bee'),
'Magic Jar': ItemData(False, None, 0xB3, '', '', '','', '', '', ''),
'Apple': ItemData(False, None, 0xB4, '', '', '','', '', '', ''),
# 'Hint': ItemData(False, None, 0xB5, '', '', '','', '', '', ''),
# 'Bomb Trap': ItemData(False, None, 0xB6, '', '', '','', '', '', ''),
'Red Potion': ItemData(False, None, 0x2E, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a red potion'),
'Green Potion': ItemData(False, None, 0x2F, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a green potion'),
'Blue Potion': ItemData(False, None, 0x30, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a blue potion'),
'Bee': ItemData(False, None, 0x0E, 'I will sting your foes a few times', 'and the sting buddy', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has mad bee again', 'a bee', True),
'Small Heart': ItemData(False, None, 0x42, 'Just a little\npiece of love!', 'and the heart', 'the life-giving kid', 'little love for sale', 'fungus for life', 'life boy feels some love again', 'a heart'),
'Activated Flute': ItemData(True, None, 0x4A, 'Save the duck\nand fly to\nfreedom!', 'and the duck call', 'the duck-call kid', 'duck call for sale', 'duck-calls for trade', 'flute boy plays again', 'the Flute'),
'Beat Agahnim 1': ItemData(True, 'Event', None, None, None, None, None, None, None, None),
'Beat Agahnim 2': ItemData(True, 'Event', None, None, None, None, None, None, None, None),
'Get Frog': ItemData(True, 'Event', None, None, None, None, None, None, None, None),
'Return Smith': ItemData(True, 'Event', None, None, None, None, None, None, None, None),
'Pick Up Purple Chest': ItemData(True, 'Event', None, None, None, None, None, None, None, None),
'Open Floodgate': ItemData(True, 'Event', None, None, None, None, None, None, None, None),
'Small Key (Palace of Darkness)': ItemData(IC.progression, 'SmallKey', 0xA6, 'A small key to darkness', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Palace of Darkness'),
'Big Key (Palace of Darkness)': ItemData(IC.progression, 'BigKey', 0x99, 'A big key to darkness', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Palace of Darkness'),
'Compass (Palace of Darkness)': ItemData(IC.filler, 'Compass', 0x89, 'Now you can find the boss of darkness!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Palace of Darkness'),
'Map (Palace of Darkness)': ItemData(IC.filler, 'Map', 0x79, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Palace of Darkness'),
'Small Key (Thieves Town)': ItemData(IC.progression, 'SmallKey', 0xAB, 'A small key to thievery', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Thieves\' Town'),
'Big Key (Thieves Town)': ItemData(IC.progression, 'BigKey', 0x94, 'A big key to thievery', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Thieves\' Town'),
'Compass (Thieves Town)': ItemData(IC.filler, 'Compass', 0x84, 'Now you can find the boss of thievery!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Thieves\' Town'),
'Map (Thieves Town)': ItemData(IC.filler, 'Map', 0x74, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Thieves\' Town'),
'Small Key (Skull Woods)': ItemData(IC.progression, 'SmallKey', 0xA8, 'A small key to the woods', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Skull Woods'),
'Big Key (Skull Woods)': ItemData(IC.progression, 'BigKey', 0x97, 'A big key to the woods', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Skull Woods'),
'Compass (Skull Woods)': ItemData(IC.filler, 'Compass', 0x87, 'Now you can find the boss of the woods!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Skull Woods'),
'Map (Skull Woods)': ItemData(IC.filler, 'Map', 0x77, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Skull Woods'),
'Small Key (Swamp Palace)': ItemData(IC.progression, 'SmallKey', 0xA5, 'A small key to the swamp', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Swamp Palace'),
'Big Key (Swamp Palace)': ItemData(IC.progression, 'BigKey', 0x9A, 'A big key to the swamp', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Swamp Palace'),
'Compass (Swamp Palace)': ItemData(IC.filler, 'Compass', 0x8A, 'Now you can find the boss of the swamp!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Swamp Palace'),
'Map (Swamp Palace)': ItemData(IC.filler, 'Map', 0x7A, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Swamp Palace'),
'Small Key (Ice Palace)': ItemData(IC.progression, 'SmallKey', 0xA9, 'A small key to the iceberg', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Ice Palace'),
'Big Key (Ice Palace)': ItemData(IC.progression, 'BigKey', 0x96, 'A big key to the iceberg', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Ice Palace'),
'Compass (Ice Palace)': ItemData(IC.filler, 'Compass', 0x86, 'Now you can find the boss of the iceberg!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Ice Palace'),
'Map (Ice Palace)': ItemData(IC.filler, 'Map', 0x76, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Ice Palace'),
'Small Key (Misery Mire)': ItemData(IC.progression, 'SmallKey', 0xA7, 'A small key to the mire', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Misery Mire'),
'Big Key (Misery Mire)': ItemData(IC.progression, 'BigKey', 0x98, 'A big key to the mire', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Misery Mire'),
'Compass (Misery Mire)': ItemData(IC.filler, 'Compass', 0x88, 'Now you can find the boss of the mire!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Misery Mire'),
'Map (Misery Mire)': ItemData(IC.filler, 'Map', 0x78, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Misery Mire'),
'Small Key (Turtle Rock)': ItemData(IC.progression, 'SmallKey', 0xAC, 'A small key to the pipe maze', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Turtle Rock'),
'Big Key (Turtle Rock)': ItemData(IC.progression, 'BigKey', 0x93, 'A big key to the pipe maze', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Turtle Rock'),
'Compass (Turtle Rock)': ItemData(IC.filler, 'Compass', 0x83, 'Now you can find the boss of the pipe maze!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Turtle Rock'),
'Map (Turtle Rock)': ItemData(IC.filler, 'Map', 0x73, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Turtle Rock'),
'Small Key (Ganons Tower)': ItemData(IC.progression, 'SmallKey', 0xAD, 'A small key to the evil tower', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Ganon\'s Tower'),
'Big Key (Ganons Tower)': ItemData(IC.progression, 'BigKey', 0x92, 'A big key to the evil tower', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Ganon\'s Tower'),
'Compass (Ganons Tower)': ItemData(IC.filler, 'Compass', 0x82, 'Now you can find the boss of the evil tower!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Ganon\'s Tower'),
'Map (Ganons Tower)': ItemData(IC.filler, 'Map', 0x72, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Ganon\'s Tower'),
'Small Key (Universal)': ItemData(IC.filler, None, 0xAF, 'A small key for any door', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key'),
'Nothing': ItemData(IC.trap, None, 0x5A, 'Some Hot Air', 'and the Nothing', 'the zen kid', 'outright theft', 'shroom theft', 'empty boy is bored again', 'nothing'),
'Bee Trap': ItemData(IC.trap, None, 0xB0, 'We will sting your face a whole lot!', 'and the sting buddies', 'the beekeeper kid', 'insects for sale', 'shroom pollenation', 'bottle boy has mad bees again', 'Friendship'),
'Faerie': ItemData(IC.filler, None, 0xB1, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a faerie'),
'Good Bee': ItemData(IC.filler, None, 0xB2, 'Save me and I will sting you (sometimes)', 'and the captive', 'the tingle kid','hostage for sale', 'good dust and shrooms', 'bottle boy has friend again', 'a bee'),
'Magic Jar': ItemData(IC.filler, None, 0xB3, '', '', '','', '', '', ''),
'Apple': ItemData(IC.filler, None, 0xB4, '', '', '','', '', '', ''),
# 'Hint': ItemData(IC.filler, None, 0xB5, '', '', '','', '', '', ''),
# 'Bomb Trap': ItemData(IC.filler, None, 0xB6, '', '', '','', '', '', ''),
'Red Potion': ItemData(IC.filler, None, 0x2E, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a red potion'),
'Green Potion': ItemData(IC.filler, None, 0x2F, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a green potion'),
'Blue Potion': ItemData(IC.filler, None, 0x30, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a blue potion'),
'Bee': ItemData(IC.trap, None, 0x0E, 'I will sting your foes a few times', 'and the sting buddy', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has mad bee again', 'a bee'),
'Small Heart': ItemData(IC.filler, None, 0x42, 'Just a little\npiece of love!', 'and the heart', 'the life-giving kid', 'little love for sale', 'fungus for life', 'life boy feels some love again', 'a heart'),
'Activated Flute': ItemData(IC.progression, None, 0x4A, 'Save the duck\nand fly to\nfreedom!', 'and the duck call', 'the duck-call kid', 'duck call for sale', 'duck-calls for trade', 'flute boy plays again', 'the Flute'),
'Beat Agahnim 1': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
'Beat Agahnim 2': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
'Get Frog': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
'Return Smith': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
'Pick Up Purple Chest': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
'Open Floodgate': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
}
as_dict_item_table = {name: data._asdict() for name, data in item_table.items()}
@@ -276,8 +275,10 @@ for basename, substring in _simple_groups:
del (_simple_groups)
progression_items = {name for name, data in item_table.items() if type(data.item_code) == int and data.advancement}
everything = {name for name, data in item_table.items() if type(data.item_code) == int}
progression_items = {name for name in everything if
item_table[name].classification in {IC.progression, IC.progression_skip_balancing}}
item_name_groups['Progression Items'] = progression_items
item_name_groups['Non Progression Items'] = everything - progression_items

View File

@@ -147,10 +147,17 @@ class Swordless(Toggle):
display_name = "Swordless"
class Retro(Toggle):
"""Zelda-1 like mode. You have to purchase a quiver to shoot arrows using rupees
and there are randomly placed take-any caves that contain one Sword and choices of Heart Container/Blue Potion."""
display_name = "Retro"
# Might be a decent idea to split "Bow" into its own option with choices of
# Defer to Progressive Option (default), Progressive, Non-Progressive, Bow + Silvers, Retro
class RetroBow(Toggle):
"""Zelda-1 like mode. You have to purchase a quiver to shoot arrows using rupees."""
display_name = "Retro Bow"
class RetroCaves(Toggle):
"""Zelda-1 like mode. There are randomly placed take-any caves that contain one Sword and
choices of Heart Container/Blue Potion."""
display_name = "Retro Caves"
class RestrictBossItem(Toggle):
@@ -159,11 +166,9 @@ class RestrictBossItem(Toggle):
class Hints(Choice):
"""Vendors: King Zora and Bottle Merchant say what they're selling.
On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints."""
"""On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints."""
display_name = "Hints"
option_off = 0
option_vendors = 1
option_on = 2
option_full = 3
default = 2
@@ -171,6 +176,22 @@ class Hints(Choice):
alias_true = 2
class Scams(Choice):
"""If on, these Merchants will no longer tell you what they're selling."""
display_name = "Scams"
option_off = 0
option_king_zora = 1
option_bottle_merchant = 2
option_all = 3
alias_false = 0
def gives_king_zora_hint(self):
return self.value in {0, 2}
def gives_bottle_merchant_hint(self):
return self.value in {0, 1}
class EnemyShuffle(Toggle):
"""Randomize every enemy spawn.
If mode is Standard, Hyrule Castle is left out (may result in visually wrong enemy sprites in that area.)"""
@@ -316,8 +337,10 @@ alttp_options: typing.Dict[str, type(Option)] = {
"map_shuffle": map_shuffle,
"progressive": Progressive,
"swordless": Swordless,
"retro": Retro,
"retro_bow": RetroBow,
"retro_caves": RetroCaves,
"hints": Hints,
"scams": Scams,
"restrict_dungeon_item_on_boss": RestrictBossItem,
"pot_shuffle": PotShuffle,
"enemy_shuffle": EnemyShuffle,

View File

@@ -873,7 +873,7 @@ def patch_rom(world, rom, player, enemized):
return 0x53 + int(num), 0x79 + int(num)
credits_total = 216
if world.retro[player]: # Old man cave and Take any caves will count towards collection rate.
if world.retro_caves[player]: # Old man cave and Take any caves will count towards collection rate.
credits_total += 5
if world.shop_item_slots[player]: # Potion shop only counts towards collection rate if included in the shuffle.
credits_total += 30 if 'w' in world.shop_shuffle[player] else 27
@@ -1037,7 +1037,7 @@ def patch_rom(world, rom, player, enemized):
prize_replacements[0xE0] = 0xDF # Fairy -> heart
prize_replacements[0xE3] = 0xD8 # Big magic -> small magic
if world.retro[player]:
if world.retro_bow[player]:
prize_replacements[0xE1] = 0xDA # 5 Arrows -> Blue Rupee
prize_replacements[0xE2] = 0xDB # 10 Arrows -> Red Rupee
@@ -1130,7 +1130,7 @@ def patch_rom(world, rom, player, enemized):
0x12, 0x01, 0x35, 0xFF, # lamp -> 5 rupees
0x51, 0x06, 0x52, 0xFF, # 6 +5 bomb upgrades -> +10 bomb upgrade
0x53, 0x06, 0x54, 0xFF, # 6 +5 arrow upgrades -> +10 arrow upgrade
0x58, 0x01, 0x36 if world.retro[player] else 0x43, 0xFF, # silver arrows -> single arrow (red 20 in retro mode)
0x58, 0x01, 0x36 if world.retro_bow[player] else 0x43, 0xFF, # silver arrows -> single arrow (red 20 in retro mode)
0x3E, difficulty.boss_heart_container_limit, 0x47, 0xff, # boss heart -> green 20
0x17, difficulty.heart_piece_limit, 0x47, 0xff, # piece of heart -> green 20
0xFF, 0xFF, 0xFF, 0xFF, # end of table sentinel
@@ -1270,12 +1270,12 @@ def patch_rom(world, rom, player, enemized):
if startingstate.has('Silver Bow', player):
equip[0x340] = 1
equip[0x38E] |= 0x60
if not world.retro[player]:
if not world.retro_bow[player]:
equip[0x38E] |= 0x80
elif startingstate.has('Bow', player):
equip[0x340] = 1
equip[0x38E] |= 0x20 # progressive flag to get the correct hint in all cases
if not world.retro[player]:
if not world.retro_bow[player]:
equip[0x38E] |= 0x80
if startingstate.has('Silver Arrows', player):
equip[0x38E] |= 0x40
@@ -1413,7 +1413,7 @@ def patch_rom(world, rom, player, enemized):
elif item.name in bombs:
equip[0x343] += bombs[item.name]
elif item.name in arrows:
if world.retro[player]:
if world.retro_bow[player]:
equip[0x38E] |= 0x80
equip[0x377] = 1
else:
@@ -1547,18 +1547,18 @@ def patch_rom(world, rom, player, enemized):
rom.write_byte(0x180172, 0x01 if world.smallkey_shuffle[
player] == smallkey_shuffle.option_universal else 0x00) # universal keys
rom.write_byte(0x18637E, 0x01 if world.retro[player] else 0x00) # Skip quiver in item shops once bought
rom.write_byte(0x180175, 0x01 if world.retro[player] else 0x00) # rupee bow
rom.write_byte(0x180176, 0x0A if world.retro[player] else 0x00) # wood arrow cost
rom.write_byte(0x180178, 0x32 if world.retro[player] else 0x00) # silver arrow cost
rom.write_byte(0x301FC, 0xDA if world.retro[player] else 0xE1) # rupees replace arrows under pots
rom.write_byte(0x30052, 0xDB if world.retro[player] else 0xE2) # replace arrows in fish prize from bottle merchant
rom.write_bytes(0xECB4E, [0xA9, 0x00, 0xEA, 0xEA] if world.retro[player] else [0xAF, 0x77, 0xF3,
rom.write_byte(0x18637E, 0x01 if world.retro_bow[player] else 0x00) # Skip quiver in item shops once bought
rom.write_byte(0x180175, 0x01 if world.retro_bow[player] else 0x00) # rupee bow
rom.write_byte(0x180176, 0x0A if world.retro_bow[player] else 0x00) # wood arrow cost
rom.write_byte(0x180178, 0x32 if world.retro_bow[player] else 0x00) # silver arrow cost
rom.write_byte(0x301FC, 0xDA if world.retro_bow[player] else 0xE1) # rupees replace arrows under pots
rom.write_byte(0x30052, 0xDB if world.retro_bow[player] else 0xE2) # replace arrows in fish prize from bottle merchant
rom.write_bytes(0xECB4E, [0xA9, 0x00, 0xEA, 0xEA] if world.retro_bow[player] else [0xAF, 0x77, 0xF3,
0x7E]) # Thief steals rupees instead of arrows
rom.write_bytes(0xF0D96, [0xA9, 0x00, 0xEA, 0xEA] if world.retro[player] else [0xAF, 0x77, 0xF3,
rom.write_bytes(0xF0D96, [0xA9, 0x00, 0xEA, 0xEA] if world.retro_bow[player] else [0xAF, 0x77, 0xF3,
0x7E]) # Pikit steals rupees instead of arrows
rom.write_bytes(0xEDA5,
[0x35, 0x41] if world.retro[player] else [0x43, 0x44]) # Chest game gives rupees instead of arrows
[0x35, 0x41] if world.retro_bow[player] else [0x43, 0x44]) # Chest game gives rupees instead of arrows
digging_game_rng = local_random.randint(1, 30) # set rng for digging game
rom.write_byte(0x180020, digging_game_rng)
rom.write_byte(0xEFD95, digging_game_rng)
@@ -1727,7 +1727,7 @@ def write_custom_shops(rom, world, player):
item_code = get_nonnative_item_sprite(item['item'])
else:
item_code = ItemFactory(item['item'], player).code
if item['item'] == 'Single Arrow' and item['player'] == 0 and world.retro[player]:
if item['item'] == 'Single Arrow' and item['player'] == 0 and world.retro_bow[player]:
rom.write_byte(0x186500 + shop.sram_offset + slot, arrow_mask)
item_data = [shop_id, item_code] + price_data + \
@@ -1740,7 +1740,7 @@ def write_custom_shops(rom, world, player):
items_data.extend([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
rom.write_bytes(0x184900, items_data)
if world.retro[player]:
if world.retro_bow[player]:
retro_shop_slots.append(0xFF)
rom.write_bytes(0x186540, retro_shop_slots)
@@ -2120,16 +2120,19 @@ def write_strings(rom, world, player):
hint += f" for {world.player_name[dest.player]}"
return hint
# For hints, first we write hints about entrances, some from the inconvenient list others from all reasonable entrances.
if world.hints[player]:
if world.scams[player].gives_king_zora_hint:
# Zora hint
zora_location = world.get_location("King Zora", player)
tt['zora_tells_cost'] = f"You got 500 rupees to buy {hint_text(zora_location.item)}" \
f"\n ≥ Duh\n Oh carp\n{{CHOICE}}"
if world.scams[player].gives_bottle_merchant_hint:
# Bottle Vendor hint
vendor_location = world.get_location("Bottle Merchant", player)
tt['bottle_vendor_choice'] = f"I gots {hint_text(vendor_location.item)}\nYous gots 100 rupees?" \
f"\n ≥ I want\n no way!\n{{CHOICE}}"
# First we write hints about entrances, some from the inconvenient list others from all reasonable entrances.
if world.hints[player]:
if world.hints[player].value >= 2:
if world.hints[player] == "full":
tt['sign_north_of_links_house'] = '> Randomizer The telepathic tiles have hints!'
@@ -2280,7 +2283,7 @@ def write_strings(rom, world, player):
items_to_hint |= item_name_groups["Big Keys"]
if world.hints[player] == "full":
hint_count = len(hint_locations) # fill all remaining hint locations with Item hints.
hint_count = len(hint_locations) # fill all remaining hint locations with Item hints.
else:
hint_count = 5 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 8

View File

@@ -249,7 +249,7 @@ def ShopSlotFill(world):
if location.item.game != "A Link to the Past":
if location.item.advancement:
price = world.random.randrange(8, 56)
elif location.item.never_exclude:
elif location.item.useful:
price = world.random.randrange(4, 28)
else:
price = world.random.randrange(2, 14)
@@ -287,7 +287,7 @@ def create_shops(world, player: int):
if 'g' in option or 'f' in option:
default_shop_table = [i for l in
[shop_generation_types[x] for x in ['arrows', 'bombs', 'potions', 'shields', 'bottle'] if
not world.retro[player] or x != 'arrows'] for i in l]
not world.retro_bow[player] or x != 'arrows'] for i in l]
new_basic_shop = world.random.sample(default_shop_table, k=3)
new_dark_shop = world.random.sample(default_shop_table, k=3)
for name, shop in player_shop_table.items():
@@ -305,7 +305,7 @@ def create_shops(world, player: int):
# make sure that blue potion is available in inverted, special case locked = None; lock when done.
player_shop_table["Dark Lake Hylia Shop"] = \
player_shop_table["Dark Lake Hylia Shop"]._replace(items=_inverted_hylia_shop_defaults, locked=None)
chance_100 = int(world.retro[player]) * 0.25 + int(
chance_100 = int(world.retro_bow[player]) * 0.25 + int(
world.smallkey_shuffle[player] == smallkey_shuffle.option_universal) * 0.5
for region_name, (room_id, type, shopkeeper, custom, locked, inventory, sram_offset) in player_shop_table.items():
region = world.get_region(region_name, player)
@@ -402,7 +402,7 @@ shop_generation_types = {
def set_up_shops(world, player: int):
# TODO: move hard+ mode changes for shields here, utilizing the new shops
if world.retro[player]:
if world.retro_bow[player]:
rss = world.get_region('Red Shield Shop', player).shop
replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50],
['Blue Shield', 50], ['Small Heart',
@@ -413,7 +413,7 @@ def set_up_shops(world, player: int):
rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1])
rss.locked = True
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal or world.retro[player]:
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal or world.retro_bow[player]:
for shop in world.random.sample([s for s in world.shops if
s.custom and not s.locked and s.type == ShopType.Shop and s.region.player == player],
5):
@@ -423,7 +423,7 @@ def set_up_shops(world, player: int):
slots = iter(slots)
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal:
shop.add_inventory(next(slots), 'Small Key (Universal)', 100)
if world.retro[player]:
if world.retro_bow[player]:
shop.push_inventory(next(slots), 'Single Arrow', 80)
@@ -436,7 +436,7 @@ def shuffle_shops(world, items, player: int):
new_items = ["Bomb Upgrade (+5)"] * 6
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
if not world.retro[player]:
if not world.retro_bow[player]:
new_items += ["Arrow Upgrade (+5)"] * 6
new_items.append("Arrow Upgrade (+5)" if progressive else "Arrow Upgrade (+10)")
@@ -578,7 +578,7 @@ def price_to_funny_price(world, item: dict, player: int):
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal \
and not "Small Key (Universal)" == item['replacement']:
price_types.append(ShopPriceType.Keys)
if not world.retro[player]:
if not world.retro_bow[player]:
price_types.append(ShopPriceType.Arrows)
world.random.shuffle(price_types)
for p_type in price_types:

View File

@@ -1,7 +1,7 @@
"""Module extending BaseClasses.py for aLttP"""
from typing import Optional
from BaseClasses import Location, Item
from BaseClasses import Location, Item, ItemClassification
class ALttPLocation(Location):
@@ -20,10 +20,10 @@ class ALttPItem(Item):
game: str = "A Link to the Past"
dungeon = None
def __init__(self, name, player, advancement=False, type=None, item_code=None, pedestal_hint=None,
def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, pedestal_hint=None,
pedestal_credit=None, sick_kid_credit=None, zora_credit=None, witch_credit=None,
flute_boy_credit=None, hint_text=None, trap=False):
super(ALttPItem, self).__init__(name, advancement, item_code, player)
flute_boy_credit=None, hint_text=None):
super(ALttPItem, self).__init__(name, classification, item_code, player)
self.type = type
self._pedestal_hint_text = pedestal_hint
self.pedestal_credit_text = pedestal_credit
@@ -32,8 +32,6 @@ class ALttPItem(Item):
self.magicshop_credit_text = witch_credit
self.fluteboy_credit_text = flute_boy_credit
self._hint_text = hint_text
if trap:
self.trap = trap
@property
def crystal(self) -> bool:

View File

@@ -24,6 +24,7 @@ from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_con
lttp_logger = logging.getLogger("A Link to the Past")
extras_list = sum(difficulties['normal'].extras[0:5], [])
class ALTTPWeb(WebWorld):
setup_en = Tutorial(
@@ -76,7 +77,7 @@ class ALTTPWeb(WebWorld):
msu.description,
"Español",
"msu1_es.md",
"msu1/en",
"msu1/es",
["Edos"]
)
@@ -154,7 +155,7 @@ class ALTTPWorld(World):
self.er_seed = "vanilla"
elif seed.startswith("group-") or world.is_race:
self.er_seed = get_same_seed(world, (
shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
shuffle, seed, world.retro_caves[player], world.mode[player], world.logic[player]))
else: # not a race or group seed, use set seed as is.
self.er_seed = seed
elif world.shuffle[player] == "vanilla":
@@ -471,19 +472,21 @@ class ALTTPWorld(World):
while gtower_locations and gt_item_pool and trash_count > 0:
spot_to_fill = gtower_locations.pop()
item_to_place = gt_item_pool.pop()
if item_to_place in localrest:
localrest.remove(item_to_place)
else:
restitempool.remove(item_to_place)
world.push_item(spot_to_fill, item_to_place, False)
fill_locations.remove(spot_to_fill) # very slow, unfortunately
trash_count -= 1
if spot_to_fill.item_rule(item_to_place):
if item_to_place in localrest:
localrest.remove(item_to_place)
else:
restitempool.remove(item_to_place)
world.push_item(spot_to_fill, item_to_place, False)
fill_locations.remove(spot_to_fill) # very slow, unfortunately
trash_count -= 1
def get_filler_item_name(self) -> str:
if self.world.goal[self.player] == "icerodhunt":
item = "Nothing"
else:
item = self.world.random.choice(chain(difficulties[self.world.difficulty[self.player]].extras[0:5]))
item = self.world.random.choice(extras_list)
return GetBeemizerItem(self.world, self.player, item)
def get_pre_fill_items(self):

View File

@@ -83,7 +83,7 @@ tun.
Wenn du an einem MultiWorld-Spiel teilnehmen möchtest, wirst du in der Regel vom Host nach deiner YAML-Datei gefragt.
Sobald du diese weitergegeben hast, wird der Host einen Link bereitstellen, wo du deinen Patch oder eine .zip-Datei mit
allen Patches herunterladen kannst. Die Patch-Datei hat immer die Endung `.apbp`.
allen Patches herunterladen kannst. Die Patch-Datei hat immer die Endung `.aplttp`.
### Mit dem Client verbinden

View File

@@ -2,22 +2,19 @@
## Required Software
- One of the client programs:
- [SNIClient](https://github.com/ArchipelagoMW/Archipelago/releases), included with the main
Archipelago install. Make sure to check the box for `SNI Client - A Link to the Past Patch Setup`
- [SuperNintendoClient](https://github.com/ArchipelagoMW/SuperNintendoClient/releases), an alternate standalone
client for Super Nintendo games
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). Make sure to check the box for `SNI Client - A Link to the Past Patch Setup`
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
[BizHawk](http://tasvideos.org/BizHawk.html), or
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 or newer). Or,
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware. **note:
modded SNES minis are currently not supported by SNI**
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
## Installation Procedures
1. Download and install your preferred client from the link above, making sure to install the most recent version.
1. Download and install SNIClient from the link above, making sure to install the most recent version.
**The installer file is located in the assets section at the bottom of the version information**.
- During setup, you will be asked to locate your base ROM file. This is your Japanese Link to the Past ROM file.
@@ -56,7 +53,7 @@ If you would like to validate your config file to make sure it works, you may do
2. You will be presented with a "Seed Info" page.
3. Click the "Create New Room" link.
4. You will be presented with a server page, from which you can download your patch file.
5. Double-click on your patch file, and the Z3Client will launch automatically, create your ROM from the patch file, and
5. Double-click on your patch file, and SNIClient will launch automatically, create your ROM from the patch file, and
open your emulator for you.
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
@@ -66,7 +63,7 @@ If you would like to validate your config file to make sure it works, you may do
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done,
the host will provide you with either a link to download your patch file, or with a zip file containing everyone's patch
files. Your patch file should have a `.apbp` extension.
files. Your patch file should have a `.aplttp` extension.
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically launch the
client, and will also create your ROM in the same place as your patch file.
@@ -85,9 +82,10 @@ first time launching, you may be prompted to allow it to communicate through the
3. Click on **New Lua Script Window...**
4. In the new window, click **Browse...**
5. Select the connector lua file included with your client
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
- Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit.
6. If you see an error while loading the script that states `socket.dll missing` or similar, navigate to the folder of
the lua you are using in your file explorer and copy the `socket.dll` to the base folder of your snes9x install.
##### BizHawk
@@ -99,9 +97,9 @@ first time launching, you may be prompted to allow it to communicate through the
3. Click on the Tools menu and click on **Lua Console**
4. Click Script -> Open Script...
5. Select the `Connector.lua` file you downloaded above
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit.
- Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only.
##### RetroArch 1.10.1 or newer

View File

@@ -99,7 +99,7 @@ Si quieres validar que tu fichero YAML para asegurarte que funciona correctament
Cuando te unes a una partida multiworld, debes proveer tu fichero YAML a quien sea el creador de la partida. Una vez
este hecho, el creador te devolverá un enlace para descargar el parche o un fichero zip conteniendo todos los ficheros
de parche de la partida Tu fichero de parche debe tener la extensión `.bmbp`.
de parche de la partida Tu fichero de parche debe tener la extensión `.aplttp`.
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y haz doble click. Esto debería ejecutar
automáticamente el cliente, y ademas creara la rom en el mismo directorio donde este el fichero de parche.

View File

@@ -99,7 +99,7 @@ Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous
Quand vous rejoignez un multiworld, il vous sera demandé de fournir votre fichier YAML à celui qui héberge la partie ou
s'occupe de la génération. Une fois cela fait, l'hôte vous fournira soit un lien pour télécharger votre patch, soit un
fichier `.zip` contenant les patchs de tous les joueurs. Votre patch devrait avoir l'extension `.bmbp`.
fichier `.zip` contenant les patchs de tous les joueurs. Votre patch devrait avoir l'extension `.aplttp`.
Placez votre patch sur votre bureau ou dans un dossier simple d'accès, et double-cliquez dessus. Cela devrait lancer
automatiquement le client, et devrait créer la ROM dans le même dossier que votre patch.

View File

@@ -1,4 +1,4 @@
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification, RegionType
from .Items import item_table
from .Rules import set_rules
from ..AutoWorld import World, WebWorld
@@ -7,14 +7,16 @@ from datetime import datetime
class ArchipIDLEWebWorld(WebWorld):
theme = 'partyTime'
tutorials = [Tutorial(
"Setup Guide",
"A guide to playing ArchipIDLE",
"English",
"guide_en.md",
"guide/en",
["Farrak Kilhn"]
)]
tutorials = [
Tutorial(
tutorial_name='Setup Guide',
description='A guide to playing Archipidle',
language='English',
file_name='guide_en.md',
link='guide/en',
authors=['Farrak Kilhn']
)
]
class ArchipIDLEWorld(World):
@@ -47,7 +49,7 @@ class ArchipIDLEWorld(World):
for i in range(100):
item = Item(
item_table_copy[i],
i < 20,
ItemClassification.progression if i < 20 else ItemClassification.filler,
self.item_name_to_id[item_table_copy[i]],
self.player
)
@@ -60,7 +62,7 @@ class ArchipIDLEWorld(World):
set_rules(self.world, self.player)
def create_item(self, name: str) -> Item:
return Item(name, True, self.item_name_to_id[name], self.player)
return Item(name, ItemClassification.progression, self.item_name_to_id[name], self.player)
def create_regions(self):
self.world.regions += [
@@ -75,8 +77,9 @@ class ArchipIDLEWorld(World):
def get_filler_item_name(self) -> str:
return self.world.random.choice(item_table)
def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None):
region = Region(name, None, name, player)
region = Region(name, RegionType.Generic, name, player)
region.world = world
if locations:
for location_name in locations.keys():

View File

@@ -1,16 +1,9 @@
import os
import json
from base64 import b64encode, b64decode
from math import ceil
from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification, RegionType
from .Items import ChecksFinderItem, item_table, required_items
from .Locations import ChecksFinderAdvancement, advancement_table, exclusion_table
from .Options import checksfinder_options
from .Regions import checksfinder_regions, link_checksfinder_structures
from .Rules import set_rules, set_completion_rules
from worlds.generic.Rules import exclusion_rules
from BaseClasses import Region, Entrance, Item, Tutorial
from .Options import checksfinder_options
from ..AutoWorld import World, WebWorld
client_version = 7
@@ -68,9 +61,6 @@ class ChecksFinderWorld(World):
# Convert itempool into real items
itempool = [item for item in map(lambda name: self.create_item(name), itempool)]
# Choose locations to automatically exclude based on settings
exclusion_pool = set()
self.world.itempool += itempool
def set_rules(self):
@@ -79,7 +69,7 @@ class ChecksFinderWorld(World):
def create_regions(self):
def ChecksFinderRegion(region_name: str, exits=[]):
ret = Region(region_name, None, region_name, self.player, self.world)
ret = Region(region_name, RegionType.Generic, region_name, self.player, self.world)
ret.locations = [ChecksFinderAdvancement(self.player, loc_name, loc_data.id, ret)
for loc_name, loc_data in advancement_table.items()
if loc_data.region == region_name]
@@ -100,5 +90,7 @@ class ChecksFinderWorld(World):
def create_item(self, name: str) -> Item:
item_data = item_table[name]
item = ChecksFinderItem(name, item_data.progression, item_data.code, self.player)
item = ChecksFinderItem(name,
ItemClassification.progression if item_data.progression else ItemClassification.filler,
item_data.code, self.player)
return item

View File

@@ -13,8 +13,8 @@ import Utils
import Patch
from . import Options
from .Technologies import tech_table, recipes, free_sample_blacklist, progressive_technology_table, \
base_tech_table, tech_to_progressive_lookup, liquids
from .Technologies import tech_table, recipes, free_sample_exclusions, progressive_technology_table, \
base_tech_table, tech_to_progressive_lookup, fluids
template_env: Optional[jinja2.Environment] = None
@@ -126,12 +126,12 @@ def generate_mod(world, output_directory: str):
"static_nodes": multiworld.worlds[player].static_nodes,
"recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None),
"recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None),
"free_sample_blacklist": {item: 1 for item in free_sample_blacklist},
"free_sample_blacklist": {item: 1 for item in free_sample_exclusions},
"progressive_technology_table": {tech.name: tech.progressive for tech in
progressive_technology_table.values()},
"custom_recipes": world.custom_recipes,
"max_science_pack": multiworld.max_science_pack[player].value,
"liquids": liquids,
"liquids": fluids,
"goal": multiworld.goal[player].value,
"energy_link": multiworld.energy_link[player].value
}

View File

@@ -1,25 +1,35 @@
from __future__ import annotations
# Factorio technologies are imported from a .json document in /data
from typing import Dict, Set, FrozenSet, Tuple, Union, List
from collections import Counter
import os
import json
import logging
import os
import string
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Set, FrozenSet, Tuple, Union, List, Any
import Utils
import logging
from . import Options
factorio_id = factorio_base_id = 2 ** 17
# Factorio technologies are imported from a .json document in /data
source_folder = os.path.join(os.path.dirname(__file__), "data")
with open(os.path.join(source_folder, "techs.json")) as f:
raw = json.load(f)
with open(os.path.join(source_folder, "recipes.json")) as f:
raw_recipes = json.load(f)
with open(os.path.join(source_folder, "machines.json")) as f:
raw_machines = json.load(f)
pool = ThreadPoolExecutor(1)
def load_json_data(data_name: str) -> Union[List[str], Dict[str, Any]]:
with open(os.path.join(source_folder, f"{data_name}.json")) as f:
return json.load(f)
techs_future = pool.submit(load_json_data, "techs")
recipes_future = pool.submit(load_json_data, "recipes")
resources_future = pool.submit(load_json_data, "resources")
machines_future = pool.submit(load_json_data, "machines")
fluids_future = pool.submit(load_json_data, "fluids")
items_future = pool.submit(load_json_data, "items")
tech_table: Dict[str, int] = {}
technology_table: Dict[str, Technology] = {}
@@ -145,8 +155,11 @@ class Recipe(FactorioElement):
for ingredient, cost in self.ingredients.items():
if ingredient in all_product_sources:
for recipe in all_product_sources[ingredient]:
ingredients.update({name: amount * cost / recipe.products[ingredient] for name, amount in
recipe.base_cost.items()})
if recipe.ingredients:
ingredients.update({name: amount * cost / recipe.products[ingredient] for name, amount in
recipe.base_cost.items()})
else:
ingredients[ingredient] += recipe.energy * cost / recipe.products[ingredient]
else:
ingredients[ingredient] += cost
return ingredients
@@ -177,8 +190,7 @@ class Machine(FactorioElement):
recipe_sources: Dict[str, Set[str]] = {} # recipe_name -> technology source
# recipes and technologies can share names in Factorio
for technology_name in sorted(raw):
data = raw[technology_name]
for technology_name, data in sorted(techs_future.result().items()):
current_ingredients = set(data["ingredients"])
technology = Technology(technology_name, current_ingredients, factorio_id,
has_modifier=data["has_modifier"], unlocks=set(data["unlocks"]))
@@ -188,28 +200,22 @@ for technology_name in sorted(raw):
for recipe_name in technology.unlocks:
recipe_sources.setdefault(recipe_name, set()).add(technology_name)
del (raw)
del techs_future
recipes = {}
all_product_sources: Dict[str, Set[Recipe]] = {"character": set()}
# add uranium mining to logic graph. TODO: add to automatic extractor for mod support
raw_recipes["uranium-ore"] = {
"ingredients": {"sulfuric-acid": 1},
"products": {"uranium-ore": 1},
"category": "mining",
"energy": 2
}
raw_recipes["crude-oil"] = {
"ingredients": {},
"products": {"crude-oil": 1},
"category": "basic-fluid",
"energy": 1
}
# raw_recipes["iron-ore"] = {"ingredients": {}, "products": {"iron-ore": 1}, "category": "mining", "energy": 2}
# raw_recipes["copper-ore"] = {"ingredients": {}, "products": {"copper-ore": 1}, "category": "mining", "energy": 2}
# raw_recipes["coal-ore"] = {"ingredients": {}, "products": {"coal": 1}, "category": "mining", "energy": 2}
# raw_recipes["stone"] = {"ingredients": {}, "products": {"coal": 1}, "category": "mining", "energy": 2}
raw_recipes = recipes_future.result()
del recipes_future
for resource_name, resource_data in resources_future.result().items():
raw_recipes[f"mining-{resource_name}"] = {
"ingredients": {resource_data["required_fluid"]: resource_data["fluid_amount"]}
if "required_fluid" in resource_data else {},
"products": {data["name"]: data["amount"] for data in resource_data["products"].values()},
"energy": resource_data["mining_time"],
"category": resource_data["category"]
}
del resources_future
for recipe_name, recipe_data in raw_recipes.items():
# example:
@@ -225,20 +231,20 @@ for recipe_name, recipe_data in raw_recipes.items():
for product_name in recipe.products:
all_product_sources.setdefault(product_name, set()).add(recipe)
del (raw_recipes)
machines: Dict[str, Machine] = {}
for name, categories in raw_machines.items():
for name, categories in machines_future.result().items():
machine = Machine(name, set(categories))
machines[name] = machine
# add electric mining drill as a crafting machine to resolve uranium-ore
machines["electric-mining-drill"] = Machine("electric-mining-drill", {"mining"})
# add electric mining drill as a crafting machine to resolve basic-solid (mining)
machines["electric-mining-drill"] = Machine("electric-mining-drill", {"basic-solid"})
machines["pumpjack"] = Machine("pumpjack", {"basic-fluid"})
machines["assembling-machine-1"].categories.add("crafting-with-fluid") # mod enables this
machines["character"].categories.add("basic-crafting") # somehow this is implied and not exported
del (raw_machines)
del machines_future
# build requirements graph for all technology ingredients
@@ -300,7 +306,7 @@ machine_per_category: Dict[str: str] = {}
for category, (cost, machine_name) in machine_tech_cost.items():
machine_per_category[category] = machine_name
del (machine_tech_cost)
del machine_tech_cost
# required technologies to be able to craft recipes from a certain category
required_category_technologies: Dict[str, FrozenSet[FrozenSet[Technology]]] = {}
@@ -327,24 +333,7 @@ def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe, satellite_
return {tech.name for tech in techs}
free_sample_blacklist: Set[str] = all_ingredient_names | {"rocket-part"}
rocket_recipes = {
Options.MaxSciencePack.option_space_science_pack:
{"rocket-control-unit": 10, "low-density-structure": 10, "rocket-fuel": 10},
Options.MaxSciencePack.option_utility_science_pack:
{"speed-module": 10, "steel-plate": 10, "solid-fuel": 10},
Options.MaxSciencePack.option_production_science_pack:
{"speed-module": 10, "steel-plate": 10, "solid-fuel": 10},
Options.MaxSciencePack.option_chemical_science_pack:
{"advanced-circuit": 10, "steel-plate": 10, "solid-fuel": 10},
Options.MaxSciencePack.option_military_science_pack:
{"defender-capsule": 10, "stone-wall": 10, "coal": 10},
Options.MaxSciencePack.option_logistic_science_pack:
{"electronic-circuit": 10, "stone-brick": 10, "coal": 10},
Options.MaxSciencePack.option_automation_science_pack:
{"copper-cable": 10, "iron-plate": 10, "wood": 10}
}
free_sample_exclusions: Set[str] = all_ingredient_names | {"rocket-part"}
# progressive technologies
# auto-progressive
@@ -471,8 +460,9 @@ rel_cost = {
"used-up-uranium-fuel-cell": 1000
}
blacklist: Set[str] = all_ingredient_names | {"rocket-part"}
liquids: Set[str] = {"crude-oil", "water", "sulfuric-acid", "petroleum-gas", "light-oil", "heavy-oil", "lubricant", "steam"}
exclusion_list: Set[str] = all_ingredient_names | {"rocket-part", "used-up-uranium-fuel-cell"}
fluids: Set[str] = set(fluids_future.result())
del fluids_future
@Utils.cache_argsless
@@ -486,7 +476,7 @@ def get_science_pack_pools() -> Dict[str, Set[str]]:
return cost
science_pack_pools: Dict[str, Set[str]] = {}
already_taken = blacklist.copy()
already_taken = exclusion_list.copy()
current_difficulty = 5
for science_pack in Options.MaxSciencePack.get_ordered_science_packs():
current = science_pack_pools[science_pack] = set()
@@ -494,13 +484,24 @@ def get_science_pack_pools() -> Dict[str, Set[str]]:
if (science_pack != "automation-science-pack" or not recipe.recursive_unlocking_technologies) \
and get_estimated_difficulty(recipe) < current_difficulty:
current |= set(recipe.products)
if science_pack == "automation-science-pack":
current |= {"iron-ore", "copper-ore", "coal", "stone"}
# Can't hand craft automation science if liquids end up in its recipe, making the seed impossible.
current -= liquids
# Can't handcraft automation science if fluids end up in its recipe, making the seed impossible.
current -= fluids
elif science_pack == "logistic-science-pack":
current |= {"steam"}
current -= already_taken
already_taken |= current
current_difficulty *= 2
return science_pack_pools
item_stack_sizes: Dict[str, int] = items_future.result()
non_stacking_items: Set[str] = {item for item, stack in item_stack_sizes.items() if stack == 1}
stacking_items: Set[str] = set(item_stack_sizes) - non_stacking_items
# cleanup async helpers
pool.shutdown()
del pool

View File

@@ -3,12 +3,12 @@ import typing
from ..AutoWorld import World, WebWorld
from BaseClasses import Region, Entrance, Location, Item, RegionType, Tutorial
from BaseClasses import Region, Entrance, Location, Item, RegionType, Tutorial, ItemClassification
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, rocket_recipes, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, \
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
liquids
fluids, stacking_items
from .Shapes import get_shapes
from .Mod import generate_mod
from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal
@@ -52,7 +52,7 @@ class Factorio(World):
item_name_to_id = all_items
location_name_to_id = base_tech_table
item_name_groups = {
"Progressive": set(progressive_tech_table.values()),
"Progressive": set(progressive_tech_table.keys()),
}
data_version = 5
required_client_version = (0, 3, 0)
@@ -115,7 +115,7 @@ class Factorio(World):
location = Location(player, "Rocket Launch", None, nauvis)
nauvis.locations.append(location)
location.game = "Factorio"
event = Item("Victory", True, None, player)
event = FactorioItem("Victory", ItemClassification.progression, None, player)
event.game = "Factorio"
self.world.push_item(location, event, False)
location.event = location.locked = True
@@ -123,7 +123,7 @@ class Factorio(World):
location = Location(player, f"Automate {ingredient}", None, nauvis)
location.game = "Factorio"
nauvis.locations.append(location)
event = Item(f"Automated {ingredient}", True, None, player)
event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player)
self.world.push_item(location, event, False)
location.event = location.locked = True
crash.connect(nauvis)
@@ -215,8 +215,8 @@ class Factorio(World):
liquids_used = 0
for _ in original.ingredients:
new_ingredient = pool.pop()
if new_ingredient in liquids:
while liquids_used == allow_liquids and new_ingredient in liquids:
if new_ingredient in fluids:
while liquids_used == allow_liquids and new_ingredient in fluids:
# liquids already at max for current recipe.
# Return the liquid to the pool and get a new ingredient.
pool.append(new_ingredient)
@@ -226,11 +226,14 @@ class Factorio(World):
return Recipe(original.name, self.get_category(original.category, liquids_used), new_ingredients,
original.products, original.energy)
def make_balanced_recipe(self, original: Recipe, pool: list, factor: float = 1, allow_liquids: int = 2) -> \
Recipe:
def make_balanced_recipe(self, original: Recipe, pool: typing.Set[str], factor: float = 1,
allow_liquids: int = 2) -> Recipe:
"""Generate a recipe from pool with time and cost similar to original * factor"""
new_ingredients = {}
pool = sorted(pool, key=lambda x: self.world.random.random())
# have to first sort for determinism, while filtering out non-stacking items
pool: typing.List[str] = sorted(pool & stacking_items)
# then sort with random data to shuffle
self.world.random.shuffle(pool)
target_raw = int(sum((count for ingredient, count in original.base_cost.items())) * factor)
target_energy = original.total_energy * factor
target_num_ingredients = len(original.ingredients)
@@ -243,7 +246,7 @@ class Factorio(World):
# fill all but one slot with random ingredients, last with a good match
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if liquids_used == allow_liquids and ingredient in liquids:
if liquids_used == allow_liquids and ingredient in fluids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
ingredient_raw = 0
if ingredient in all_product_sources:
@@ -279,14 +282,14 @@ class Factorio(World):
remaining_raw -= num * ingredient_raw
remaining_energy -= num * ingredient_energy
remaining_num_ingredients -= 1
if ingredient in liquids:
if ingredient in fluids:
liquids_used += 1
# fill failed slots with whatever we got
pool = fallback_pool
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if liquids_used == allow_liquids and ingredient in liquids:
if liquids_used == allow_liquids and ingredient in fluids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
ingredient_recipe = recipes.get(ingredient, None)
@@ -307,7 +310,7 @@ class Factorio(World):
remaining_raw -= num * ingredient_raw
remaining_energy -= num * ingredient_energy
remaining_num_ingredients -= 1
if ingredient in liquids:
if ingredient in fluids:
liquids_used += 1
if remaining_num_ingredients > 1:
@@ -328,7 +331,7 @@ class Factorio(World):
science_pack_pools = get_science_pack_pools()
valid_pool = sorted(science_pack_pools[self.world.max_science_pack[self.player].get_max_pack()])
self.world.random.shuffle(valid_pool)
while any([valid_pool[x] in liquids for x in range(3)]):
while any([valid_pool[x] in fluids for x in range(3)]):
self.world.random.shuffle(valid_pool)
self.custom_recipes = {"rocket-part": Recipe("rocket-part", original_rocket_part.category,
{valid_pool[x]: 10 for x in range(3)},
@@ -346,9 +349,9 @@ class Factorio(World):
if self.world.silo[self.player].value == Silo.option_randomize_recipe \
or self.world.satellite[self.player].value == Satellite.option_randomize_recipe:
valid_pool = []
valid_pool = set()
for pack in sorted(self.world.max_science_pack[self.player].get_allowed_packs()):
valid_pool += sorted(science_pack_pools[pack])
valid_pool |= science_pack_pools[pack]
if self.world.silo[self.player].value == Silo.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool,
@@ -385,12 +388,17 @@ class Factorio(World):
prog_add.add(tech_to_progressive_lookup[tech])
self.advancement_technologies |= prog_add
def create_item(self, name: str) -> Item:
if name in tech_table:
return FactorioItem(name, name in self.advancement_technologies,
def create_item(self, name: str) -> FactorioItem:
if name in tech_table: # is a Technology
if name in self.advancement_technologies:
classification = ItemClassification.progression
else:
classification = ItemClassification.filler
return FactorioItem(name,
classification,
tech_table[name], self.player)
item = FactorioItem(name, False, all_items[name], self.player)
if "Trap" in name:
item.trap = True
item = FactorioItem(name,
ItemClassification.trap if "Trap" in name else ItemClassification.filler,
all_items[name], self.player)
return item

View File

@@ -0,0 +1 @@
["fluid-unknown","water","crude-oil","steam","heavy-oil","light-oil","petroleum-gas","sulfuric-acid","lubricant"]

View File

@@ -0,0 +1 @@
{"wooden-chest":50,"iron-chest":50,"steel-chest":50,"storage-tank":50,"transport-belt":100,"fast-transport-belt":100,"express-transport-belt":100,"underground-belt":50,"fast-underground-belt":50,"express-underground-belt":50,"splitter":50,"fast-splitter":50,"express-splitter":50,"loader":50,"fast-loader":50,"express-loader":50,"burner-inserter":50,"inserter":50,"long-handed-inserter":50,"fast-inserter":50,"filter-inserter":50,"stack-inserter":50,"stack-filter-inserter":50,"small-electric-pole":50,"medium-electric-pole":50,"big-electric-pole":50,"substation":50,"pipe":100,"pipe-to-ground":50,"pump":50,"rail":100,"train-stop":10,"rail-signal":50,"rail-chain-signal":50,"locomotive":5,"cargo-wagon":5,"fluid-wagon":5,"artillery-wagon":5,"car":1,"tank":1,"spidertron":1,"spidertron-remote":1,"logistic-robot":50,"construction-robot":50,"logistic-chest-active-provider":50,"logistic-chest-passive-provider":50,"logistic-chest-storage":50,"logistic-chest-buffer":50,"logistic-chest-requester":50,"roboport":10,"small-lamp":50,"red-wire":200,"green-wire":200,"arithmetic-combinator":50,"decider-combinator":50,"constant-combinator":50,"power-switch":50,"programmable-speaker":50,"stone-brick":100,"concrete":100,"hazard-concrete":100,"refined-concrete":100,"refined-hazard-concrete":100,"landfill":100,"cliff-explosives":20,"dummy-steel-axe":1,"repair-pack":100,"blueprint":1,"deconstruction-planner":1,"upgrade-planner":1,"blueprint-book":1,"copy-paste-tool":1,"cut-paste-tool":1,"boiler":50,"steam-engine":10,"solar-panel":50,"accumulator":50,"nuclear-reactor":10,"heat-pipe":50,"heat-exchanger":50,"steam-turbine":10,"burner-mining-drill":50,"electric-mining-drill":50,"offshore-pump":20,"pumpjack":20,"stone-furnace":50,"steel-furnace":50,"electric-furnace":50,"assembling-machine-1":50,"assembling-machine-2":50,"assembling-machine-3":50,"oil-refinery":10,"chemical-plant":10,"centrifuge":50,"lab":10,"beacon":10,"speed-module":50,"speed-module-2":50,"speed-module-3":50,"effectivity-module":50,"effectivity-module-2":50,"effectivity-module-3":50,"productivity-module":50,"productivity-module-2":50,"productivity-module-3":50,"rocket-silo":1,"satellite":1,"wood":100,"coal":50,"stone":50,"iron-ore":50,"copper-ore":50,"uranium-ore":50,"raw-fish":100,"iron-plate":100,"copper-plate":100,"solid-fuel":50,"steel-plate":100,"plastic-bar":100,"sulfur":50,"battery":200,"explosives":50,"crude-oil-barrel":10,"heavy-oil-barrel":10,"light-oil-barrel":10,"lubricant-barrel":10,"petroleum-gas-barrel":10,"sulfuric-acid-barrel":10,"water-barrel":10,"copper-cable":200,"iron-stick":100,"iron-gear-wheel":100,"empty-barrel":10,"electronic-circuit":200,"advanced-circuit":200,"processing-unit":100,"engine-unit":50,"electric-engine-unit":50,"flying-robot-frame":50,"rocket-control-unit":10,"low-density-structure":10,"rocket-fuel":10,"rocket-part":5,"nuclear-fuel":1,"uranium-235":100,"uranium-238":100,"uranium-fuel-cell":50,"used-up-uranium-fuel-cell":50,"automation-science-pack":200,"logistic-science-pack":200,"military-science-pack":200,"chemical-science-pack":200,"production-science-pack":200,"utility-science-pack":200,"space-science-pack":2000,"coin":100000,"pistol":5,"submachine-gun":5,"tank-machine-gun":1,"vehicle-machine-gun":1,"tank-flamethrower":1,"shotgun":5,"combat-shotgun":5,"rocket-launcher":5,"flamethrower":5,"land-mine":100,"artillery-wagon-cannon":1,"spidertron-rocket-launcher-1":1,"spidertron-rocket-launcher-2":1,"spidertron-rocket-launcher-3":1,"spidertron-rocket-launcher-4":1,"tank-cannon":1,"firearm-magazine":200,"piercing-rounds-magazine":200,"uranium-rounds-magazine":200,"shotgun-shell":200,"piercing-shotgun-shell":200,"cannon-shell":200,"explosive-cannon-shell":200,"uranium-cannon-shell":200,"explosive-uranium-cannon-shell":200,"artillery-shell":1,"rocket":200,"explosive-rocket":200,"atomic-bomb":10,"flamethrower-ammo":100,"grenade":100,"cluster-grenade":100,"poison-capsule":100,"slowdown-capsule":100,"defender-capsule":100,"distractor-capsule":100,"destroyer-capsule":100,"light-armor":1,"heavy-armor":1,"modular-armor":1,"power-armor":1,"power-armor-mk2":1,"solar-panel-equipment":20,"fusion-reactor-equipment":20,"battery-equipment":20,"battery-mk2-equipment":20,"belt-immunity-equipment":20,"exoskeleton-equipment":20,"personal-roboport-equipment":20,"personal-roboport-mk2-equipment":20,"night-vision-equipment":20,"energy-shield-equipment":20,"energy-shield-mk2-equipment":20,"personal-laser-defense-equipment":20,"discharge-defense-equipment":20,"discharge-defense-remote":1,"stone-wall":100,"gate":50,"gun-turret":50,"laser-turret":50,"flamethrower-turret":50,"artillery-turret":10,"artillery-targeting-remote":1,"radar":50,"player-port":50,"item-unknown":1,"electric-energy-interface":50,"linked-chest":10,"heat-interface":20,"linked-belt":10,"infinity-chest":10,"infinity-pipe":10,"selection-tool":1,"item-with-inventory":1,"item-with-label":1,"item-with-tags":1,"simple-entity-with-force":50,"simple-entity-with-owner":50,"burner-generator":10}

View File

@@ -178,13 +178,13 @@ data:extend{new_tree_copy}
{% endfor %}
{% if recipe_time_scale %}
{%- for recipe_name, recipe in recipes.items() %}
{%- if recipe.category not in ("mining", "basic-fluid") %}
{%- if recipe.category not in ("basic-solid", "basic-fluid") %}
adjust_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_scale) }})
{%- endif %}
{%- endfor -%}
{% elif recipe_time_range %}
{%- for recipe_name, recipe in recipes.items() %}
{%- if recipe.category not in ("mining", "basic-fluid") %}
{%- if recipe.category not in ("basic-solid", "basic-fluid") %}
set_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_range) }})
{%- endif %}
{%- endfor -%}

View File

@@ -0,0 +1 @@
{"iron-ore":{"minable":true,"infinite":false,"category":"basic-solid","mining_time":1,"products":{"iron-ore":{"name":"iron-ore","amount":1}}},"copper-ore":{"minable":true,"infinite":false,"category":"basic-solid","mining_time":1,"products":{"copper-ore":{"name":"copper-ore","amount":1}}},"stone":{"minable":true,"infinite":false,"category":"basic-solid","mining_time":1,"products":{"stone":{"name":"stone","amount":1}}},"coal":{"minable":true,"infinite":false,"category":"basic-solid","mining_time":1,"products":{"coal":{"name":"coal","amount":1}}},"uranium-ore":{"minable":true,"infinite":false,"category":"basic-solid","mining_time":2,"required_fluid":"sulfuric-acid","fluid_amount":10,"products":{"uranium-ore":{"name":"uranium-ore","amount":1}}},"crude-oil":{"minable":true,"infinite":true,"infinite_depletion":10,"category":"basic-fluid","mining_time":1,"products":{"crude-oil":{"name":"crude-oil","amount":10}}}}

View File

@@ -88,8 +88,9 @@ Factorio product code. This will allow you to download the game directly from th
It is recommended to download the standalone version of Factorio for use as a dedicated server. Doing so prevents any
potential conflicts with your currently-installed version of Factorio. Download the file by clicking on the button
appropriate to your operating system, and extract the folder to a convenient location (we recommend `C:\Factorio` or
similar).
appropriate to your operating system, and extract the folder to a convenient location. The best place to do this for
Archipelago is to place the extracted game folder into the `Archipelago` directory and rename it to just be "Factorio".
![Factorio Download Options](/static/generated/docs/Factorio/factorio-download.png)
@@ -99,12 +100,13 @@ have logged in, you may close the game.
#### Configure your Archipelago Installation
You must modify your `host.yaml` file inside your Archipelago installation directory so that it points to your
standalone Factorio executable. Here is an example of the appropriate setup, note the double `\\` are required:
If you did not place the Factorio standalone in your Archipelago installation, you must modify your `host.yaml` file
inside your Archipelago installation directory so that it points to your standalone Factorio executable. Here is an
example of the appropriate setup, note the double `\\` are required:
```yaml
factorio_options:
executable: C:\\factorio\\bin\\x64\\factorio"
executable: C:\\path\\to\\factorio\\bin\\x64\\factorio"
```
This allows you to host your own Factorio game.
@@ -145,6 +147,13 @@ In case any problems should occur, the Archipelago Client will create a file `Fa
contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other people
in Archipelago.
## Commands in game
Once you have connected to the server successfully using the Archipelago Factorio Client you should see a message
stating you can get help using Archipelago commands by typing `!help`. Commands cannot currently be sent from within
the Factorio session, but you can send them from the Archipelago Factorio Client. For more information about the commands
you can use see the [commands guide](/tutorial/Archipelago/commands/en).
## Additional Resources
- Alternate Tutorial by

View File

@@ -2,15 +2,14 @@ import json
from pathlib import Path
from typing import Dict, Set, NamedTuple, List
from BaseClasses import Item
from BaseClasses import Item, ItemClassification
class ItemData(NamedTuple):
name: str
code: int
item_type: str
progression: bool
classification: ItemClassification
FF1_BRIDGE = 'Bridge'
@@ -27,6 +26,11 @@ FF1_PROGRESSION_LIST = [
"EarthOrb", "FireOrb", "WaterOrb", "AirOrb"
]
FF1_USEFUL_LIST = [
"Tail", "Masamune", "Xcalber", "Katana", "Vorpal",
"DragonArmor", "Opal", "AegisShield", "Ribbon"
]
class FF1Items:
_item_table: List[ItemData] = []
@@ -38,8 +42,9 @@ class FF1Items:
with open(file_path) as file:
items = json.load(file)
# Hardcode progression and categories for now
self._item_table = [ItemData(name, code, "FF1Item", name in FF1_PROGRESSION_LIST)
for name, code in items.items()]
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
ItemClassification.filler) for name, code in items.items()]
self._item_table_lookup = {item.name: item for item in self._item_table}
def _get_item_table(self) -> List[ItemData]:
@@ -62,7 +67,8 @@ class FF1Items:
def generate_item(self, name: str, player: int) -> Item:
item = self._get_item_table_lookup().get(name)
return Item(name, item.progression, item.code, player)
return Item(name, item.classification,
item.code, player)
def get_item_name_to_code_dict(self) -> Dict[str, int]:
return {name: item.code for name, item in self._get_item_table_lookup().items()}

View File

@@ -45,7 +45,7 @@ def exclusion_rules(world, player: int, exclude_locations: typing.Set[str]):
if loc_name not in world.worlds[player].location_name_to_id:
raise Exception(f"Unable to exclude location {loc_name} in player {player}'s world.") from e
else:
add_item_rule(location, lambda i: not (i.advancement or i.never_exclude))
add_item_rule(location, lambda i: not (i.advancement or i.useful))
location.progress_type = LocationProgressType.EXCLUDED

View File

@@ -1,7 +1,7 @@
from typing import NamedTuple, Union
import logging
from BaseClasses import Item, Tutorial
from BaseClasses import Item, Tutorial, ItemClassification
from ..AutoWorld import World, WebWorld
from NetUtils import SlotType
@@ -46,7 +46,7 @@ class GenericWorld(World):
def create_item(self, name: str) -> Item:
if name == "Nothing":
return Item(name, False, -1, self.player)
return Item(name, ItemClassification.filler, -1, self.player)
raise KeyError(name)

View File

@@ -125,9 +125,7 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en)
* `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk"
item which isn't necessary for progression to go in these locations.
* `item_links` allows you to link up items so that when one players finds the item all other participating players also
get it.
* `item_links` allows players to link their items into a group with the same item link name and game. The items declared in `item_pool` get combined and when an item is found for the group, all players in the group receive it. Item links can also have local and non local items, forcing the items to either be placed within the worlds of the group or in worlds outside the group. If players have a varying amount of a specific item in the link, the lowest amount from the players will be the amount put into the group.
### Random numbers
Options taking a choice of a number can also use a variety of `random` options to choose a number randomly.
@@ -148,9 +146,11 @@ Options taking a choice of a number can also use a variety of `random` options t
description: An example using various advanced options
name: Example Player
game: A Link to the Past
game:
A Link to the Past: 10
Timespinner: 10
requires:
version: 0.2.0
version: 0.3.2
accessibility: none
progression_balancing: on
A Link to the Past:
@@ -191,14 +191,24 @@ triggers:
bigkey_shuffle: any_world
map_shuffle: any_world
compass_shuffle: any_world
Timespinner:
item_links: # Share part of your item pool with other players.
- name: TSAll
item_pool:
- Everything
local_items:
- Twin Pyramid Key
- Timespinner Wheel
replacement_item: null
```
#### This is a fully functional yaml file that will do all the following things:
* `description` gives us a general overview so if we pull up this file later we can understand the intent.
* `name` is `Example Player` and this will be used in the server console when sending and receiving items.
* `game` is set to `A Link to the Past` meaning that is what game we will play with this file.
* `requires` is set to require release version 0.2.0 or higher.
* `game` has an equal chance of being either `A Link to the Past` or `Timespinner` with a 10/20 chance for each. The reason for this is becuase each game has a weight of 10 and the toal of all weights is 20.
* `requires` is set to required release version 0.3.2 or higher.
* `accesibility` is set to `none` which will set this seed to beatable only meaning some locations and items may be
completely inaccessible but the seed will still be completable.
* `progression_balancing` is set on, giving it the default value, meaning we will likely receive important items
@@ -225,10 +235,87 @@ triggers:
* `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the
multiworld that can be used for no cost.
* `exclude_locations` forces a not important item to be placed on the `Cave 45` location.
* `item_links` causes all players with the same `item_links` settings to share a `Fire Rod` and `Ice Rod`. Extra
`Rupee (1)` are put in the item pool instead of additional Rods.
* `item_links`
* For `A Link to the Past` all players in the `rods` item link group will share their fire and ice rods and the player
items will be replaced with single rupees.
* For `Timespinner` all players in the `TSAll` item link group will share their entire item pool and the`Twin Pyramid
* For `A Link to the Past` all players in the `rods` item link group will share their fire and ice rods and the player
items will be replaced with single rupees.
* For `Timespinner` all players in the `TSAll` item link group will share their entire item pool and the `Twin Pyramid
Key` and `Timespinner Wheel` will be forced among the worlds of those in the group. The `null` replacement item will, instead
of forcing a specific chosen item, allow the generator to randomly pick a filler item in place of putting in another one of the linked item.
* `triggers` allows us to define a trigger such that if our `smallkey_shuffle` option happens to roll the `any_world`
result it will also ensure that `bigkey_shuffle`, `map_shuffle`, and `compass_shuffle` are also forced to
the `any_world`
result.
### Generating Multiple Worlds
YAML files can be configured to generate multiple worlds using only one file. This is mostly useful if you are playing an asynchronous multiworld (shortened to async) and are wanting to submit multiple worlds as they can be condensed into one file, removing the need to manage separate files if one chooses to do so.
As a precautionary measure, before submitting a multi-game yaml like this one in a synchronous/sync multiworld, please confirm that the other players in the multi are OK with what you are submitting, and please be fairly reasonable about the submission. (ie. Multiple long games (SMZ3, OoT, HK, etc.) for a game intended to be <2 hrs is not likely considered reasonable, but submitting a ChecksFinder alongside another game OR submitting multiple Slay the Spire runs is likely OK)
To configure your file to generate multiple worlds, use 3 dashes `---` on an empty line to separate the ending of one world and the beginning of another world.
#### Example
```yaml
description: Example of generating multiple worlds. World 1 of 3
name: Mario
game: Super Mario 64
requires:
version: 0.3.2
Super Mario 64:
progression_balancing: 50
accessibilty: items
EnableCoinStars: false
StrictCapRequirements: true
StrictCannonRequirements: true
StarsToFinish: 70
ExtraStars: 30
DeathLink: true
BuddyChecks: true
AreaRandomizer: true
ProgressiveKeys:
true: 1
false: 1
---
description: Example of generating multiple worlds. World 2 of 3
name: Minecraft
game: Minecraft
Minecraft:
progression_balancing: 50
accessibilty: items
advancement_goal: 40
combat_difficulty: hard
include_hard_advancements: false
include_unreasonable_advancements: false
include_postgame_advancements: false
shuffle_structures: true
structure_compasses: true
send_defeated_mobs: true
bee_traps: 15
egg_shards_required: 7
egg_shards_available: 10
required_bosses:
none: 0
ender_dragon: 1
wither: 0
both: 0
---
description: Example of generating multiple worlds. World 3 of 3
name: ExampleFinder
game: ChecksFinder
ChecksFinder:
progression_balancing: 50
accessibilty: items
```
The above example will generate 3 worlds - one Super Mario 64, one Minecraft, and one ChecksFinder.

View File

@@ -28,7 +28,7 @@ def put_digits_at_end(text: str) -> str:
def hk_loads(file: str) -> typing.Any:
with open(file) as f:
with open(file, encoding="utf-8-sig") as f:
data = f.read()
new_data = []
for row in data.split("\n"):

View File

@@ -22,3 +22,15 @@ for item, item_data in item_table.items():
item_name_groups = {group: lookup_type_to_names[group] for group in ("Skill", "Charm", "Mask", "Vessel",
"Relic", "Root", "Map", "Stag", "Cocoon",
"Soul", "DreamWarrior", "DreamBoss")}
directionals = ('', 'Left_', 'Right_')
item_name_groups.update({
"Dreamer": {"Herrah", "Monomon", "Lurien"},
"Cloak": {x + 'Mothwing_Cloak' for x in directionals} | {'Shade_Cloak', 'Split_Shade_Cloak'},
"Claw": {x + 'Mantis_Claw' for x in directionals},
"CDash": {x + 'Crystal_Heart' for x in directionals},
"Fragments": {"Queen_Fragment", "King_Fragment", "Void_Heart"},
})
item_name_groups['Horizontal'] = item_name_groups['Cloak'] | item_name_groups['CDash']
item_name_groups['Vertical'] = item_name_groups['Claw'] | {'Monarch_Wings'}

View File

@@ -1,9 +1,15 @@
import typing
from .ExtractedData import logic_options, starts, pool_options
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, SpecialRange
from .Charms import vanilla_costs, names as charm_names
if typing.TYPE_CHECKING:
# avoid import during runtime
from random import Random
else:
Random = typing.Any
class Disabled(Toggle):
def __init__(self, value: int):
@@ -202,20 +208,32 @@ class MaximumCharmPrice(MinimumCharmPrice):
default = 20
class RandomCharmCosts(Range):
"""Total Notch Cost of all Charms together. Set to -1 for vanilla costs. Vanilla sums to 90.
This value is distributed among all charms in a random fashion."""
class RandomCharmCosts(SpecialRange):
"""Total Notch Cost of all Charms together. Vanilla sums to 90.
This value is distributed among all charms in a random fashion.
Special Cases:
Set to -1 or vanilla for vanilla costs.
Set to -2 or shuffle to shuffle around the vanilla costs to different charms."""
display_name = "Randomize Charm Notch Costs"
range_start = -1
range_start = -2
range_end = 240
default = -1
vanilla_costs: typing.List[int] = vanilla_costs
charm_count: int = len(vanilla_costs)
special_range_names = {
"vanilla": -1,
"shuffle": -2
}
def get_costs(self, random_source) -> typing.List[int]:
def get_costs(self, random_source: Random) -> typing.List[int]:
charms: typing.List[int]
if -1 == self.value:
return self.vanilla_costs
return self.vanilla_costs.copy()
elif -2 == self.value:
charms = self.vanilla_costs.copy()
random_source.shuffle(charms)
return charms
else:
charms = [0]*self.charm_count
for x in range(self.value):
@@ -245,6 +263,39 @@ class EggShopSlots(Range):
range_end = 16
class Goal(Choice):
"""The goal required of you in order to complete your run in Archipelago."""
display_name = "Goal"
option_any = 0
option_hollowknight = 1
option_siblings = 2
option_radiance = 3
# Client support exists for this, but logic is a nightmare
# option_godhome = 4
default = 0
class WhitePalace(Choice):
"""
Whether or not to include White Palace or not. Note: Even if excluded, the King Fragment check may still be
required if charms are vanilla.
"""
display_name = "White Palace"
option_exclude = 0 # No White Palace at all
option_kingfragment = 1 # Include King Fragment check only
option_nopathofpain = 2 # Exclude Path of Pain locations.
option_include = 3 # Include all White Palace locations, including Path of Pain.
default = 0
class StartingGeo(Range):
"""The amount of starting geo you have."""
display_name = "Starting Geo"
range_start = 0
range_end = 1000
default = 0
hollow_knight_options: typing.Dict[str, type(Option)] = {
**hollow_knight_randomize_options,
**hollow_knight_logic_options,
@@ -260,4 +311,7 @@ hollow_knight_options: typing.Dict[str, type(Option)] = {
MinimumEggPrice.__name__: MinimumEggPrice,
MaximumEggPrice.__name__: MaximumEggPrice,
EggShopSlots.__name__: EggShopSlots,
Goal.__name__: Goal,
WhitePalace.__name__: WhitePalace,
StartingGeo.__name__: StartingGeo,
}

View File

@@ -27,7 +27,7 @@ def set_shop_prices(hk_world):
for shop, unit in hk_world.shops.items():
for i in range(1, 1 + hk_world.created_multi_locations[shop]):
loc = hk_world.world.get_location(f"{shop}_{i}", hk_world.player)
add_rule(loc, lambda state, unit=units[unit], cost=loc.cost: state.count(unit, player) > cost)
add_rule(loc, lambda state, unit=units[unit], cost=loc.cost: state.count(unit, player) >= cost)
def set_rules(hk_world):

View File

@@ -9,77 +9,80 @@ logger = logging.getLogger("Hollow Knight")
from .Items import item_table, lookup_type_to_names, item_name_groups
from .Regions import create_regions
from .Rules import set_rules
from .Options import hollow_knight_options, hollow_knight_randomize_options, disabled
from .Options import hollow_knight_options, hollow_knight_randomize_options, disabled, Goal, WhitePalace
from .ExtractedData import locations, starts, multi_locations, location_to_region_lookup, \
event_names, item_effects, connectors, one_ways
from .Charms import names as charm_names
from BaseClasses import Region, Entrance, Location, MultiWorld, Item, RegionType, Tutorial
from BaseClasses import Region, Entrance, Location, MultiWorld, Item, RegionType, LocationProgressType, Tutorial, ItemClassification
from ..AutoWorld import World, LogicMixin, WebWorld
white_palace_locations = {
path_of_pain_locations = {
"Soul_Totem-Path_of_Pain_Below_Thornskip",
"Soul_Totem-White_Palace_Final",
"Lore_Tablet-Path_of_Pain_Entrance",
"Soul_Totem-Path_of_Pain_Left_of_Lever",
"Soul_Totem-Path_of_Pain_Hidden",
"Soul_Totem-Path_of_Pain_Entrance",
"Soul_Totem-Path_of_Pain_Final",
"Soul_Totem-White_Palace_Entrance",
"Soul_Totem-Path_of_Pain_Below_Lever",
"Lore_Tablet-Palace_Throne",
"Soul_Totem-Path_of_Pain_Second",
"Journal_Entry-Seal_of_Binding",
"Warp-Path_of_Pain_Complete",
"Defeated_Path_of_Pain_Arena",
"Completed_Path_of_Pain",
# Path of Pain transitions
"White_Palace_17[right1]", "White_Palace_17[bot1]",
"White_Palace_18[top1]", "White_Palace_18[right1]",
"White_Palace_19[left1]", "White_Palace_19[top1]",
"White_Palace_20[bot1]",
}
white_palace_transitions = {
# Event-Transitions:
# "Grubfather_2",
"White_Palace_01[left1]", "White_Palace_01[right1]", "White_Palace_01[top1]",
"White_Palace_02[left1]",
"White_Palace_03_hub[bot1]", "White_Palace_03_hub[left1]", "White_Palace_03_hub[left2]",
"White_Palace_03_hub[right1]", "White_Palace_03_hub[top1]",
"White_Palace_04[right2]", "White_Palace_04[top1]",
"White_Palace_05[left1]", "White_Palace_05[left2]", "White_Palace_05[right1]", "White_Palace_05[right2]",
"White_Palace_06[bot1]", "White_Palace_06[left1]", "White_Palace_06[top1]", "White_Palace_07[bot1]",
"White_Palace_07[top1]", "White_Palace_08[left1]", "White_Palace_08[right1]",
"White_Palace_09[right1]",
"White_Palace_11[door2]",
"White_Palace_12[bot1]", "White_Palace_12[right1]",
"White_Palace_13[left1]", "White_Palace_13[left2]", "White_Palace_13[left3]", "White_Palace_13[right1]",
"White_Palace_14[bot1]", "White_Palace_14[right1]",
"White_Palace_15[left1]", "White_Palace_15[right1]", "White_Palace_15[right2]",
"White_Palace_16[left1]", "White_Palace_16[left2]",
}
white_palace_checks = {
"Soul_Totem-White_Palace_Final",
"Soul_Totem-White_Palace_Entrance",
"Lore_Tablet-Palace_Throne",
"Soul_Totem-White_Palace_Left",
"Lore_Tablet-Palace_Workshop",
"Soul_Totem-White_Palace_Hub",
"Journal_Entry-Seal_of_Binding",
"Soul_Totem-White_Palace_Right",
"King_Fragment",
# Events:
"Palace_Entrance_Lantern_Lit",
"Palace_Left_Lantern_Lit",
"Palace_Right_Lantern_Lit",
"Warp-Path_of_Pain_Complete",
"Defeated_Path_of_Pain_Arena",
"Palace_Atrium_Gates_Opened",
"Completed_Path_of_Pain",
"Warp-White_Palace_Atrium_to_Palace_Grounds",
"Warp-White_Palace_Entrance_to_Palace_Grounds",
# Event-Regions:
"Soul_Totem-White_Palace_Right"
}
white_palace_events = {
"White_Palace_03_hub",
"White_Palace_13",
"White_Palace_01",
# Event-Transitions:
"White_Palace_12[bot1]", "White_Palace_12[bot1]", "White_Palace_03_hub[bot1]", "White_Palace_16[left2]",
"White_Palace_16[left2]", "White_Palace_11[door2]", "White_Palace_11[door2]", "White_Palace_18[top1]",
"White_Palace_18[top1]", "White_Palace_15[left1]", "White_Palace_15[left1]", "White_Palace_05[left2]",
"White_Palace_05[left2]", "White_Palace_14[bot1]", "White_Palace_14[bot1]", "White_Palace_13[left2]",
"White_Palace_13[left2]", "White_Palace_03_hub[left1]", "White_Palace_03_hub[left1]", "White_Palace_15[right2]",
"White_Palace_15[right2]", "White_Palace_06[top1]", "White_Palace_06[top1]", "White_Palace_03_hub[bot1]",
"White_Palace_08[right1]", "White_Palace_08[right1]", "White_Palace_03_hub[right1]", "White_Palace_03_hub[right1]",
"White_Palace_01[right1]", "White_Palace_01[right1]", "White_Palace_08[left1]", "White_Palace_08[left1]",
"White_Palace_19[left1]", "White_Palace_19[left1]", "White_Palace_04[right2]", "White_Palace_04[right2]",
"White_Palace_01[left1]", "White_Palace_01[left1]", "White_Palace_17[right1]", "White_Palace_17[right1]",
"White_Palace_07[bot1]", "White_Palace_07[bot1]", "White_Palace_20[bot1]", "White_Palace_20[bot1]",
"White_Palace_03_hub[left2]", "White_Palace_03_hub[left2]", "White_Palace_18[right1]", "White_Palace_18[right1]",
"White_Palace_05[right1]", "White_Palace_05[right1]", "White_Palace_17[bot1]", "White_Palace_17[bot1]",
"White_Palace_09[right1]", "White_Palace_09[right1]", "White_Palace_16[left1]", "White_Palace_16[left1]",
"White_Palace_13[left1]", "White_Palace_13[left1]", "White_Palace_06[bot1]", "White_Palace_06[bot1]",
"White_Palace_15[right1]", "White_Palace_15[right1]", "White_Palace_06[left1]", "White_Palace_06[left1]",
"White_Palace_05[right2]", "White_Palace_05[right2]", "White_Palace_04[top1]", "White_Palace_04[top1]",
"White_Palace_19[top1]", "White_Palace_19[top1]", "White_Palace_14[right1]", "White_Palace_14[right1]",
"White_Palace_03_hub[top1]", "White_Palace_03_hub[top1]", "Grubfather_2", "White_Palace_13[left3]",
"White_Palace_13[left3]", "White_Palace_02[left1]", "White_Palace_02[left1]", "White_Palace_12[right1]",
"White_Palace_12[right1]", "White_Palace_07[top1]", "White_Palace_07[top1]", "White_Palace_05[left1]",
"White_Palace_05[left1]", "White_Palace_13[right1]", "White_Palace_13[right1]", "White_Palace_01[top1]",
"White_Palace_01[top1]",
"Palace_Entrance_Lantern_Lit",
"Palace_Left_Lantern_Lit",
"Palace_Right_Lantern_Lit",
"Palace_Atrium_Gates_Opened",
"Warp-White_Palace_Atrium_to_Palace_Grounds",
"Warp-White_Palace_Entrance_to_Palace_Grounds",
}
progression_charms = {
# Baulder Killers
# Baldur Killers
"Grubberfly's_Elegy", "Weaversong", "Glowing_Womb",
# Spore Shroom spots in fungle wastes
# Spore Shroom spots in fungal wastes and elsewhere
"Spore_Shroom",
# Tuk gives egg,
"Defender's_Crest",
@@ -87,6 +90,14 @@ progression_charms = {
"Grimmchild1", "Grimmchild2"
}
# Vanilla placements of the following items have no impact on logic, thus we can avoid creating these items and
# locations entirely when the option to randomize them is disabled.
logicless_options = {
"RandomizeVesselFragments", "RandomizeGeoChests", "RandomizeJunkPitChests", "RandomizeRelics",
"RandomizeMaps", "RandomizeJournalEntries", "RandomizeGeoRocks", "RandomizeBossGeo",
"RandomizeLoreTablets", "RandomizeSoulTotems",
}
class HKWeb(WebWorld):
tutorials = [Tutorial(
@@ -125,8 +136,6 @@ class HKWorld(World):
charm_costs: typing.List[int]
data_version = 2
allow_white_palace = False
def __init__(self, world, player):
super(HKWorld, self).__init__(world, player)
self.created_multi_locations: typing.Dict[str, int] = Counter()
@@ -136,7 +145,7 @@ class HKWorld(World):
world = self.world
charm_costs = world.RandomCharmCosts[self.player].get_costs(world.random)
self.charm_costs = world.PlandoCharmCosts[self.player].get_costs(charm_costs)
world.exclude_locations[self.player].value.update(white_palace_locations)
# world.exclude_locations[self.player].value.update(white_palace_locations)
world.local_items[self.player].value.add("Mimic_Grub")
for vendor, unit in self.shops.items():
mini = getattr(world, f"Minimum{unit}Price")[self.player]
@@ -149,23 +158,43 @@ class HKWorld(World):
for option_name in disabled:
getattr(world, option_name)[self.player].value = 0
def white_palace_exclusions(self):
exclusions = set()
wp = self.world.WhitePalace[self.player]
if wp <= WhitePalace.option_nopathofpain:
exclusions.update(path_of_pain_locations)
if wp <= WhitePalace.option_kingfragment:
exclusions.update(white_palace_checks)
if wp == WhitePalace.option_exclude:
exclusions.add("King_Fragment")
if self.world.RandomizeCharms[self.player]:
# If charms are randomized, this will be junk-filled -- so transitions and events are not progression
exclusions.update(white_palace_transitions)
exclusions.update(white_palace_events)
return exclusions
def create_regions(self):
menu_region: Region = create_region(self.world, self.player, 'Menu')
self.world.regions.append(menu_region)
# wp_exclusions = self.white_palace_exclusions()
# Link regions
for event_name in event_names:
#if event_name in wp_exclusions:
# continue
loc = HKLocation(self.player, event_name, None, menu_region)
loc.place_locked_item(HKItem(event_name,
self.allow_white_palace or event_name not in white_palace_locations,
True, #event_name not in wp_exclusions,
None, "Event", self.player))
menu_region.locations.append(loc)
for entry_transition, exit_transition in connectors.items():
#if entry_transition in wp_exclusions:
# continue
if exit_transition:
# if door logic fulfilled -> award vanilla target as event
loc = HKLocation(self.player, entry_transition, None, menu_region)
loc.place_locked_item(HKItem(exit_transition,
self.allow_white_palace or exit_transition not in white_palace_locations,
True, #exit_transition not in wp_exclusions,
None, "Event", self.player))
menu_region.locations.append(loc)
@@ -178,33 +207,35 @@ class HKWorld(World):
geo_replace.add("Shade_Soul")
geo_replace.add("Descending_Dark")
wp_exclusions = self.white_palace_exclusions()
for option_key, option in hollow_knight_randomize_options.items():
if getattr(self.world, option_key)[self.player]:
for item_name, location_name in zip(option.items, option.locations):
if item_name in geo_replace:
item_name = "Geo_Rock-Default"
item = self.create_item(item_name)
if location_name in white_palace_locations:
self.create_location(location_name).place_locked_item(item)
elif location_name == "Start":
self.world.push_precollected(item)
randomized = getattr(self.world, option_key)[self.player]
for item_name, location_name in zip(option.items, option.locations):
vanilla = not randomized
excluded = False
if item_name in geo_replace:
item_name = "Geo_Rock-Default"
item = self.create_item(item_name)
if location_name == "Start":
self.world.push_precollected(item)
continue
location = self.create_location(location_name)
if not vanilla and location_name in wp_exclusions:
if location_name == 'King_Fragment':
excluded = True
else:
self.create_location(location_name)
pool.append(item)
else:
for item_name, location_name in zip(option.items, option.locations):
item = self.create_item(item_name)
if location_name == "Start":
self.world.push_precollected(item)
else:
self.create_location(location_name).place_locked_item(item)
vanilla = True
if excluded:
location.progress_type = LocationProgressType.EXCLUDED
if vanilla:
location.place_locked_item(item)
else:
pool.append(item)
for i in range(self.world.EggShopSlots[self.player].value):
self.create_location("Egg_Shop")
pool.append(self.create_item("Geo_Rock-Default"))
if not self.allow_white_palace:
loc = self.world.get_location("King_Fragment", self.player)
if loc.item and loc.item.name == loc.name:
loc.item.advancement = False
self.world.itempool += pool
for shopname in self.shops:
@@ -222,7 +253,15 @@ class HKWorld(World):
world = self.world
player = self.player
if world.logic[player] != 'nologic':
world.completion_condition[player] = lambda state: state.has('DREAMER', player, 3)
goal = world.Goal[player]
if goal == Goal.option_siblings:
world.completion_condition[player] = lambda state: state._hk_siblings_ending(player)
elif goal == Goal.option_radiance:
world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player)
else:
# Hollow Knight or Any goal.
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player)
set_rules(self)
def fill_slot_data(self):
@@ -348,16 +387,18 @@ class HKItem(Item):
game = "Hollow Knight"
def __init__(self, name, advancement, code, type, player: int = None):
super(HKItem, self).__init__(name, advancement, code if code else None, player)
self.type = type
if name == "Mimic_Grub":
self.trap = True
if type in ("Grub", "DreamWarrior", "Root", "Egg"):
self.skip_in_prog_balancing = True
if type == "Charm" and name not in progression_charms:
self.skip_in_prog_balancing = True
classification = ItemClassification.trap
elif type in ("Grub", "DreamWarrior", "Root", "Egg"):
classification = ItemClassification.progression_skip_balancing
elif type == "Charm" and name not in progression_charms:
classification = ItemClassification.progression_skip_balancing
elif advancement:
classification = ItemClassification.progression
else:
classification = ItemClassification.filler
super(HKItem, self).__init__(name, classification, code if code else None, player)
self.type = type
class HKLogicMixin(LogicMixin):
@@ -371,3 +412,38 @@ class HKLogicMixin(LogicMixin):
def _hk_start(self, player, start_location: str) -> bool:
return self.world.StartLocation[player] == start_location
def _hk_nail_combat(self, player: int) -> bool:
return self.has_any({'LFFTSLASH', 'RIGHTSLASH', 'UPSLASH'}, player)
def _hk_can_beat_thk(self, player: int) -> bool:
return (
self.has('Opened_Black_Egg_Temple', player)
and (self.count('FIREBALL', player) + self.count('SCREAM', player) + self.count('QUAKE', player)) > 1
and self._hk_nail_combat(player)
and (
self.has_any({'LEFTDASH', 'RIGHTDASH'}, player)
or self._hk_option(player, 'ProficientCombat')
)
)
def _hk_siblings_ending(self, player: int) -> bool:
return self._hk_can_beat_thk(player) and self.has('WHITEFRAGMENT', player, 3)
def _hk_can_beat_radiance(self, player: int) -> bool:
return (
self._hk_siblings_ending(player)
and self.has('DREAMNAIL', player, 1)
and (
(self.has('LEFTCLAW', player) and self.has('RIGHTCLAW', player))
or self.has('WINGS', player)
)
and (
self.count('FIREBALL', player) + self.count('SCREAM', player)
+ self.count('QUAKE', player)
) > 1
and (
(self.has('LEFTDASH', player, 2) and self.has('RIGHTDASH', player, 2)) # Both Shade Cloaks
or (self._hk_option(player, 'ProficientCombat') and self.has('QUAKE', player)) # or Dive
)
)

View File

@@ -27,4 +27,11 @@ website to generate a YAML using a graphical interface.
5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements.
6. The game will immediately drop you into the randomized game.
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
* Or hit Start then pause the game once you're in it.
* Or hit Start then pause the game once you're in it.
## Commands
While playing the multiworld you can interact with the server using various commands listed in the
[commands guide](/tutorial/Archipelago/commands/en). As this game does not have an in-game text client at the moment,
You can optionally connect to the multiworld using the text client, which can be found in the
[main Archipelago installation](https://github.com/ArchipelagoMW/Archipelago/releases) as Archipelago Text Client to
enter these commands.

View File

@@ -25,7 +25,7 @@ def set_shop_prices(hk_world):
for shop, unit in hk_world.shops.items():
for i in range(1, 1 + hk_world.created_multi_locations[shop]):
loc = hk_world.world.get_location(f"{shop}_{i}", hk_world.player)
add_rule(loc, lambda state, unit=units[unit], cost=loc.cost: state.count(unit, player) > cost)
add_rule(loc, lambda state, unit=units[unit], cost=loc.cost: state.count(unit, player) >= cost)
def set_rules(hk_world):

View File

@@ -5,7 +5,7 @@
import typing
from BaseClasses import Item
from BaseClasses import Item, ItemClassification
# pedestal_credit_text: str = "and the Unknown Item"
@@ -145,12 +145,14 @@ class MeritousItem(Item):
game: str = "Meritous"
def __init__(self, name, advancement, code, player):
super(MeritousItem, self).__init__(name, advancement, code, player)
super(MeritousItem, self).__init__(name,
ItemClassification.progression if advancement else ItemClassification.filler,
code, player)
if code is None:
self.type = "Event"
elif "Trap" in name:
self.type = "Trap"
self.trap = True
self.classification = ItemClassification.trap
elif "PSI Key" in name:
self.type = "PSI Key"
elif "upgrade" in name:
@@ -167,7 +169,7 @@ class MeritousItem(Item):
self.type = "Important Artifact"
else:
self.type = "Artifact"
self.never_exclude = True
self.classification = ItemClassification.useful
if name in LttPCreditsText:
lttp = LttPCreditsText[name]

View File

@@ -68,6 +68,7 @@ class MeritousWorld(World):
]
def create_item(self, name: str) -> Item:
return MeritousItem(name, self._is_progression(
name), item_table[name], self.player)
@@ -83,16 +84,19 @@ class MeritousWorld(World):
crystal_pool = []
for _ in range(0, qty):
rand_crystals = self.world.random.randrange(0, 32)
if rand_crystals < 16:
crystal_pool += [self.create_item("Crystals x500")]
elif rand_crystals < 28:
crystal_pool += [self.create_item("Crystals x1000")]
else:
crystal_pool += [self.create_item("Crystals x2000")]
crystal_pool.append(self.create_filler())
return crystal_pool
def get_filler_item_name(self) -> str:
rand_crystals = self.world.random.randrange(0, 32)
if rand_crystals < 16:
return "Crystals x500"
elif rand_crystals < 28:
return "Crystals x1000"
else:
return "Crystals x2000"
def generate_early(self):
self.goal = self.world.goal[self.player].value
self.include_evolution_traps = self.world.include_evolution_traps[self.player].value

View File

@@ -52,6 +52,13 @@ Once the goal has been completed, you may press F to send a forfeit, sending out
More in-depth information about the game can be found in the game's help file, accessed by pressing H while playing.
## Commands
While playing the multiworld you can interact with the server using various commands listed in the
[commands guide](/tutorial/Archipelago/commands/en). As this game does not have an in-game text client at the moment,
You can optionally connect to the multiworld using the text client, which can be found in the
[main Archipelago installation](https://github.com/ArchipelagoMW/Archipelago/releases) as Archipelago Text Client to
enter these commands.
## Game Troubleshooting
### An error message shows up at the bottom-left

View File

@@ -40,13 +40,13 @@ item_table = {
"16 Iron Ore": ItemData(45025, False),
"500 XP": ItemData(45026, False),
"100 XP": ItemData(45027, False),
"50 XP": ItemData(45028, False),
"50 XP": ItemData(45028, False),
"3 Ender Pearls": ItemData(45029, True),
"4 Lapis Lazuli": ItemData(45030, False),
"16 Porkchops": ItemData(45031, False),
"8 Gold Ore": ItemData(45032, False),
"Rotten Flesh": ItemData(45033, False),
"Single Arrow": ItemData(45034, False),
"4 Lapis Lazuli": ItemData(45030, False),
"16 Porkchops": ItemData(45031, False),
"8 Gold Ore": ItemData(45032, False),
"Rotten Flesh": ItemData(45033, False),
"Single Arrow": ItemData(45034, False),
"32 Arrows": ItemData(45035, False),
"Saddle": ItemData(45036, True),
"Structure Compass (Village)": ItemData(45037, True),
@@ -57,8 +57,9 @@ item_table = {
"Shulker Box": ItemData(45042, False),
"Dragon Egg Shard": ItemData(45043, True),
"Spyglass": ItemData(45044, True),
"Bee Trap": ItemData(45100, False),
"Lead": ItemData(45045, True),
"Bee Trap": ItemData(45100, False),
"Blaze Rods": ItemData(None, True),
"Defeat Ender Dragon": ItemData(None, True),
"Defeat Wither": ItemData(None, True),
@@ -90,6 +91,7 @@ required_items = {
"3 Ender Pearls": 4,
"Saddle": 1,
"Spyglass": 1,
"Lead": 1,
}
junk_weights = {

View File

@@ -124,6 +124,15 @@ advancement_table = {
"Sound of Music": AdvData(42105, 'Overworld'),
"Star Trader": AdvData(42106, 'Village'),
# 1.19 advancements
"Birthday Song": AdvData(42107, 'Pillager Outpost'),
"Bukkit Bukkit": AdvData(42108, 'Overworld'),
"It Spreads": AdvData(42109, 'Overworld'),
"Sneak 100": AdvData(42110, 'Overworld'),
"When the Squad Hops into Town": AdvData(42111, 'Overworld'),
"With Our Powers Combined!": AdvData(42112, 'The Nether'),
"You've Got a Friend in Me": AdvData(42113, 'Pillager Outpost'),
"Blaze Spawner": AdvData(None, 'Nether Fortress'),
"Ender Dragon": AdvData(None, 'The End'),
"Wither": AdvData(None, 'Nether Fortress'),
@@ -145,6 +154,8 @@ exclusion_table = {
"Surge Protector",
"Sound of Music",
"Star Trader",
"When the Squad Hops into Town",
"With Our Powers Combined!",
},
"unreasonable": {
"How Did We Get Here?",

View File

@@ -274,6 +274,22 @@ def set_advancement_rules(world: MultiWorld, player: int):
(state.can_reach("The Nether", 'Region', player) or state.can_reach("Nether Fortress", 'Region', player) or state._mc_can_piglin_trade(player)) and # soul sand for water elevator
state._mc_overworld_villager(player))
# 1.19 advancements
# can make a cake, and can reach a pillager outposts for allays
set_rule(world.get_location("Birthday Song", player), lambda state: state.can_reach("The Lie", "Location", player))
# find allay and craft a noteblock
set_rule(world.get_location("You've Got a Friend in Me", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player))
# craft bucket and adventure to find frog spawning biome
set_rule(world.get_location("Bukkit Bukkit", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player) and state._mc_can_adventure(player))
# I don't like this one its way to easy to get. just a pain to find.
set_rule(world.get_location("It Spreads", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has("Progressive Tools", player, 2))
# literally just a duplicate of It spreads.
set_rule(world.get_location("Sneak 100", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has("Progressive Tools", player, 2))
set_rule(world.get_location("When the Squad Hops into Town", player), lambda state: state._mc_can_adventure(player) and state.has("Lead", player))
# lead frogs to the nether and a basalt delta's biomes to find magma cubes.
set_rule(world.get_location("With Our Powers Combined!", player), lambda state: state._mc_can_adventure(player) and state.has("Lead", player))
# Sets rules on completion condition and postgame advancements
def set_completion_rules(world: MultiWorld, player: int):

View File

@@ -9,11 +9,11 @@ from .Regions import mc_regions, link_minecraft_structures, default_connections
from .Rules import set_advancement_rules, set_completion_rules
from worlds.generic.Rules import exclusion_rules
from BaseClasses import Region, Entrance, Item, Tutorial
from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification
from .Options import minecraft_options
from ..AutoWorld import World, WebWorld
client_version = 8
client_version = 9
class MinecraftWebWorld(WebWorld):
theme = "jungle"
@@ -65,7 +65,7 @@ class MinecraftWorld(World):
item_name_to_id = {name: data.code for name, data in item_table.items()}
location_name_to_id = {name: data.id for name, data in advancement_table.items()}
data_version = 6
data_version = 7
def _get_mc_data(self):
exits = [connection[0] for connection in default_connections]
@@ -164,12 +164,17 @@ class MinecraftWorld(World):
def create_item(self, name: str) -> Item:
item_data = item_table[name]
item = MinecraftItem(name, item_data.progression, item_data.code, self.player)
nonexcluded_items = ["Sharpness III Book", "Infinity Book", "Looting III Book"]
if name in nonexcluded_items: # prevent books from going on excluded locations
item.never_exclude = True
if name == "Bee Trap":
item.trap = True
classification = ItemClassification.trap
# prevent books from going on excluded locations
elif name in ("Sharpness III Book", "Infinity Book", "Looting III Book"):
classification = ItemClassification.useful
elif item_data.progression:
classification = ItemClassification.progression
else:
classification = ItemClassification.filler
item = MinecraftItem(name, classification, item_data.code, self.player)
return item
def mc_update_output(raw_data, server, port):

View File

@@ -3,7 +3,7 @@
## Required Software
- Minecraft Java Edition from
the [Minecraft Java Edition Store Page](https://www.minecraft.net/en-us/store/minecraft-java-edition) (update 1.17.1)
the [Minecraft Java Edition Store Page](https://www.minecraft.net/en-us/store/minecraft-java-edition)
- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
- (select `Minecraft Client` during installation.)
@@ -33,12 +33,13 @@ leave this window open as this is your server console.
### Connect to the MultiServer
Using minecraft 1.17.1 connect to the server `localhost`.
Using Minecraft 1.18.2 connect to the server `localhost`.
Once you are in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of
If you are using the website to host the game then it should auto-connect to the AP server without the need to `/connect`
38281. `(Password)` is only required if the Archipelago server you are using has a password set.
otherwise once you are in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281.
`(Password)` is only required if the Archipelago server you are using has a password set.
### Play the game
@@ -54,8 +55,8 @@ the following links are the versions of the software we use.
### Manual install Software links
- [Minecraft Forge Download Page](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.17.1.html)
- [Minecraft Forge Download Page](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.18.2.html)
- [Minecraft Archipelago Randomizer Mod Releases Page](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
- **DO NOT INSTALL THIS ON YOUR CLIENT**
- [Java 16 Download Page](https://docs.aws.amazon.com/corretto/latest/corretto-16-ug/downloads-list.html)
- [Amazon Corretto Java 17 Download Page](https://docs.aws.amazon.com/corretto/latest/corretto-17-ug/downloads-list.html)

View File

@@ -1,6 +1,7 @@
import typing
from BaseClasses import Item
from BaseClasses import Item, ItemClassification
def oot_data_to_ap_id(data, event):
if event or data[2] is None or data[0] == 'Shop':
@@ -11,6 +12,7 @@ def oot_data_to_ap_id(data, event):
else:
raise Exception(f'Unexpected OOT item type found: {data[0]}')
def ap_id_to_oot_data(ap_id):
offset = 66000
val = ap_id - offset
@@ -19,25 +21,31 @@ def ap_id_to_oot_data(ap_id):
except IndexError:
raise Exception(f'Could not find desired item ID: {ap_id}')
class OOTItem(Item):
game: str = "Ocarina of Time"
def __init__(self, name, player, data, event, force_not_advancement):
(type, advancement, index, special) = data
# "advancement" is True, False or None; some items are not advancement based on settings
if force_not_advancement:
classification = ItemClassification.useful
elif name == "Ice Trap":
classification = ItemClassification.trap
elif name == 'Gold Skulltula Token':
classification = ItemClassification.progression_skip_balancing
elif advancement:
classification = ItemClassification.progression
else:
classification = ItemClassification.filler
adv = bool(advancement) and not force_not_advancement
super(OOTItem, self).__init__(name, adv, oot_data_to_ap_id(data, event), player)
super(OOTItem, self).__init__(name, classification, oot_data_to_ap_id(data, event), player)
self.type = type
self.index = index
self.special = special or {}
self.looks_like_item = None
self.price = special.get('price', None) if special else None
self.internal = False
self.trap = name == 'Ice Trap'
if force_not_advancement:
self.never_exclude = True
if name == 'Gold Skulltula Token':
self.skip_in_prog_balancing = True
# The playthrough calculation calls a function that uses "sweep_for_events(key_only=True)"
# This checks if the item it's looking for is a small key, using the small key property.

View File

@@ -82,7 +82,7 @@ class OOTWeb(WebWorld):
"Español",
"setup_es.md",
"setup/es",
setup.author
setup.authors
)
tutorials = [setup, setup_es]
@@ -90,8 +90,8 @@ class OOTWeb(WebWorld):
class OOTWorld(World):
"""
The Legend of Zelda: Ocarina of Time is a 3D action/adventure game. Travel through Hyrule in two time periods,
learn magical ocarina songs, and explore twelve dungeons on your quest. Use Link's many items and abilities
The Legend of Zelda: Ocarina of Time is a 3D action/adventure game. Travel through Hyrule in two time periods,
learn magical ocarina songs, and explore twelve dungeons on your quest. Use Link's many items and abilities
to rescue the Seven Sages, and then confront Ganondorf to save Hyrule!
"""
game: str = "Ocarina of Time"
@@ -577,7 +577,7 @@ class OOTWorld(World):
(loc.internal or loc.type == 'Drop') and loc.event and loc.locked and loc not in reachable]
for loc in unreachable:
loc.parent_region.locations.remove(loc)
# Exception: Sell Big Poe is an event which is only reachable if Bottle with Big Poe is in the item pool.
# Exception: Sell Big Poe is an event which is only reachable if Bottle with Big Poe is in the item pool.
# We allow it to be removed only if Bottle with Big Poe is not in the itempool.
bigpoe = self.world.get_location('Sell Big Poe from Market Guard House', self.player)
if not all_state.has('Bottle with Big Poe', self.player) and bigpoe not in reachable:
@@ -632,7 +632,7 @@ class OOTWorld(World):
if shufflebk in itempools:
itempools[shufflebk].extend(dungeon.boss_key)
# We can't put a dungeon item on the end of a dungeon if a song is supposed to go there. Make sure not to include it.
# We can't put a dungeon item on the end of a dungeon if a song is supposed to go there. Make sure not to include it.
dungeon_locations = [loc for region in dungeon.regions for loc in region.locations
if loc.item is None and (
self.shuffle_song_items != 'dungeon' or loc.name not in dungeon_song_locations)]
@@ -877,7 +877,7 @@ class OOTWorld(World):
if loc.player in barren_hint_players:
hint_area = get_hint_area(loc)
items_by_region[loc.player][hint_area]['weight'] += 1
if loc.item.advancement or loc.item.never_exclude:
if loc.item.advancement or loc.item.useful:
items_by_region[loc.player][hint_area]['is_barren'] = False
if loc.player in woth_hint_players and loc.item.advancement:
# Skip item at location and see if game is still beatable

View File

@@ -20,6 +20,9 @@ Once Bizhawk has been installed, open Bizhawk and change the following settings:
- Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
"Lua+LuaInterface". This is required for the Lua script to function correctly.
**NOTE: Even if "Lua+LuaInterface" is already selected, toggle between the two options and reselect it. Fresh installs**
**of newer versions of Bizhawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load**
**"NLua+KopiLua" until this step is done.**
- Under Config > Customize > Advanced, make sure the box for AutoSaveRAM is checked, and click the 5s button.
This reduces the possibility of losing save data in emulator crashes.
- Under Config > Customize, check the "Run in background" and "Accept background input" boxes. This will allow you to

View File

@@ -6,7 +6,7 @@ from .Locations import lookup_name_to_id
from .Rules import set_rules, location_rules
from .Regions import locations_by_region, connectors
from .Options import options
from BaseClasses import Region, Item, Location, RegionType, Entrance
from BaseClasses import Region, Item, Location, RegionType, Entrance, ItemClassification
class OriBlindForest(World):
@@ -65,7 +65,9 @@ class OriBlindForest(World):
self.world.itempool.extend([self.create_item(item_name)] * count)
def create_item(self, name: str) -> Item:
return Item(name, not name.startswith("EX"), item_table[name], self.player)
return Item(name,
ItemClassification.progression if not name.startswith("EX") else ItemClassification.filler,
item_table[name], self.player)
class OriBlindForestLogic(LogicMixin):

View File

@@ -9,7 +9,7 @@ from .Regions import create_regions, getConnectionName
from .Rules import set_rules
from .Options import raft_options
from BaseClasses import Region, RegionType, Entrance, Location, MultiWorld, Item, Tutorial
from BaseClasses import Region, RegionType, Entrance, Location, MultiWorld, Item, ItemClassification, Tutorial
from ..AutoWorld import World, WebWorld
@@ -106,10 +106,11 @@ class RaftWorld(World):
def create_item(self, name: str) -> Item:
item = lookup_name_to_item[name]
return RaftItem(name, item["progression"], self.item_name_to_id[name], player=self.player)
return RaftItem(name, ItemClassification.progression if item["progression"] else ItemClassification.filler,
self.item_name_to_id[name], player=self.player)
def create_resourcePack(self, rpName: str) -> Item:
return RaftItem(rpName, False, self.item_name_to_id[rpName], player=self.player)
return RaftItem(rpName, ItemClassification.filler, self.item_name_to_id[rpName], player=self.player)
def collect_item(self, state, item, remove=False):
if item.name in progressive_item_list:
@@ -138,7 +139,7 @@ class RaftWorld(World):
self.setLocationItemFromRegion("CaravanIsland", "Tangaroa Frequency")
# Victory item
self.world.get_location("Tangaroa Next Frequency", self.player).place_locked_item(
RaftItem("Victory", True, None, player=self.player))
RaftItem("Victory", ItemClassification.progression, None, player=self.player))
def setLocationItem(self, location: str, itemName: str):
itemToUse = next(filter(lambda itm: itm.name == itemName, self.world.itempool))

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