Compare commits

...

735 Commits

Author SHA1 Message Date
CaitSith2
87152f17f5 Merge branch 'main' into show_all_hints 2022-07-02 06:56:15 -07:00
Fabian Dill
b9fb4de878 BaseClasses: make ItemClassification properties faster 2022-07-02 13:56:35 +02:00
Jarno Westhof
bcd7096e1d [The Witness] Update data_version as it was forgotten for 0.3.3
# Conflicts:
#	worlds/witness/docs/setup_en.md
2022-07-02 12:19:08 +02:00
strotlog
b206f2846a SNES games: use JPN as abbreviation for Japan/Japanese 2022-07-02 12:16:15 +02:00
CaitSith2
8a8bc6aa34 Factorio: Fix impossible seeds for rocket-part recipes as well. (#733) 2022-07-01 00:40:31 +02:00
black-sliver
bce7c258c3 CI: update Enemizer to 7.0.1 2022-06-30 22:55:05 +02:00
Fabian Dill
cea7278faf LttP: now that Enemizer allows for AP rom name, rename it. (#730)
* LttP: now that Enemizer allows for AP rom name, rename it.

* LttP: fix missing Enemizer message parenthesis
2022-06-30 10:00:37 -07:00
alwaysintreble
d7a9b98ce8 fix glossary link on sitemap 2022-06-29 22:08:38 +02:00
Alchav
7dcde12e2e Revert SC2 item classifications 2022-06-29 12:15:19 +02:00
black-sliver
ba2a5c4744 MC: add non-windows install to docs (#713)
* MC: add non-windows install to docs

* MC: better link naming for non-windows doc

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

* MC: doc change manual forge link to index

By removing the direct link to the version we avoid having to update it all the time and users will have to check the other version numbers for manual installation anyway.

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-06-28 19:23:18 +02:00
espeon65536
39ac3c38bf sm64: only apply DDD 100 coin star rule if the location exists (#716) 2022-06-27 23:03:34 -07:00
alwaysintreble
61f751a1db docs: add common terms documentation to website (#680)
* docs: add common terms documentation to website

* minor cleanup

* some rewording and reformatting.

* tighten up world definition clarity

Co-authored-by: Rome Reginelli <mduo13@gmail.com>

* Clarify seed definition a bit better

Co-authored-by: Rome Reginelli <mduo13@gmail.com>

* add text for "out of logic" and that slot names must be unique

* rename common terms to glossary

Co-authored-by: Rome Reginelli <mduo13@gmail.com>
2022-06-27 23:34:47 -04:00
alwaysintreble
5f2193f2e4 ror2: update setup guide (#671)
* ror2: remove yaml template from guide and link to player settings page. Add documentation on chat client

* ror2: copy paste the good config description like everyone else.
2022-06-27 21:05:55 -04:00
Daniel Grace
98b714f84a HK: Add options for Deathlink. (#672) 2022-06-27 21:05:29 -04:00
alwaysintreble
2a0198b618 multiserver: allow !release as an alias for !forfeit (#693)
* multiserver: allow `!release` as an alias for `!forfeit`

* create `/release` command. Add some periods to messages that print in console and point users to release

* Add a missing space on line 1135

Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-06-27 20:59:42 -04:00
The T
cd9f8f3119 SM64: DDD 100 Coins in Entrance Rando should expect sub removal (#711)
I brought this up in #super-mario-64, and the minor consensus is that 100 Coins is "possible", the same way Red Coins is possible.

According to a FAQ online, DDD has 106 coins. That means you are still required to get at least 5 of the red coins in order to get the 100 coin star. If we already have a rule stating the Red Coins require the sub to be removed (by reaching Bowser in the Fire Sea), it should apply to the 100 coins as well.

The consensus on it being "possible" was that it requires a very specific triple jump. There is no "Strict" category for this since it isn't caps/cannons-based, but it is extremely unreasonable to casual play. If you want to sequence break it, go for it, but I don't think it should be expected.
2022-06-27 07:43:48 -05:00
CaitSith2
37b569eca6 Changes: (#639)
* Changes:

* When client loses connection to the server through no fault of your own, it no longer forgets your username.
* It is now possible to do /connect archipelago://username:password@server:port or to paste archipelago://username:password@server:port into the connect bar and hit connect, and have both the username/password filled in that way.

* Switch checksfinder client to getting username from url if suppplied by url.

* Correct the print statement
2022-06-27 03:10:41 -07:00
CaitSith2
c1b099d44e Merge branch 'main' into show_all_hints 2022-06-27 02:19:24 -07:00
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
CaitSith2
d20ade7ff8 Automatically allow spectator slots to see all hints. 2022-06-22 16:18:36 -07:00
alwaysintreble
4e5071fd68 core: add a link to FAQ to the repo readme 2022-06-22 16:30:43 +02:00
CaitSith2
df90ff4ddb Don't need this line. 2022-06-22 04:53:38 -07:00
CaitSith2
a798e8aea2 Add a means to allow a client to opt into seeing ALL hints. 2022-06-22 04:49:31 -07: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
Felix R
5d3b4c8efd Meritous: Minor logic change (#584) 2022-05-28 00:52:14 +02:00
TheCondor07
8adc0dd7eb SC2: Fixed issue in random mission order with some missions being available too early 2022-05-27 20:53:06 +02:00
Jarno Westhof
2cb71c5352 [Timespinner] Removed backwarp from refugee camp to library from logic 2022-05-27 20:51:29 +02:00
TheCondor07
b6068f4519 SC2: Updated webhost details page 2022-05-27 18:32:33 +02:00
Fabian Dill
21a6b0143d MC: fix Bee Trap name 2022-05-26 20:49:24 -07:00
Fabian Dill
28949853f7 Setup: "ParseVersion" gives Deprecated Warning, fixing the warning. 2022-05-26 20:17:44 -07:00
Fabian Dill
65c83393bb SC2: fix copy pasta in client 2022-05-26 20:11:46 -07:00
Fabian Dill
960988ddcd WebHost: undo autoconnect link as not all browsers behave like Vivaldi. (#577)
* WebHost: undo autoconnect link as not all browsers behave like Vivaldi.

* Increase tooltip z-index

Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-05-26 21:13:49 -04:00
Fabian Dill
fb99dca83e WebHost: update waitress and bokeh (#575) 2022-05-26 20:58:48 -04:00
TheCondor07
e786243738 SC2: Option for random mission order (#569) 2022-05-26 19:28:10 +02:00
espeon65536
cec0e2cbfb OoT Client: deathlink toggle 2022-05-26 19:26:07 +02:00
espeon65536
dadd7d4693 OoT: big poe count option returns 2022-05-26 19:26:07 +02:00
espeon65536
dc558f906c OoT: lua script reads MQ dungeon address dynamically from autotracker context
finally I can stop updating this every version
2022-05-26 19:26:07 +02:00
espeon65536
8184e99409 OoT: add version check to lua script + client 2022-05-26 19:26:07 +02:00
espeon65536
ac87629550 OoT: write data into autotracking context
useful for the client and autotrackers to gather data easily
2022-05-26 19:26:07 +02:00
espeon65536
1c231b703a OoT: trap display rework
Traps from all games now disguise themselves as OoT items
Traps all display "[Player] is a FOOL!" when picked up
2022-05-26 19:26:07 +02:00
espeon65536
a66b11e6ec OoT: remove warning message during multidata manipulation 2022-05-26 19:26:07 +02:00
espeon65536
4f24c4ea78 OoT: write double-ended shuffled entrances to spoiler log more clearly 2022-05-26 19:26:07 +02:00
Fabian Dill
a800b148a2 Clients: allow "&[]" in tooltips, as kivy-escaped characters and fix similar translate issues in copy-paste clipboard 2022-05-26 07:46:23 -07:00
espeon65536
1710e15e49 MC: Bee Trap is renamed and trap 2022-05-26 07:45:14 -07:00
N00byKing
a332d4935d v6,sm64ex: Use standard Death Link option name 2022-05-26 07:05:19 +02:00
lordlou
9b855c7de0 Sm various fixes (#518) 2022-05-25 08:50:32 +02:00
Fabian Dill
e8be80ccd7 Network: remove "SlotAlreadyTaken" from docs and clients, as it was removed from the server in 0.2 2022-05-24 19:16:53 -07:00
Zach Parks
c661da57d8 add tooltip for Plando Options on Generate page (#563) 2022-05-23 19:17:41 -04:00
Fabian Dill
4165f58414 Clients: now featuring tooltips and some general cleanup (#564)
* Clients: now featuring tooltips and some general cleanup

* Clients: fade in tooltip over 0.25 seconds

* Clients: reset slot and team when disconnecting

* Clients: allow joining multiworld via link (TextClient only for now)
2022-05-23 15:20:02 -07:00
TheCondor07
7126b7bca0 SC2: Launch game in fullscreen mode. 2022-05-23 17:05:55 +02:00
CaitSith2
a7f647e3ca Block collection of Sahasrahlah. (#562) 2022-05-22 18:02:33 -07:00
Fabian Dill
e901a87afd LttP: fix adjuster partial settings store crash 2022-05-22 15:07:12 -07:00
Fabian Dill
9eb237b3af Clients: some cleanup 2022-05-22 04:49:55 -07:00
Fabian Dill
909ea9dc99 WebHost: fix plando options type error 2022-05-22 04:44:26 -07:00
Fabian Dill
86013328d6 Factorio: fix crude-oil related crashes (#552) 2022-05-21 20:57:26 +02:00
jtoyoda
0c80cd017f Adding in error message for FF1 if player name is empty in the ROM 2022-05-21 20:52:58 +02:00
TheCondor07
2b8a0f8cd8 SC2: Better set-up instructs and a section for those having issues 2022-05-21 20:52:37 +02:00
Alchav
e1926c973e [SC2] Item name groups and item game name fix (#555) 2022-05-21 20:52:00 +02:00
Chris Wilson
f515f680a4 ArchipIDLE is only visible during April 2022-05-21 20:51:24 +02:00
Fabian Dill
effba9fdec Factorio: fix crude-oil having no requirements at all 2022-05-21 02:49:20 +02:00
Fabian Dill
388f064307 SC2: fix typo in AllInMap Choice 2022-05-20 17:49:05 -07:00
TheCondor07
bb15485965 SC2: Quality of Life Changes/Fixes to Prepare For Future Feature (#550) 2022-05-21 02:47:16 +02:00
CaitSith2
cb9db5dff1 Verify start location hint 2022-05-20 18:42:36 +02:00
TheCondor07
3b644a0af1 SC2: Changed All In to require either previous mission instead of both 2022-05-20 17:06:12 +02:00
Fabian Dill
8ce2ecfaac SC2: more cleanup and fix setup compile 2022-05-19 19:18:12 -07:00
Fabian Dill
bdd9ca76ee WebHost: fix title (#544)
This is pretty simple. Approved.
2022-05-19 21:26:23 -04:00
Fabian Dill
44ae50083d SC2: setup fix link 2022-05-20 01:45:00 +02:00
Fabian Dill
e5d999c755 SC2: prevent freeze when X-ing out the window 2022-05-19 09:19:42 -07:00
espeon65536
4e90ebc7d9 MC: add 1.18.2 advancements (#537)
* MC: add 1.18.2 advancements and update options to match

* client version 8

* MC: multiworkd -> multiworld

* MC: account for overworld villager in Star Trader logic
Also standardized Surge Protector and VVFrightening logic

* MC: fix _mc_overworld_villager
some day I won't second-guess myself when writing logic
2022-05-19 09:15:23 -07:00
Alchav
dbf0458575 Implement get_filler_item_name for various games (#451) 2022-05-19 15:37:26 +02:00
TheCondor07
e6e44b8747 SC2: Updated /available and /unfinished to better handle collects 2022-05-19 05:34:46 +02:00
weffjebster
2b702528fd [Timespinner]HP cap setting (#536) 2022-05-19 05:25:08 +02:00
Colin Lenzen
23144ff204 [Timespinner] Add Show Item Drops in Bestiary 2022-05-19 05:24:31 +02:00
TheCondor07
764b6c78c5 SC2: Turned weaker upgrades into trash items 2022-05-19 05:23:57 +02:00
Fabian Dill
051e19e9c1 Core: tkinter import may only be needed for type-info and can be skipped in certain cases for speed of startup 2022-05-19 05:23:02 +02:00
Fabian Dill
ad99850192 SC2: some cleanup (#532)
* SC2: some cleanup

* SC2: some cleanup in client
2022-05-18 18:03:33 -07:00
alwaysintreble
c93eeb3607 tests: implement test to check for game_info file (#531) 2022-05-19 00:08:29 +02:00
TheCondor07
551cf8442f Starcraft 2 Wings of Liberty AP Implementation (#528) 2022-05-18 23:27:38 +02:00
Fabian Dill
90d506ee7c Fill: fix type-crash on unfilled having either str or Location
Fill: speed up Counter creation by skipping intermediary list creation
2022-05-18 22:40:40 +02:00
alwaysintreble
45bca78e75 docs: add tutorials to api documentation 2022-05-18 21:29:59 +02:00
alwaysintreble
11faca1940 docs: update various broken links/images and fix a few small typos. point some links to current webhost server rather than hardcoding archipelago.gg 2022-05-18 21:29:59 +02:00
Fabian Dill
47b179dec4 setup: utf-8-sig signing 2022-05-18 11:57:10 -07:00
PoryGone
05efbe0af8 SA2B Style Improvements (#525) 2022-05-18 14:56:43 +02:00
alwaysintreble
48a7587c5a Fix broken plando guide links 2022-05-18 14:55:53 +02:00
metzner
ff82145633 The Witness: Updated Setup Guide, now referencing the PopTracker map- & auto-tracking package! 2022-05-17 16:09:41 +02:00
wafflesoup
dcc703f454 Webworld docs: Removed extra space 2022-05-17 16:09:16 +02:00
Jarno Westhof
07f66fb15a [Timespinner] Make DamageRandoOverrides a bit easier to work with and compatible with older yamls (#517) 2022-05-15 14:39:38 -07:00
CaitSith2
c0fb7d9f9a Add local and non_local items to item_links (#506)
* Add local and non_local items to item_links

* Whoops, don't pass list of list to verify_items.

* Give a did you mean result in the exception.
2022-05-15 07:41:11 -07:00
beauxq
2b6fc6dd3a only accept true and false for a range if they make sense 2022-05-15 16:31:26 +02:00
lordlou
e147495fb9 Sm unbeatable seed fix (#514) 2022-05-15 16:29:56 +02:00
Fabian Dill
b2e65a19a2 Webhost serialize fixes (#512)
* Main: compress world type output log

* WebHost: ensure plando_options is serializable to json
2022-05-14 14:05:21 -07:00
Fabian Dill
44638ccc1a Fill: fix priority_locations being undone by prog_balancing shop shuffle and other late-fills (#513) 2022-05-14 14:04:16 -07:00
Fabian Dill
5f4b2cfa52 Main: compress world type output log (#509) 2022-05-14 11:52:57 -07:00
jtoyoda
0bc2301530 Updating docs to remove reference to the AP preset 2022-05-14 19:50:25 +02:00
Fabian Dill
d1eda38745 Clients: centralize UI and input behaviour 2022-05-14 12:01:11 +02:00
PoryGone
dc10421531 Sonic Adventure 2: Battle Implementation (#501) 2022-05-14 12:00:49 +02:00
black-sliver
00f5975a3c CI: build release AppImage on ubuntu-18.04
Change to oldest available container to maximize compatibility
2022-05-14 11:56:13 +02:00
metzner
b41f444013 Logic Fix (Potentially gamebreaking) 2022-05-14 11:55:48 +02:00
CaitSith2
89b4060a06 Fix Plando options pickling error 2022-05-14 11:53:37 +02:00
CaitSith2
98ca001da6 Fix for variable progression balancing when yaml has progression on/off. 2022-05-14 11:52:57 +02:00
weffjebster
b0b41711d4 Adding damage rando v2 options to timespinner rando (#503) 2022-05-14 11:52:35 +02:00
CaitSith2
3f691d6977 Add "exclude" to item links (#497) 2022-05-11 16:37:18 -07:00
alwaysintreble
977159e572 Webworld docs: move gameinfo documentation to their world folders and copy them for webhost use. (#455) 2022-05-11 20:05:53 +02:00
black-sliver
9e15e754c2 Doc: use RegionType.Generic in api.md 2022-05-11 11:53:57 +02:00
Doug Hoskisson
c085ee47ed variable-progression-balancing (#356) 2022-05-11 09:13:21 +02:00
Fabian Dill
a5ca118bbf Test: rename (#499) 2022-05-10 23:51:18 -07:00
KonoTyran
521122fd4f Minecraft Version support (#458)
* add support for other java/forge versions

* fix fetching correct mod for specified version.

* add support for other java/forge versions

* fix fetching correct mod for specified version.

* convert MinecraftClient.py to read forge versions from Randomizer Mod Repo.

* add minecraft_versions.json to gitignore.

* remove redundant json import

* update host to release.
add forge checking,
fixed duplicated code due to merge.

* clerify that beta channel will most likely make games no longer playable on release channel

* convert commetns to docstrings.
2022-05-10 21:00:53 -07:00
Fabian Dill
86933d8150 LttP: ensure non-native items are rendered as star in Shops (#486)
* LttP: ensure non-native items are rendered as star in Shops

* LttP: ensure non-native items are rendered as star in Shops - fix missing player number lookup
2022-05-10 20:41:44 -07:00
Alchav
976f34c19f Fix Harmless Hellway logic
Original logic from SMZ3 is:

items.KeyPD >= (GetLocation("Palace of Darkness - Harmless Hellway").ItemIs(KeyPD, World) ?
                        (items.Hammer && items.Bow && items.Lamp) || config.Keysanity ? 4 : 3 :
                        (items.Hammer && items.Bow && items.Lamp) || config.Keysanity ? 6 : 5))

I believe these parentheses are needed to correctly replicate this logic
2022-05-11 04:20:30 +02:00
Fabian Dill
a56340663c Test: check that all_state can complete game 2022-05-10 19:20:15 -07:00
Fabian Dill
e3900e9f99 Test: fix wrong name 2022-05-10 19:20:15 -07:00
Fabian Dill
e8b1362172 Test: check for working completion condition 2022-05-10 19:20:15 -07:00
espeon65536
f6d857b5b5 Core: make progression balancing deterministic (#295) 2022-05-11 04:12:26 +02:00
Fabian Dill
aa9f43dea1 Fuzzy: switch to damerau_levenshtein_distance with ignored case 2022-05-10 19:09:07 -07:00
Fabian Dill
513ab62ce7 Fuzzy: replace thefuzz with jellyfish
GPL -> BSD2Clause and should be faster though I haven't tested it myself and just trusted people on the internet.
Jellyfish also allows us access to many more algorithms should they be any better. Trying out Jaro distance now instead of Levenshtein.
2022-05-10 19:09:07 -07:00
black-sliver
a020dea277 Doc: fix wrong naming in api.md example code 2022-05-10 16:52:41 +02:00
espeon65536
19dd447dcb OoT: update connector.lua to use new names 2022-05-10 05:50:39 -07:00
Kono Tyran
eb1abd9222 Fix broken image link in Factorio setup_en.md 2022-05-09 11:12:05 -07:00
NewSoupVi
9ab7c8d9e5 Witness: Changes in response to Beta run 1 (#494)
Co-authored-by: metzner <unconfigured@null.spigotmc.org>
2022-05-09 07:20:28 +02:00
Hussein Farran
1e592b4681 Update network protocol doc to extend intra-doc linking (#489) 2022-05-06 10:01:43 -04:00
espeon65536
40a08d0d84 SM64 logic fixes and ER handling (#488)
* SM64: add painting name to location hints if area randomizer

* SM64: fix BitFS access logic
Using can_reach regions in an entrance's logic is unsafe because reachable_regions won't be updated if no progression locations are reached. can_reach location is safe.

* SM64: rework logic for correctness and consistency
- BoB Mario Wings to the Sky is extremely difficult with cap and no cannon, will never be required
- DDD Collect the Caps no longer requires metal cap except on strict cap
- Cavern of the Metal Cap red coins no longer requires metal cap except on strict cap
- CCM, TTM, WDW cannons added on strict cannons for their expected stars
- BoB 100 coins requires cap or cannon if both are strict, since only 99 coins are available otherwise

* SM64: write entrances to spoiler log

* SM64: tweak format of WDW cannon rules
2022-05-06 13:33:39 +02:00
CaitSith2
517e72f442 Add options to generate page (#450)
* Add Item cheat permission to generate page.

* Indicate that both remaining_mode and item cheat are disabled in race mode.

* Add server_password

* refine tooltips and help for server_password and !admin command.

* Add Plando options to generation page.

* Remove debugging code

* Style adjustments and HTML formatting and tag fixes with the goal of making the page nicer looking and not as vertical.

Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-05-04 20:03:19 -07:00
black-sliver
ea51df432d Factorio: map gen: allow arbitrary property expressions
Can be used to override tile generation; we don't want to define all of them
2022-05-05 02:34:11 +02:00
black-sliver
c27bfc515e Factorio: map gen: allow width and height
Don't accept arbitrary keys to catch typos.
This should be all 'basic' map gen settings now.
2022-05-05 02:34:11 +02:00
Fabian Dill
7fad0b0f51 Test: introduce test for every game has a tutorial (#478) 2022-05-03 22:14:03 +02:00
Fabian Dill
76663f819b Merge pull request #483 from espeon65536/oot
Ocarina of Time: V6.2 updates
2022-05-02 11:54:42 +02:00
Fabian Dill
666760f0cf Merge branch 'main' into oot 2022-05-02 11:54:00 +02:00
espeon65536
2d73f2f46e OoT: update mq dungeon table address in lua script
Plan is to automate this next version, so the lua doesn't need updating every time I mess with the ROM
2022-05-01 14:48:33 -05:00
espeon65536
c8e54bbcd0 OoT: add Thieves' Hideout keys/locations to tracker 2022-05-01 14:44:49 -05:00
espeon65536
76a4dce66a OoT: move Thieves' Hideout location IDs to match with old ID 2022-05-01 14:44:26 -05:00
espeon65536
c102d602b3 OoT: update ASM to version 6.2 2022-05-01 13:27:53 -05:00
espeon65536
e711490f6c OoT: bump data version 2022-05-01 13:07:15 -05:00
espeon65536
c801cdbb3b OoT: update logic files, naming, and logic tricks to version 6.2
Gerudo Training Grounds -> Ground
Composers Grave -> Royal Familys Tomb
Gerudo Fortress -> Thieves Hideout for the indoor sections
2022-05-01 13:05:52 -05:00
metzner
9d638671bb Removed Tutorial Gate Close as a location for compatibility with current randomizer version 2022-04-30 15:10:50 -07:00
metzner
4a703481ba Removed Mountain Trap Door Triple Exit location. 2022-04-30 15:10:50 -07:00
metzner
897cbb9826 Moved Quarry Big Panel to uncommon 2022-04-30 15:10:50 -07:00
metzner
bb710cc360 Fix: Traps weren't showing up 2022-04-30 15:10:50 -07:00
Fabian Dill
5eab07d8d6 Network: add games argument to GetDataPackage (#473) 2022-04-30 04:39:08 +02:00
espeon65536
894a30b9bd Check for ROMs at beginning of generation (#475) 2022-04-30 03:37:28 +02:00
Fabian Dill
e8579771a5 Requirements: update websockets 2022-04-29 17:52:41 -07:00
Fabian Dill
09670a4475 Factorio: demote EnergyLink text to debug logging level. 2022-04-29 16:56:54 -07:00
Fabian Dill
ff783cf9a5 WebHost: update flask 2022-04-29 16:54:42 -07:00
beauxq
46d31c3ee3 typing, mostly in AutoWorld.py
includes a bugfix (that was found by static type checking)
in `get_filler_item_name`
2022-04-29 03:00:39 +02:00
NewSoupVi
3e8c821c02 Add The Witness (#467)
* Added The Witness


Co-authored-by: metzner <unconfigured@null.spigotmc.org>
Co-authored-by: Jarno Westhof <jarnowesthof@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-04-29 00:42:11 +02:00
Alchav
50eaf712a9 Remove outdated disclaimer 2022-04-29 00:29:21 +02:00
espeon65536
f476747ade OoT: remove early ROM check
Will be replaced with an Autoworld class method, can_generate
2022-04-28 09:44:53 -05:00
espeon65536
d8d881085f OoT: permit dungeon_items: overworld to fill into shops 2022-04-27 21:45:31 -05:00
espeon65536
fd6e1b3046 OoT: fix bad interaction between dungeon_items: overworld and songs: dungeon 2022-04-27 21:43:16 -05:00
espeon65536
d6697924cb OoT: item links don't crash
still point to not-helpful locations though
2022-04-27 21:11:04 -05:00
espeon65536
3001926ae4 OoT: fix locations pointing to wrong entrance in server hints 2022-04-27 20:12:32 -05:00
Doug Hoskisson
578451fcfa add some typing info to CollectionState (#468) 2022-04-27 21:19:53 +02:00
espeon65536
d57bdf6dc3 OoT: No Logic modifications
NL now uses the glitchless world graph, which enables entrance randomizer
NL forces all logic tricks on, progression balancing off, minimal accessibility
2022-04-26 15:16:02 -05:00
espeon65536
0309fac592 OoT: check for existence of ROM at start of generation 2022-04-26 13:43:02 -05:00
Fabian Dill
9ecd320c8c OoT: prevent connection from outdated clients 2022-04-26 07:40:01 -07:00
CaitSith2
c326566bd2 Show "did you mean 'item/location_name'" in invalid item/location error. (#469) 2022-04-26 02:28:43 -07:00
black-sliver
4f10dbb896 Test: add missing cleanup in TestGenerate
fixes a warning on some systems
2022-04-24 19:32:08 +02:00
N00byKing
cb6d377796 sm64ex: Rule updates 2022-04-24 08:29:26 -07:00
Jarno Westhof
b5f58b0a03 Fixed copy paste issue 2022-04-24 08:28:14 -07:00
N00byKing
9ee5fae476 sm64ex: Update dependency in documentation 2022-04-24 08:27:31 -07:00
Hussein Farran
81feb2fd5e [Docs] Update network diagram into mermaid diagram syntax. (#446) 2022-04-24 11:20:14 -04:00
Colin Lenzen
75a76fb184 Include options in options dict 2022-04-24 05:01:53 +02:00
Colin Lenzen
21f1ccbfb4 Timespinner: Options to Support Loot Randomization 2022-04-24 05:01:53 +02:00
Fabian Dill
0f5a7cda6c LttP: fix retro allowing arrows in "P" price shuffle in shops (#448) 2022-04-22 09:12:51 +02:00
Fabian Dill
acd7bce903 Logging: change text loggers to log current time 2022-04-22 09:11:50 +02:00
Chris Wilson
1afacd28a1 Fix chart indent 2022-04-20 14:30:36 -07:00
Chris Wilson
6e171d19f0 Remove no longer needed control data 2022-04-20 14:30:36 -07:00
Chris Wilson
66921499ad Display multiple charts per row, reduce overall chart size 2022-04-20 14:30:36 -07:00
Fabian Dill
249972c7fd webhost: stats improvements 2022-04-20 14:30:36 -07:00
Fabian Dill
dae0e233b8 WebHost: add a /stats page 2022-04-20 14:30:36 -07:00
CaitSith2
8bb566a250 Fix remaining_mode on webhost. (#449)
* Fix remaining_mode on webhost.

* Actually use the correct parameter for remaining_mode.
2022-04-20 10:46:05 -04:00
Rob McAuley
6a25bbeef0 Fix other instances of /tutorial/archipelago 2022-04-17 15:54:52 +02:00
Rob McAuley
6286ac4a3b Fix lowercase letter in link leading to 404 2022-04-17 15:54:52 +02:00
Vince Lund
447f99ea15 Documentation: Added example of item_links 2022-04-17 15:53:57 +02:00
N00byKing
587d4dc8b6 v6,sm64ex: Allow location exclusions 2022-04-15 02:02:39 +02:00
black-sliver
b5613ffcf5 OoT: mark Compress/Decompress as executables 2022-04-13 23:34:44 +02:00
KonoTyran
1fe82b1312 Add bug report link to WebWorld (#440)
* Add bug report link to WebWorld

* change bug_report_page to an optional
reword bug report link text.

* update Minecraft bug report page to a template.

* change wording of link.

* add `bug_report_page` documentation to api.md
2022-04-12 17:37:05 -04:00
Fabian Dill
a4daa78c0b HK: plando charm cost (#431)
* HK: Charm costs in spoiler log now with charm name.

* HK: Allow Plando Charm costs

* HK: skip unnecessary checks
https://github.com/ArchipelagoMW/Archipelago/pull/431#discussion_r847804916
2022-04-12 11:13:52 -04:00
Jarno Westhof
618bdfc917 [Core] Allow multiple worlds in one yaml (#428) 2022-04-12 10:57:29 +02:00
CaitSith2
8e68aa0ccd Add group collect (#424)
* Add group collect

* code cleanup
2022-04-10 14:08:54 -07:00
Fabian Dill
df3757657e Setup: fix SMZ3 and SoE file bindings 2022-04-10 10:03:24 -07:00
Vince Lund
0eea1a1d89 Timespinner: Added Lore names to Downloads 2022-04-10 09:28:33 +02:00
Fabian Dill
15dcdca6fc HK: slight optimization
items are marked as advancement if they have an additional effect, so instead of a lookup we can just refer to a bool that's already local as a quick pre-check
2022-04-08 21:53:30 +02:00
Fabian Dill
7a6aef03e7 HK: Charm costs in spoiler log now with charm name. 2022-04-08 21:53:17 +02:00
Fabian Dill
c61f3b9110 MC: make slot data json compatible
(Changing base type of Options in recent PR broke this)
2022-04-08 21:37:08 +02:00
black-sliver
42fecc7491 Core: change how required versions work, deprecate IgnoreGame (#426)
`AutoWorld.World`s can set required_server_version and required_client_version properties. Drop `get_required_client_version()`.
`MultiServer` will set an absolute minimum client version based on its capability (protocol level).
`IgnoreVersion` tag is replaced by using `Tracker` or `TextOnly` with empty or null `game`.
Ignoring game will also ignore game's required_client_version (and fall back to server capability).
2022-04-08 11:16:36 +02:00
Doug Hoskisson
0acca6dd64 Options.py typing (#412)
* Options.py typing
use NumericOption class inheriting from numbers.Integral instead of int
also can sometimes take text like:
"high": high end of range
"low": low end of range
"true", "on": default if it exists, otherwise high end of range
"false", "off": zero if zero is the low end

* just low, high, and default for range text

Co-authored-by: Doug Hoskisson <doughoskisson@novuslabs.com>
2022-04-07 13:42:30 -04:00
Fabian Dill
ec00d1b710 SMZ3: allow TextClient to connect by name (#423) 2022-04-07 10:50:55 -04:00
Fabian Dill
f093e90c23 ModuleUpdate: add it to a few more common entry points
MinecraftClient: add requests import to requirements.txt
2022-04-07 15:21:47 +02:00
black-sliver
3d1f6d9b82 Clients: don't use stdin when loading steam overlay 2022-04-07 12:28:00 +02:00
CaitSith2
9bdcbb9008 Fix item links. 2022-04-07 10:22:17 +02:00
Fabian Dill
491e6c8730 HK: don't progression balance "Currency"-like progression items (#419)
* HK: don't progression balance "Currency"-like progression items

* only skip prog balancing on charms that don't unlock checks by themselves

Co-authored-by: Kono Tyran <HAklowner@gmail.com>
2022-04-05 18:41:15 -04:00
Fabian Dill
d32d268d97 WebHost: add yaml checker to sitemap and drop "mystery", as we've been doing in various places (#421) 2022-04-05 15:17:47 +02:00
Fabian Dill
30c447b9f3 Lttp adjuster (#417)
* LttP: Allow running Adjuster with positional arg rom (windows -> open with)

* LttP: use "proper" logging in adjuster and load baserom from local directory if not found.
2022-04-05 09:16:06 -04:00
Fabian Dill
2def8f35ad KH: what? yeah, it's HK (#420)
* KH: what? yeah, it's HK
someone this hadn't been spotted yet.

* KH: also fix the start AST Node, just in case we add those in at some point (currently they resolve to True/False anyway)
2022-04-05 09:01:33 -04:00
Chris Wilson
f2055daf1a Add a /sitemap to the WebHost (#418) 2022-04-05 07:14:30 +02:00
CaitSith2
944571ea89 LttP: Add Allow collect option, default Off. (#414)
* LttP: Add Allow collect option, default Off.

* Add allow_collect to the sample yaml.
2022-04-05 03:54:49 +02:00
Fabian Dill
f7c601b863 HK: Fix web gen
By allowing pickle to find the options
2022-04-05 02:35:55 +02:00
Fabian Dill
7315da2ccb AutoWorld: don't import __pycache__ 2022-04-05 02:26:58 +02:00
Fabian Dill
2f7f6a0b58 Setup: copy LttP yaml to build automatically 2022-04-05 02:26:41 +02:00
Chris Wilson
3f43051c35 [WebHost] Do not calculate settingHash multiple times in weighted-settings 2022-04-04 16:48:59 -07:00
Chris Wilson
535c35310d [WebHost] Fix a bug causing player-settings to fail to update the hash on JSON updates 2022-04-04 16:48:59 -07:00
Chris Wilson
8fbe6a4511 [WebHost] Only calculate settingHash once in player-settings 2022-04-04 16:48:59 -07:00
Chris Wilson
07ff0f1026 [WebHost] Fix /user-content styles (#408) 2022-04-03 20:16:15 -04:00
Fabian Dill
a080288e3e Core: update version (#407) 2022-04-03 19:39:01 -04:00
Fabian Dill
71bd87f293 HK: don't flag maps as progression 2022-04-03 19:38:39 -04:00
Fabian Dill
574e2abba8 HK: write shop prices to spoiler log 2022-04-03 19:38:39 -04:00
Hussein Farran
cffa772801 Fix unit test and generation failures. Whoops. 2022-04-03 19:38:39 -04:00
Fabian Dill
66bd793306 HK: add item name groups 2022-04-03 19:38:39 -04:00
Hussein Farran
0eb37883ca Add docstrings to hollow knight YAML options. 2022-04-03 19:38:39 -04:00
Hussein Farran
356384ab05 Add Hollow Knight setup guide, game info, and to README 2022-04-03 19:38:39 -04:00
Fabian Dill
8c2c6877b6 HK: sort shop contents by cost 2022-04-03 19:38:39 -04:00
Fabian Dill
d1d40d8a60 HK: ignore relics logic
HK: write sets ordered, to reduce history changes
2022-04-03 19:38:39 -04:00
Fabian Dill
b026a0a372 HK: write charm costs to spoiler 2022-04-03 19:38:39 -04:00
Fabian Dill
73bcd0058a HK: force disabled options to actually be disabled 2022-04-03 19:38:39 -04:00
Fabian Dill
0cf396e5d6 HK: account for "Start" location in another place 2022-04-03 19:38:39 -04:00
Fabian Dill
1bc09d4292 make black sliver happy 2022-04-03 19:38:39 -04:00
Fabian Dill
97d0c51db1 HK: allow webgen 2022-04-03 19:38:39 -04:00
Fabian Dill
ed1c11267c Options: loudly crash if random text is not recognized, instead of de… (#401)
* Options: loudly crash if random text is not recognized, instead of defaulting to full "random"

* Update Options.py

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

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-04-03 19:37:57 -04:00
Fabian Dill
a3e1ac896f Generate: don't fail on marked utf-8 files (#399)
utf-8-sig will fallback to non-sig automatically
2022-04-03 15:55:46 -04:00
Zach Parks
37d9eb2752 Added filesafe player name function and updated generator functions in all worlds to use filesafe player name during output
Thanks Windows for your bad filesystem.
2022-04-03 20:45:44 +02:00
CaitSith2
05e267a0bd Prevent use of old collection clients without boss collection blocklist. (#406) 2022-04-03 14:45:06 -04:00
Fabian Dill
d1f0a29a02 OoT: fix patching deltas when run from another folder 2022-04-03 20:44:27 +02:00
Fabian Dill
fb2e780c56 LttP/SMZ3: some more file ending fixes (#393) 2022-04-03 13:42:18 -04:00
Fabian Dill
ba3257f850 ItemLinks: prevent attempts at cross-game (#402) 2022-04-03 13:09:05 -04:00
Fabian Dill
215d5e9adf AutoWorld: ensure WebWorld is instantiated, preventing an easy mistake. (#404) 2022-04-03 13:08:50 -04:00
black-sliver
5392b32d5c SoE: WebWorld theme and fix long standing bug (#397) 2022-04-03 04:48:43 +02:00
alwaysintreble
4dd0a75914 multiworld tracker: properly fix item link breaking tracker 2022-04-03 02:03:48 +02:00
CaitSith2
a2212002ae Link to the Past Block collection of bosses. (#395) 2022-04-03 01:39:28 +02:00
lordlou
91ccee3513 [SM] remote item back compat fix (#400) 2022-04-03 01:36:31 +02:00
black-sliver
2a593d5d0a CI: add windows build action
set setuptools to 60.x until the issue is resolved
change retention to 7 days
2022-04-02 04:49:42 +02:00
black-sliver
a93b3d79aa Minecraft fixes (#388) 2022-04-02 04:49:27 +02:00
black-sliver
938ab32cda CI: bigger unittest matrix 2022-04-02 00:17:53 +02:00
Jarno Westhof
6f5ab05345 [Docs] Added WebWorld Theme (#387) 2022-04-01 22:39:39 +02:00
Chris Wilson
95f8647f09 Added 50 items to ArchipIDLE (#385) 2022-04-01 10:04:42 -04:00
jonloveslegos
06c8caa3cc Fixed checksfinder client failing when getting an item before sending one, and fixed checksfinder client not appearing in the installer (#383) 2022-04-01 07:55:06 +02:00
Fabian Dill
d206a562df rename ChecksFinder folder (#380) 2022-04-01 01:17:46 -04:00
Fabian Dill
a0a290e481 Setup: fix OoT conditions for file page (#381) 2022-04-01 01:17:26 -04:00
Fabian Dill
266ff0c520 Setup: fix apparently forgotten file endings (#382)
I feel like I did this... mh.
2022-04-01 01:16:54 -04:00
Fabian Dill
931bf7da16 SMZ3: fix loading TextScript on systems that don't default to something utf-8 compatible (#384) 2022-04-01 01:14:20 -04:00
black-sliver
fe4a26d034 CI: add Generate.py tests
* allows ModuleUpdate to be run outside of local_dir
* adds windows-latest to the unittest matrix
2022-04-01 06:16:13 +02:00
Zach Parks
dca70a99ad Webhost - Update copyright year 2022-04-01 04:46:02 +02:00
Fabian Dill
1a24a73ccd add forgotten file (#378) 2022-03-31 22:14:51 -04:00
Fabian Dill
ae163319e0 More bug fixes 2022-04-01 03:54:30 +02:00
Fabian Dill
65864e273b Fix bugs 2022-04-01 03:54:30 +02:00
Fabian Dill
199b778d2b Bug Squashing 2022-04-01 03:54:30 +02:00
Fabian Dill
70e3c47120 Core: update version 2022-04-01 03:54:30 +02:00
Fabian Dill
eddc5d6524 Options: some more typing 2022-04-01 03:21:31 +02:00
strotlog
fae3068c25 Documentation: Add RetroArch emu to SNES games (#365)
* Documentation: Add RetroArch emu to SNES games

* Documentation: fix screenshot alt text
2022-03-31 18:09:13 -04:00
Fabian Dill
b9014b2a60 Setup: update cx-Freeze (#370) 2022-03-31 18:08:48 -04:00
Chris Wilson
6b07b6407c Add ArchipIDLE setup guide (#375) 2022-03-31 18:08:13 -04:00
black-sliver
a10b987f1c CI: add release action 2022-03-31 23:29:56 +02:00
Fabian Dill
1f16310797 Options: fix "toggle: random" always being True 2022-03-31 22:57:19 +02:00
Jarno Westhof
0fd59063d9 [Timespinner] Fixed typo 2022-03-31 22:16:23 +02:00
Jarno Westhof
aab477b874 Value is not actually a member of a Set package 2022-03-31 20:16:04 +02:00
black-sliver
098d939653 Generate: fix windows support (#368) 2022-03-31 08:22:01 +02:00
black-sliver
7d830362a7 Setup, Launcher, Linux Support (#359) 2022-03-31 05:08:15 +02:00
Fabian Dill
0db1660369 MultiServer: fix crash when hint_location hits a location that can exist, but did not exist in this multiworld. 2022-03-30 19:59:34 -07:00
strotlog
c471a70b35 Documentation: Fix order, title, and link to SMZ3 2022-03-31 04:57:52 +02:00
alwaysintreble
6aef6f2c11 Revert "increment data version since progression of items changed"
This reverts commit 2243686847.
2022-03-31 04:57:01 +02:00
alwaysintreble
000f0bf2f1 clean, concise method for flagging dio's as progression
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-31 04:57:01 +02:00
alwaysintreble
0f1c08b43a increment data version since progression of items changed 2022-03-31 04:57:01 +02:00
alwaysintreble
76ffb5cd53 fix event location and victory rules. 2022-03-31 04:57:01 +02:00
alwaysintreble
23d245d43c Update RoR2 logic to use event locations 2022-03-31 04:57:01 +02:00
Chris Wilson
aabc86fc01 ArchipIDLE "Improvements" (#366)
* Increase ArchipIDLE location count to 150

* Reduce location count per seed to 100. Game will now complete between 50 and 100 minutes.

* Add 50 more items to ArchipIDLE

* Update data_version to 2
2022-03-30 21:34:39 -04:00
Fabian Dill
cebd7fb545 Core: check for key-only once in sweep (#361) 2022-03-30 21:30:06 -04:00
Fabian Dill
8337689640 Options: more feedback on bad compares (#362) 2022-03-30 21:29:45 -04:00
Fabian Dill
0263130126 SM: fix Nothing type crash (#363) 2022-03-30 21:29:08 -04:00
jonloveslegos
c472d740ec fixed the checksfinder game info saying the wrong number 2022-03-29 09:19:13 +02:00
jonloveslegos
0fd244eee0 Added to the ChecksFinder docs 2022-03-29 09:19:13 +02:00
Chris Wilson
7dcb6f66da Website Style Upgrade (#353)
* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* Seed download page improvements

* Add styles to weighted-settings page

* Minor adjustments to styles

* Revert base theme to grass

* Add more items to ArchipIDLE

* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* Seed download page improvements

* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* Add styles to weighted-settings page

* Minor adjustments to styles

* Revert base theme to grass

* Add more items to ArchipIDLE

* Improve Archipidle item name

* [WebHost] Update background images, waiting on jungle.png, added partyTime theme

* [WebHost] Fix tab ordering on landing page, remove islands on screen scale, fix tutorial page width scaling

* [WebHost] Final touches to WebHost

* Improve get_world_theme function, add partyTime theme to ArchipIDLE WebWorld

* Remove sending_visible from AutoWorld

* AP Ocarina of Time Client (#352)

* Core: update jinja (#351)

* some typing and cleaning, mostly in Fill.py (#349)

* some typing and cleaning, mostly in Fill.py

* address missing Option types

* resolve a few TODOs discussed in pull request

* SM: Optimize a bit (#350)

* SM: Optimize a bit

* SM: init bosses only once

* New World Order (#355)

* Core: update jinja

* SM: Optimize a bit

* AutoWorld: import worlds in alphabetical order, to be predictable rather than arbitrary

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

* Remove references to Z5Client in English OoT setup guide

* Prevent markdown code blocks from overflowing their container

Co-authored-by: espeon65536 <81029175+espeon65536@users.noreply.github.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-28 20:12:17 -04:00
Fabian Dill
14956d27bd Core: don't sweep excluded locations for accessibility check, as they are forbidden from having progression anyway. (#357) 2022-03-28 20:03:57 -04:00
Fabian Dill
420be2c44f New World Order (#355)
* Core: update jinja

* SM: Optimize a bit

* AutoWorld: import worlds in alphabetical order, to be predictable rather than arbitrary

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-27 21:45:14 -04:00
Fabian Dill
3bb3a902b3 SM: Optimize a bit (#350)
* SM: Optimize a bit

* SM: init bosses only once
2022-03-27 19:50:58 -04:00
Doug Hoskisson
2b138ac940 some typing and cleaning, mostly in Fill.py (#349)
* some typing and cleaning, mostly in Fill.py

* address missing Option types

* resolve a few TODOs discussed in pull request
2022-03-27 19:47:47 -04:00
Fabian Dill
b6eeef1db6 Core: update jinja (#351) 2022-03-27 19:44:25 -04:00
espeon65536
469dda7d85 AP Ocarina of Time Client (#352) 2022-03-27 21:44:22 +02:00
Fabian Dill
3c2933d587 V6: fix area cost always referencing last area cost, instead of current index (#348)
* V6: fix area cost always referencing last area cost, instead of current index
* V6: autoformat Rules.py
* V6: correct a location name for rule application
2022-03-26 10:16:28 +01:00
Alchav
3b128c8512 SM - Option to remove empty locations (#323) 2022-03-26 07:26:55 +01:00
lordlou
fb1be7b003 [SM] min client version change (#347) 2022-03-26 02:36:13 +01:00
lordlou
e0aa52ed27 [SMZ3] player count fix (#346) 2022-03-26 02:35:55 +01:00
Fabian Dill
64ac619b46 Core: use assert correctly (#345)
Core: add some more types to State and add count() method
2022-03-25 20:12:54 -04:00
Fabian Dill
902472be32 Core: fix place_locked_item not setting location back-reference (#344) 2022-03-25 17:57:00 -04:00
Fabian Dill
cb024b00d9 Fill: don't crash before debug output in case of unfilled locations (#342) 2022-03-24 12:47:20 -04:00
Fabian Dill
75de616465 Core: remove sending_visible (#339)
* Core: remove sending_visible
Only used by Factorio and that use predates start_location_hints, which works perfectly fine for this purpose.

* Factorio: minor cleanup
2022-03-24 12:15:52 -04:00
Fabian Dill
c12d8e2f46 WebHost: remove duplicate file ending dot (#343) 2022-03-24 12:03:05 -04:00
strotlog
d8087660e6 SM: remove SNIClient read of duplicative ROM name (#340) 2022-03-24 11:40:02 -04:00
alwaysintreble
87a8e6e20c Documentation: minor updates (#320)
* documentation: add links to other guides in adding games.md

* documentation: add webworld to api.md

* documentation: point people to docs folder and discord for help with adding games

* tutorial: go a bit more in depth on downloading a template yaml

* Make Ijwu happy

* point to baseclasses.py in api.md and reformat links a bit
2022-03-24 09:21:08 -04:00
black-sliver
b599a7607d SoE: mark traps as being traps 2022-03-24 01:49:45 +01:00
black-sliver
a6b22d1f41 Doc: rewrite patch section (#336)
this gets rid of a lot of information that is not required
and somewhat adds best practice to it
2022-03-23 19:47:27 -04:00
Fabian Dill
8e59761b03 BaseClasses: more type annotations (#337) 2022-03-23 19:46:26 -04:00
Jarno Westhof
8599506497 [Docs] Datastorage (#333) 2022-03-23 22:20:55 +01:00
Fabian Dill
e4ab10fe92 MultiServer: try to import tkinter, then provide some feedback (#329)
* MultiServer: try to import tkinter, then provide some feedback

TK may not be installed alongside python on some systems, like minimal linux installations.

* specify tkinter package

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

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-23 08:53:35 -04:00
Fabian Dill
171c297d1b Options: implement additional assert checking for duplicate option ID (#332)
Options: change "random" prevention to assert, so it doesn't get checked in compiled version, as it's a source-code-time issue.
2022-03-22 21:28:15 -04:00
black-sliver
5eccb0ed49 api.md: clarify get_required_client_version (#334) 2022-03-22 21:22:58 -04:00
black-sliver
f326de2686 SoE: require client 0.2.6
Require latest https://github.com/black-sliver/ap-soeclient/
currently hosted on evermizer.com/apclient.beta
2022-03-23 02:21:47 +01:00
black-sliver
2ca6b7f929 SoE: add traps and death link 2022-03-23 02:21:47 +01:00
black-sliver
79afae17e7 SoE: add item groups 2022-03-23 02:21:47 +01:00
black-sliver
cb4d9dc365 SoE: some cleanup 2022-03-23 02:21:47 +01:00
jonloveslegos
4bf8b98681 Added my game made specifically for AP, ChecksFinder (Minesweeper) (#302) 2022-03-22 23:30:10 +01:00
Fabian Dill
7f1371ec00 SNIClient: provide example full connect command when required and some pep8 (#330) 2022-03-22 14:13:04 -04:00
espeon65536
cb3db8ae16 ALttP: fix ROM crash when loading mail/shield overflow sprite in hard/expert 2022-03-22 18:59:47 +01:00
Fabian Dill
cf2e37f92d Options: sort values when displaying OptionSet (#326) 2022-03-22 10:25:34 -04:00
Fabian Dill
92319b0e31 Options: implement item name groups for item sets options (#325)
* Options: implement item name groups for item sets options

* Options: update outdated comments; verify is done by the verify mixin parent class nowadays
2022-03-21 15:49:54 -04:00
Fabian Dill
d4ff653937 Clients: change scouted locations_info to full NetworkItem (#324) 2022-03-21 10:26:38 -04:00
lordlou
7df12930ef [SM] Add support for Remote Items (#317) 2022-03-21 05:34:47 +01:00
lordlou
9ba70951d5 [SMZ3] tutorial (#322) 2022-03-20 16:12:53 +01:00
espeon65536
2d25369d06 Core: fix division by zero in case of spectator slot 2022-03-20 16:08:22 +01:00
Alchav
affcaf1c02 ItemLink - ensure no extra fillers are created (#316) 2022-03-20 16:07:51 +01:00
Fabian Dill
7e314c0d7a Multidata: don't include start inventory events in sendable items (#319) 2022-03-18 13:19:21 -04:00
Fabian Dill
1266ca314c Options: some display name renames that were missed (#318) 2022-03-18 13:17:19 -04:00
Fabian Dill
7394598aff Patch: update to version 4 (#312) 2022-03-18 04:53:09 +01:00
Felix R
b02a710bc5 Add Meritous (#278) 2022-03-18 04:30:47 +01:00
Fabian Dill
ce6966a823 WebHost: update modules (#314) 2022-03-16 08:53:11 -04:00
Alchav
689183edc0 [RL] Specify list of available classes (#262) 2022-03-16 02:31:14 +01:00
Hussein Farran
43113c7844 Merge pull request #308 from ArchipelagoMW/quick-recipe
Quick recipe
2022-03-15 13:19:34 -04:00
Hussein Farran
fb8879a919 Merge pull request #307 from ArchipelagoMW/energy-bridge
Factorio: increase cost of Energy Bridge
2022-03-15 13:18:52 -04:00
Hussein Farran
136b9f9138 Merge pull request #309 from ArchipelagoMW/update-requirements
Requirements: update modules and move bsdiff4 to be a common module
2022-03-15 13:17:17 -04:00
Hussein Farran
eea326561e Merge pull request #310 from ArchipelagoMW/lttp-client-version
LttP: update required client version as behaviour changes were introd…
2022-03-15 13:16:30 -04:00
Fabian Dill
e3781c68be Requirements: update modules and move bsdiff4 to be a common module 2022-03-15 14:17:03 +01:00
Fabian Dill
d2927dc68f LttP: update required client version as behaviour changes were introduced with location check writes to savegame 2022-03-15 14:07:32 +01:00
Fabian Dill
ca95d47127 Factorio: improve generation speed of make_quick_recipe slightly 2022-03-15 14:02:05 +01:00
Fabian Dill
a5a0c94a2c Factorio: increase cost of Energy Bridge 2022-03-15 14:01:15 +01:00
lordlou
cfa49ee757 Add SMZ3 support (#270) 2022-03-15 13:55:57 +01:00
jtoyoda
8921baecd0 Adding in support for bizhawk 2.8 2022-03-14 23:29:02 +01:00
Fabian Dill
8b78477c69 WebHost: order guides by alphabet 2022-03-14 21:30:18 +01:00
Fabian Dill
14633724f2 MultiServer: don't count groups as players in status message 2022-03-14 20:31:57 +01:00
Fabian Dill
8d3ea9c50f Factorio: write Group names to mod 2022-03-14 20:26:16 +01:00
Fabian Dill
32a58b1adb Progression Balancing: fix ItemLinks and Spectator interactions 2022-03-14 20:10:49 +01:00
Fabian Dill
f01a31ce56 Factorio: add recipe for energy bridge 2022-03-14 19:40:35 +01:00
Chris Wilson
3f69c3a2ab Merge pull request #304 from LegendaryLinux/webhost-archipidle
[WebHost] Add docblock and FAQ pages for ArchipIDLE
2022-03-13 23:52:32 -04:00
Chris Wilson
e0f3d6d0d7 [WebHost] Add docblock and FAQ pages for ArchipIDLE 2022-03-13 23:44:30 -04:00
Chris Wilson
a8f148acac Merge pull request #303 from LegendaryLinux/archipidle
Fix generation issues with ArchipIDLE
2022-03-13 23:17:48 -04:00
Chris Wilson
0c57af40dc [ArchipIDLE] Rename locations to indicate the time required to wait 2022-03-13 22:56:46 -04:00
Chris Wilson
0714be6b73 [ArchipIDLE] Prevent overwriting global item pool 2022-03-13 20:44:08 -04:00
Chris Wilson
b5ce6f0bb0 [ArchipIDLE] Fix inefficiency caused by indentation error 2022-03-13 20:42:20 -04:00
Chris Wilson
67d59067eb [ArchipIDLE] Use shuffled item_table during generation 2022-03-13 20:39:13 -04:00
Chris Wilson
f1984a103d [ArchipIDLE] Set only 20 items as progressive 2022-03-13 15:31:27 -04:00
Chris Wilson
41fd7a8a56 Fixed failing tests 2022-03-13 14:37:56 -04:00
Chris Wilson
14ac139d03 Added world for ArchipIDLE 2022-03-13 04:04:12 -04:00
Yussur Mustafa Oraji
97b1ae5ee9 v6,sm64ex: Add support for offline singeplayer seeds (#301) 2022-03-12 22:05:54 +01:00
espeon65536
15e0763ed5 Update progression balancing algorithm (#300)
* New progression balancing algo: computes based on percentage of locations available rather than absolute number of locations
2022-03-12 22:05:03 +01:00
CaitSith2
3ce5d14210 changes
* Fix bug in overworld collected item checks.
* Don't mark checks as checked on the same cycle that its written just in case write fails for some reason. It will be later confirmed by a successful read of the newly written value on a future cycle.
2022-03-07 17:32:28 -08:00
CaitSith2
2c884e2ca5 Mark LttP items as collected in game if item is not owned by player. 2022-03-07 14:10:07 -08:00
CaitSith2
c204fb9b14 Fix LocationInfo packet handling. 2022-03-07 11:21:29 -08:00
Fabian Dill
69721d2d04 MultiServer: remove no longer needed value check from Set packet 2022-03-04 22:48:27 +01:00
Fabian Dill
73b14d3826 Factorio: rename "data" to "keys" to make EnergyLink work 2022-03-04 21:41:07 +01:00
Fabian Dill
7ca6f24e6c MultiServer: allow multiple, ordered operations
MultiServer: rename "data" on Get, Retrieved and SetNotify to "keys"
MultiServer: add some more operators
SniClient: some pep8 cleanup
2022-03-04 21:36:18 +01:00
lordlou
2c3e3f0d43 Sm/slot data (#299) 2022-03-02 19:41:03 +01:00
Alchav
3b68c6902c Save game options with server save data (#294) 2022-03-02 00:39:58 +01:00
espeon65536
c5926fcf2b OoT: rename all option displayname to display_name 2022-03-02 00:38:24 +01:00
lordlou
e6546eea85 Sm/slot data (#298)
for trackers
2022-03-02 00:37:52 +01:00
lordlou
892357cc2c Sm/item link support (#297) 2022-03-02 00:37:11 +01:00
CaitSith2
7c6fb26eb7 Filter new line characters from connect bar text input. 2022-02-28 18:25:07 -08:00
Fabian Dill
491530ad60 LttP: fix reveal bytes for Mysery Mire Prize 2022-02-24 23:43:33 +01:00
Fabian Dill
6667c1f03d Factorio: set parenthesis correctly 2022-02-24 22:50:51 +01:00
Fabian Dill
e985fc41ce Factorio: make EnergyLink an option 2022-02-24 22:40:16 +01:00
CaitSith2
508eb04e94 Tweak energy bridge values
ENERGY_INCREMENT now set dynamically by whatever the ap-energy-bridge buffer capacity ends up being.
2022-02-24 13:16:18 -08:00
Fabian Dill
68e9368bb3 EnergyLink: cleanup the second 2022-02-24 06:17:51 +01:00
CaitSith2
db152e6790 Fix deathlink killing the game watcher on startup. 2022-02-23 21:13:17 -08:00
Fabian Dill
6bf2f5611a EnergyLink: lots of cleanup 2022-02-24 04:47:01 +01:00
CaitSith2
11a13967d5 Report precisely what item link is invalid instead of ALL of them. 2022-02-23 16:21:53 -08:00
Fabian Dill
05fe423ef1 Factorio: implement EnergyLink 2022-02-24 00:51:31 +01:00
CaitSith2
6e0165986f Move duplicate name item link check to verify. 2022-02-23 15:17:24 -08:00
t3hf1gm3nt
f167e11905 Update ALttP in-game hints (#289) 2022-02-23 19:29:37 +01:00
Jarno Westhof
727cae902a [Subnautica] I guess someone had todo it 2022-02-23 19:26:17 +01:00
Fabian Dill
f38f9a47da Webhost: support groups without loading multidata on every /room request 2022-02-23 19:16:45 +01:00
CaitSith2
7708d3d157 Don't list item_link on neither trackers nor main patch download page. 2022-02-23 01:51:49 -08:00
Fabian Dill
4c64c5ad05 Spectator: fix data type 2022-02-23 04:02:11 +01:00
Fabian Dill
534ce179ec MultiServer: fix sending items_handling warning 2022-02-23 03:35:24 +01:00
espeon65536
1b73bacde1 Minecraft: add death_link attr to test world 2022-02-23 02:44:47 +01:00
espeon65536
a13ad32ec5 Minecraft: save some memory with static rules on Locations 2022-02-23 02:44:47 +01:00
espeon65536
13a6c86077 Minecraft: require bed for can_adventure if death link is on by default 2022-02-23 02:44:47 +01:00
espeon65536
5fc1b760f4 Minecraft: only add egg shards to the pool if at least 1 is required 2022-02-23 02:44:47 +01:00
jtoyoda
a6d78d9af7 Adding in the ability to disable messages in the client 2022-02-23 02:44:27 +01:00
CaitSith2
48669e96d1 Remove players from item_link pool if they don't contribute any items to the pool. 2022-02-22 16:35:41 -08:00
CaitSith2
071161176e Deny same item_link name from same player. Also report which player caused the item_link errors. 2022-02-22 16:32:37 -08:00
CaitSith2
f046d76c59 make sure starting location hints also apply to all applicable item_link players. 2022-02-22 12:49:43 -08:00
Fabian Dill
53ab224fba MultiServer: rip Store, Modify -> Set, Retrieve -> Get, Modified -> SetReply, ModifyNotify -> SetNotify 2022-02-22 12:17:21 +01:00
Fabian Dill
5faf1f27de MultiServer: add network commands Store, Retrieve, Modify and ModifyNotify 2022-02-22 11:48:08 +01:00
Fabian Dill
f38b970ea2 ItemLinks: hopefully fix remaining generation issues 2022-02-22 10:14:26 +01:00
Fabian Dill
5dbccfcbbd ItemLinks: fix all_state not collecting event locations 2022-02-22 09:49:01 +01:00
CaitSith2
de5249f99e start_hints now work for items in item_link pools. 2022-02-21 15:33:39 -08:00
CaitSith2
420320f896 Fix item_links not even rolling 2022-02-21 14:59:01 -08:00
Hussein Farran
06ac2d1805 Merge pull request #290 from N00byKing/patch-1
sm64ex: Documentation Updates
2022-02-21 11:35:14 -05:00
jtoyoda
cdc0b7a649 Fixing unit tests for FFR by excluding tests that use Default settings as FFR logic is controlled by the original randomizer 2022-02-21 00:01:27 +01:00
jtoyoda
6c7be51221 Adding in check to ensure there is at least one item in the FFR item pool 2022-02-21 00:01:27 +01:00
Fabian Dill
1159137c0d FF1: set up special settings page (remote website) 2022-02-20 21:54:00 +01:00
Fabian Dill
a98cb040b7 Core: Region type hints and some init optimization 2022-02-20 19:19:56 +01:00
Fabian Dill
170213e6d4 Core: reduce memory use of "Entrance" class
SM64: reduce count of lambda creations (memory/cpu speedup)
2022-02-20 19:10:08 +01:00
Yussur Mustafa Oraji
129c6d2d1e sm64ex: Documentation Updates 2022-02-20 12:41:16 +01:00
Fabian Dill
ad75ee8c50 Multiserver: warn about missing items_handling 2022-02-20 04:17:27 +01:00
Fabian Dill
e94b99da65 SNIClient: make address optional for multi-snes 2022-02-20 04:17:27 +01:00
CaitSith2
4f47709d32 Add entrance info to start hints. 2022-02-19 10:52:05 -08:00
Fabian Dill
71ea8d7148 Multiserver: provide compat for 0.2.3 and somewhat older multidata 2022-02-19 17:50:56 +01:00
Fabian Dill
919223cd2f Super Metroid: fix start_inventory 2022-02-19 17:43:16 +01:00
CaitSith2
fd8cace362 Reworked hints for item_link 2022-02-18 13:03:55 -08:00
Fabian Dill
18d937d83e Core: shuffle around AutoWorld imports 2022-02-18 20:29:44 +01:00
Yussur Mustafa Oraji
1d19868119 v6: Update NPC Trinket Rule 2022-02-18 19:32:36 +01:00
Fabian Dill
840e634161 update docs with NetworkSlot and create_as_hint 2022-02-18 18:54:26 +01:00
Fabian Dill
731eef8c2f bump version 2022-02-18 17:58:45 +01:00
CaitSith2
135ee018a9 update Copyright 2022-02-17 19:03:11 -08:00
Fabian Dill
7633392eea update Copyright 2022-02-17 08:21:26 +01:00
Fabian Dill
daea0f3e5e Core: provide a way to add to CollectionState init and copy
SM: use that way
OoT: use that way
2022-02-17 07:07:34 +01:00
Fabian Dill
c525c80b49 ItemLinks: move item links to events, mess up their logic in doing so and lock them behind plando option "item_links" until they're fixed. 2022-02-17 06:07:20 +01:00
N00byKing
311fb04647 sm64ex: Add option for Bob-omb Buddy Checks 2022-02-16 19:46:28 +01:00
Hussein Farran
219bd9c10e Merge pull request #285 from JarnoWesthof/add_reference_to_cpp_lib
[Docs] Added reference to the cpp lib
2022-02-16 09:01:00 -05:00
Jarno Westhof
6d704eadd7 [Docs] Added reference to the cpp lib 2022-02-16 13:05:47 +01:00
Hussein Farran
32da1993e1 Merge pull request #283 from alwaysintreble/tutorials
Tutorials: Clean up plando guide a bit; explain datapackage page. Add…
2022-02-15 15:58:03 -05:00
alwaysintreble
d4cad980e5 Tutorials: remove /api 2022-02-15 14:17:17 -06:00
Fabian Dill
53340ab22c Core: remove legacy "dynamic_regions", as all regions are now dynamic 2022-02-15 06:29:57 +01:00
alwaysintreble
2d3767a35c Tutorials: Clean up plando guide a bit; explain datapackage page. Add link to the weighted settings page in advanced tutorial. 2022-02-14 17:21:19 -06:00
Fabian Dill
aaa9bc906e WebHost: update dependencies 2022-02-14 21:37:50 +01:00
N00byKing
7503317d49 sm64ex: Add DeathLink Support 2022-02-14 16:37:49 +01:00
Fabian Dill
3fc93a33c8 WebHost: check for duplicate names
Generate: use Counter for duplicate names to make finding the dupes easier
2022-02-14 04:58:21 +01:00
Fabian Dill
d7d1d54a0b Core: generalize pre_fill item pool handling 2022-02-13 23:02:18 +01:00
Fabian Dill
34b9344084 ItemLink; correct validation to allow for None replacement item 2022-02-13 20:19:17 +01:00
espeon65536
779f3a8a61 OoT: regions are not barren if they contain never-exclude items 2022-02-12 17:29:06 +01:00
espeon65536
8c1690ef65 OoT: invert logic of previous commit 2022-02-12 17:29:06 +01:00
espeon65536
85f32d9a97 OoT: make Farore's Wind a never-exclude item if the relevant trick is off 2022-02-12 17:29:06 +01:00
espeon65536
54c7ec5873 OoT: ice traps have the trap attribute 2022-02-12 17:29:06 +01:00
espeon65536
8d260708d3 OoT: ER fixes
Don't allow beatable only to influence priority placements
Shuffle spawns after warp songs to prevent spawn points going to Desert Colossus
Prevent child spawn from priority placing at Colossus if overworld ER is off
2022-02-12 17:29:06 +01:00
espeon65536
f8009e4b84 OoT: certain ER options convert closed forest into closed deku + child start 2022-02-12 17:29:06 +01:00
Alchav
a2260ee6b2 [SM] Fix "No Energy" bugs 2022-02-12 17:28:23 +01:00
Bondo
6193eafb7b Update Text.py (#274)
Changed the Houlihan hint tile to list the winner of the SGLive 2021 tournament in similar style to alttp tournament winners.
2022-02-12 03:01:41 +01:00
black-sliver
a4eea3325f Document id range for items and locations 2022-02-12 03:00:09 +01:00
Jarno Westhof
b93e61b758 [Timnespinner] Implemented get_filler_item_name 2022-02-09 21:08:07 +01:00
Fabian Dill
14448ad97e Multidata: allow SoE/SM/LttP to connect via player name for use in Tracker/Text clients 2022-02-09 21:06:50 +01:00
Yussur Mustafa Oraji
3d17f0d588 sm64ex: Add Course Randomizer and Progressive Keys (#256) 2022-02-09 20:57:38 +01:00
CaitSith2
ee5ea09cbc Add an autofill !hint_location for clicking on a Missing: line, when user uses !missing. 2022-02-08 14:29:24 -08:00
Fabian Dill
aac8ca97ed Core: define unreachables as set 2022-02-07 00:26:44 +01:00
Fabian Dill
e4d6da47a4 LttP: fix rom writing crash because I accidentally defaulted to pep8 naming 2022-02-06 21:44:19 +01:00
Fabian Dill
9f7dbb394e LttP: convert overflow progressive items into highest-allowed-tier of non-progressive item 2022-02-06 20:11:40 +01:00
Fabian Dill
f98063b97a Options: move name verification into class methods, out of Generate.py 2022-02-06 16:37:21 +01:00
black-sliver
ed607bdc37 Fix wrong message when loading apsave
from doubling received_items that happened when moving from world-based to client-based remote_items
2022-02-06 12:28:46 +01:00
N00byKing
a3c3e4cbd4 v6: Add Area Cost Shuffle 2022-02-05 20:24:42 +01:00
ScootyPuffJr1
bffb8a034e [SM]Update Options.py (#268)
* [SM] Update Options.py
2022-02-05 20:23:17 +01:00
Fabian Dill
8242d4fe92 ItemLink: fix wrong variable use 2022-02-05 20:15:56 +01:00
Fabian Dill
279b682ac2 ItemLink: hopefully fix coop functionality 2022-02-05 17:35:12 +01:00
Fabian Dill
43ff476d98 AutoWorld: add "Everything" item_name_group to all worlds 2022-02-05 16:55:11 +01:00
Fabian Dill
28201a6c38 Core: implement first version of ItemLinks 2022-02-05 15:49:19 +01:00
N00byKing
6923800081 v6: Music Randomizer 2022-02-04 23:04:05 +01:00
Jarno Westhof
700b83572e [Timespinner] Added new shop options (#264)
* [Timespinner] Added new shop options
2022-02-04 21:53:47 +01:00
Fabian Dill
6e53cb2deb V6: some cleanup 2022-02-04 21:34:39 +01:00
Yussur Mustafa Oraji
8e04182b3f v6: Add Area Randomizer (#249)
* v6: Add Area Randomizer
2022-02-04 21:22:26 +01:00
Jarno Westhof
9fd6d1b81f [Server] Broadcast hint_cost and location_check_points update changes via RoomUpdate 2022-02-03 13:09:59 +01:00
Fabian Dill
60379d9ae6 LttP: when generating hint tiles, no longer consider Single Arrow as useful, but do consider all varieties of Bow. Additionally, don't create hints for Universal Small Keys 2022-02-03 10:41:31 +01:00
black-sliver
29ba1d4809 Doc: change displayname to display_name in api.md 2022-02-02 23:38:00 +01:00
Fabian Dill
dc4b064c73 Options: change displayname to display_name 2022-02-02 16:29:29 +01:00
Fabian Dill
0f20888563 Options: allow yaml access to Priority Locations 2022-02-01 16:36:14 +01:00
Brad Humphrey
2361f8f9d3 Use logic when placing non-excluded items 2022-02-01 16:35:18 +01:00
Chris Wilson
feba54d5d2 Fix filename for Super Mario 64 info page 2022-01-31 18:39:17 -05:00
Brad Humphrey
3cecab25c7 Add unplaced_items into the fill sweep 2022-01-31 19:17:06 +01:00
Brad Humphrey
814851ba60 Don't require every item to fill 2022-01-31 19:17:06 +01:00
Fabian Dill
6333cc3bea Server: optimize send_multiple 2022-01-31 19:05:00 +01:00
N00byKing
00bf9c569a Add send_multiple command 2022-01-31 18:56:46 +01:00
Jarno
6def1bce25 [Docs] Made LocationInfoPacket more specific 2022-01-31 18:55:20 +01:00
Jarno Westhof
3ab5c90d7c [Docs] updated description on player property of NetworkItem 2022-01-31 18:55:20 +01:00
N00byKing
0507d6923e sm64ex: Add Option to limit stars, replace with junk 2022-01-31 18:54:54 +01:00
N00byKing
e85baa8068 sm64ex: Link to release page 2022-01-31 10:57:57 +01:00
N00byKing
cbed5a0c14 sm64ex,v6: Add Note regarding spaces in arguments 2022-01-31 10:57:43 +01:00
Fabian Dill
e0628ec6c9 WebHost: correct some texts 2022-01-31 10:11:39 +01:00
Chris Wilson
82637ff072 [WebHost] Add version notice to /generate and /uploads 2022-01-30 20:06:03 -05:00
Chris Wilson
a95a18a8b5 [WebHost] weighted-settings: Add cursor hover to user-message 2022-01-30 16:53:53 -05:00
Chris Wilson
d36637ed13 Fix a bug causing the /weighted-settings page to fail to detect a change in the source JSON file 2022-01-30 16:50:04 -05:00
N00byKing
dd5e5dcda7 v6: Add missing info regarding Server Port 2022-01-30 18:49:39 +01:00
Jarno Westhof
0ff7fe8479 [Generation] Fixed creation of new Slot-Info 2022-01-30 17:09:10 +01:00
Fabian Dill
8c638bcfd8 Server: allow LocationScouts to create free hints 2022-01-30 14:14:49 +01:00
Fabian Dill
0bd252e7f5 Server: add slot_info key to Connected 2022-01-30 13:57:12 +01:00
Jarno Westhof
ddd3073132 [Docs] Fixed typo 2022-01-30 13:52:51 +01:00
N00byKing
1788422abc v6: Link to release instead of actions 2022-01-30 10:58:48 +01:00
Fabian Dill
6210630ce2 Core: increment version 2022-01-30 03:45:21 +01:00
Fabian Dill
e5af7d11cc Tests: add ID range checks 2022-01-29 16:10:42 +01:00
Fabian Dill
5777808aa9 git: cleanup gitignore, as a bunch of files/folders no longer exist in AP 2022-01-29 15:39:14 +01:00
Fabian Dill
a97e6833a3 LttP: point guide to snes9x rr which is open source, rather than someone's google drive 2022-01-29 15:38:26 +01:00
Chris Wilson
79408ba0c4 Add *.nes to .gitignore 2022-01-29 00:15:24 -05:00
Fabian Dill
25dd89ed17 MultiServer: delete unused function 2022-01-28 09:29:29 +01:00
Brad Humphrey
dd61d0d395 Don't swap items that reduce access (#247) 2022-01-28 05:40:08 +01:00
Brad Humphrey
65a92746d1 Sort before distribute to preserve seed integrity 2022-01-28 05:39:34 +01:00
N00byKing
695e87689c sm64ex: More name changes 2022-01-28 05:38:41 +01:00
Hussein Farran
8997e786da Merge pull request #242 from N00byKing/patch-2
sm64ex: Clarify Instructions
2022-01-27 13:36:18 -05:00
Fabian Dill
239f1afbbd SM64: increment data version to trigger new names to be downloaded to clients 2022-01-27 15:36:14 +01:00
N00byKing
df09b5baac sm64ex: Rename some Items and Locations according to feedback 2022-01-27 15:35:28 +01:00
Yussur Mustafa Oraji
de4aa78fd6 sm64ex: Clarify Instructions 2022-01-27 14:42:49 +01:00
Zach Parks
8175d4c31f Rogue Legacy: Update world definition for 0.8 changes. (#236)
"
Here's a list of compiled changes for the next major release of the Rogue Legacy Randomizer.

Ability to toggle (or randomize) whether you fight vanilla bosses or challenge bosses.
Ability to change Architect settings (start with unlocked, normal, or disabled)
Ability to adjust Architect's fees.
Ability to combine chests or fairy chests into a single pool, similar to Risk of Rain 2's implementation.
Ability to change blueprints acquirement to being progressive instead of random.
Added additional classes that the player can start with and removed all other classes unlocked except for starting class. You must find them!
Ability to tweak the item pool amounts for stat ups.
Ability to define additional character names.
Ability to get a new diary check every time you enter a newly generated castle.
"
2022-01-26 23:35:56 +01:00
Jarno Westhof
2694bd37ea [Docs] Extended info about bounced packets 2022-01-26 23:29:18 +01:00
N00byKing
954d2e64ef v6: Mitigate Generation Problems 2022-01-26 23:28:03 +01:00
CaitSith2
c2be70b61d Death Link during a crystal/pendant cutscene no longer softlocks while connected. 2022-01-26 07:54:09 -08:00
alwaysintreble
d701a7b04e LTTP: update playerSettings.yaml 2022-01-26 10:02:40 +01:00
Chris Wilson
2925aa6261 Fix incorrect game reference. 2022-01-25 22:03:28 -05:00
Chris Wilson
4ebd43104c Include mention of SNC in Super Metroid setup guide 2022-01-25 21:56:31 -05:00
Hussein Farran
2ebe8d0ed4 Increment RoR2 Data Version 2022-01-25 13:00:11 -05:00
Hussein Farran
b26bce8fde Merge pull request #228 from Mathx2/main
Increase Location count and Item pool count
2022-01-25 10:50:21 -05:00
N00byKing
dc31ee4f7e sm64ex: Note incompatibility with spaces in path 2022-01-25 09:51:25 +01:00
Fabian Dill
1b3b0f199d Generate: improve duplicate key feedback by providing duplicate text, line and column 2022-01-25 04:20:08 +01:00
Fabian Dill
0800cfccb6 CommonClient: fix color related crashes in --nogui mode 2022-01-25 02:25:20 +01:00
Chris Wilson
ea0ff6cbf7 [WebHost] Fix /templates page referencing the wrong directory 2022-01-24 19:11:44 -05:00
Mathx2
341fefda01 Convert revives to a percent of total locations 2022-01-24 14:43:42 -08:00
Mathx2
8550c071a2 Revert Max revives
Set the max revives back to 10 and start to convert it to a percentage of the total locations instead of a static value.
2022-01-24 14:40:51 -08:00
CaitSith2
6b1c555d38 Fix inconsistency in parameter name now that !hint only hints items, not locations. 2022-01-23 22:09:06 -08:00
Brad Humphrey
64ce90d5ca Don't add more locations to the priority fill pool 2022-01-24 06:48:59 +01:00
Fabian Dill
415526d23e Fill: remove warning loggers that confused people 2022-01-24 04:50:49 +01:00
Mathx2
b2ebb65c26 Set Max Weights back to 100
Increasing max weight to 500 for fine tuning was overkill.
Max Locations still set to 500
Max Revives still set to 10% of Max Locations
2022-01-23 16:13:07 -08:00
Fabian Dill
7a7e3544cf Fill: log per-player item and location counts in case of mismatch. 2022-01-24 00:18:00 +01:00
Fabian Dill
9fbc7470c1 Clients: fix incorrect log entry height, by overriding correct height every 30 milliseconds 2022-01-23 23:31:49 +01:00
Yussur Mustafa Oraji
056b38fd2a sm64ex: Remove placeholder text 2022-01-23 21:47:26 +01:00
Yussur Mustafa Oraji
23211dd1ee Add Super Mario 64 (PC Port) to Archipelago (#207)
* Add Super Mario 64
2022-01-23 21:34:30 +01:00
Grrmo
b4ad0ebf52 Created new region for kitty boss (#233)
* Added own region for kitty boss

Kitty boss had the same access restriction as upper lake desolation, which is wrong.
2022-01-23 21:26:32 +01:00
N00byKing
0ee6dd3f77 V6: Raise DoorCost Max to 5 2022-01-23 21:24:46 +01:00
N00byKing
70a422d354 V6: Fix broken Generation for Location "V" 2022-01-23 21:24:46 +01:00
Yussur Mustafa Oraji
9d7975ce33 Update Rules.py 2022-01-23 21:24:46 +01:00
Mathx2
9b5a1bedc0 Increase amount of items allowed in the pool
Multiplied max for all items and revives by 5
2022-01-22 22:52:02 -08:00
Mathx2
1518168843 Increase max number of locations
Updated from 100 to 500
2022-01-22 22:48:20 -08:00
black-sliver
f0cfe30a36 Move remote_items and _start_inventory from world to client (#227) 2022-01-23 06:38:46 +01:00
Alchav
219bcb3521 Item Plando updates (#226)
* Item Plando updates

Add True option for item count to place the number of that item that is in the item pool.
Prioritize plando blocks by location count minus item count, so that the least flexible blocks are handled first to increase likelihood of success.
True and False for Force option are coming in as bools instead of strings, so that had to be accounted for.
Several other bug fixes.
2022-01-22 21:03:13 +01:00
Fabian Dill
c7e87bc16a Setup: add setup specific requirements 2022-01-22 20:35:30 +01:00
Fabian Dill
66c15c8639 fix MultiTracker 2022-01-22 05:19:33 +01:00
Brad Humphrey
00ccecac9c Allow fill_hook to remove things from the pool 2022-01-22 04:40:24 +01:00
black-sliver
102c1fecb6 SoE: allow start_inventory 2022-01-22 04:37:48 +01:00
black-sliver
9d4d92167a SoE: place Wings in Halls NE to avoid softlock 2022-01-22 04:37:48 +01:00
black-sliver
e7fde3bacb SoE: Update to pyevermizer v0.41.0
* invers meaning of two flags
* fixes some softlocks
* see see https://github.com/black-sliver/pyevermizer/releases/tag/v0.41.0
2022-01-22 04:37:48 +01:00
Chris Wilson
c0fe9c179c Add LICENSE files to directories containing assets owned by Archipelago 2022-01-21 22:17:29 -05:00
Jarno Westhof
929c684977 [Bug] fixed collect 2022-01-21 23:13:14 +01:00
Henrique Gemignani Passos Lima
02e776bfe5 Run tests in Python 3.8 and 3.9 2022-01-21 23:12:42 +01:00
black-sliver
0c46cc6843 Add per-client remote_item settings + TextOnly Tag
* Tracker tag will receive all items via server (including local)
* TextOnly tag will receive no items
* TextClient sends TextOnly tag
* precollected items / start_inventory does not get an "Order received" number anymore
* local items do always get an "Order received" number now
* multisave changed, includes version number now, upgrade works for games (not trackers)
2022-01-21 22:42:59 +01:00
Yussur Mustafa Oraji
344f4afdbd Add VVVVVV to Archipelago (#178) 2022-01-21 22:42:11 +01:00
Sunny Bat
4291912577 Add Raft to Archipelago (#174) 2022-01-21 22:41:53 +01:00
Fabian Dill
1e5c4c9b7c Setup: pass used python version into windows installer automatically 2022-01-21 08:39:42 +01:00
Fabian Dill
06ec72a064 Fill: fix for crash when locations are prefilled 2022-01-21 05:04:02 +01:00
Fabian Dill
31a823bc34 Change remaining flags to 0b notation by popularity vote 2022-01-21 00:42:45 +01:00
Alchav
dc6f1c4dd2 Item Plando overhaul (#205) 2022-01-20 19:34:17 +01:00
Jarno Westhof
fc8e3d1787 [Timespinner] Added Talaria Attachment to tracker if QuickSeed is enabled
Added new locations ids to tracker
Added new chest & logic for Ancient pyramid
Made tracker change available locations based on flags
Made tracker only show items that are progression based on selected flags
2022-01-20 04:25:16 +01:00
Jarno Westhof
8a25471fbb [Tracker] Fix bug reported by Grrmo, introduced with my change to multi world location data 2022-01-20 04:24:13 +01:00
Robinde67
ad06d9bb4a Adjuster fixes and added GUI prompt for applying last settings (#173) 2022-01-20 04:19:58 +01:00
Brad Humphrey
ec95ce8329 Allow locations to be prioritized for progress item placement (#189) 2022-01-20 04:19:07 +01:00
Fabian Dill
ab4fb6e69c WebHost: fix /api/room_info 2022-01-19 18:51:26 +01:00
Chris Wilson
238e2d0280 [WebHost] player-settings: Cross-port the name validation from weighted-settings to help ensure people enter a name on their settings file 2022-01-19 00:54:14 -05:00
Chris Wilson
2c8a581923 Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into main 2022-01-19 00:38:29 -05:00
Chris Wilson
e878d7d439 [WebHost] player-settings: Default invalid player names to "Player{player}" instead of "noname" 2022-01-19 00:38:17 -05:00
Fabian Dill
4f12660961 Requirements: let's keep whitespace style consistent 2022-01-19 06:15:07 +01:00
Fabian Dill
80a7e4175b MultiServer: update FuzzyWuzzy to TheFuzz 2022-01-19 06:14:13 +01:00
Fabian Dill
b4f17e67d0 Generate: disallow duplicate mapping keys in input files 2022-01-19 04:26:25 +01:00
Jarno Westhof
5df4d2f2fd [Docs] Specified NetworkItem player is about the player slot of the location, not who the item is intended for (#217) 2022-01-18 19:01:51 +01:00
Hussein Farran
ffc7715f1b Merge pull request #204 from Lincoded/patch-1
Increase contrast of SM tracker
2022-01-18 09:32:44 -05:00
Fabian Dill
a6cca3094d WebHost: give proper incompatible version error message.. in the future when this is deployed for next time. 2022-01-18 08:23:38 +01:00
Fabian Dill
b82e0749b7 Network Docs: should put the bits in the right spot 2022-01-18 06:51:16 +01:00
Fabian Dill
5c1d2b3393 Network: unify flags docs and implementation 2022-01-18 06:45:09 +01:00
vgZerst
4841926f83 Note evolution trap scaling in Options docstring 2022-01-18 06:22:00 +01:00
vgZerst
eebf1a5126 Attenuate evolution trap increases
Attenuate evolution trap increases based on game's current evolution_factor to improve difficulty slider scaling. See drive.google.com/file/d/1RBBZV3XRmvgwOTXJhr6aQJIaTatJc2WF
2022-01-18 06:22:00 +01:00
Fabian Dill
028207022a Factorio: support new colors in-game
Various: cleanup and comments
2022-01-18 06:16:16 +01:00
Jarno Westhof
c9fa49d40f [Network_Item] Add item flags to network item so client can distinct some details (#210) 2022-01-18 05:52:29 +01:00
Hussein Farran
5d356d509c Merge pull request #159 from ArchipelagoMW/docs_consolidation
Docs Consolidation
2022-01-17 17:43:30 -05:00
Grrmo
22b361c281 Fixed broken locations in Timespinner (#213)
* Fixed mixed up locations for Aelana's chest and pedestal.
Can provide screenshots for proof.

* Fixed mixed up locations for Upper Lake Desolation double jump cave floor and platform.
Can provide screenshots for proof.

* Fixed up mixed locations for:
Aelana's chest and pedestal
Upper desolation double jump cave platform and floor
upper sealedcave after sirends chest 1 and chest 2

* Updated data version from 6 to 7
2022-01-17 23:15:04 +01:00
Hussein Farran
38b98a97d1 Merge from main and reformat tutorials. 2022-01-17 15:37:34 -05:00
Hussein Farran
9599f54b06 Merge branch 'main' into docs_consolidation
# Conflicts:
#	WebHostLib/static/assets/tutorial/archipelago/advanced_settings_en.md
#	WebHostLib/static/assets/tutorial/archipelago/plando_en.md
#	WebHostLib/static/assets/tutorial/archipelago/triggers_en.md
#	WebHostLib/static/assets/tutorial/timespinner/setup_en.md
2022-01-17 15:37:03 -05:00
Fabian Dill
e74333cbd3 MultiServer: remove location hinting from !hint and /hint; add /hint_location 2022-01-16 02:20:37 +01:00
Alchav
6a7e1d920a User-specified random range (#203)
* Add user-specified random range for yaml options
2022-01-16 01:59:40 +01:00
Fabian Dill
0dc714f947 Options: fix verify_keys breaking options containing lists of dicts 2022-01-15 21:20:34 +01:00
espeon65536
62391d3074 Minecraft tracker: replace bed image url, remove game-complete indicator 2022-01-15 21:16:08 +01:00
Grrmo
c507efd920 Corrected mistake in Regions 2022-01-15 21:15:50 +01:00
espeon65536
6641d428a2 oot: check item name for skip child zelda, not the actual item itself 2022-01-15 21:15:28 +01:00
Fabian Dill
b8afc27e2f Docs: improve "sending_visible" comment 2022-01-14 19:27:54 +01:00
Hussein Farran
d577428ac8 Merge pull request #206 from alwaysintreble/tutorials
add requirements mention to plando tutorial
2022-01-14 11:01:29 -05:00
alwaysintreble
fba8019f98 add requirements mention to plando tutorial 2022-01-13 19:00:29 -06:00
Hussein Farran
6f922ac3ac Merge pull request #202 from alwaysintreble/tutorials
Add a new multi trigger example and explain use of "imaginary" options
2022-01-13 09:16:01 -05:00
Fabian Dill
44cf8efc06 WebHost: count non-owned Rooms of a given Seed 2022-01-13 07:41:31 +01:00
Fabian Dill
1990b893e5 WebHost: fix /api/get_rooms and /api/get_seeds 2022-01-13 07:40:26 +01:00
Chris Wilson
684bb736bc [WebHost] weighted-settings: Include new option types when creating the default settings 2022-01-11 18:06:22 -05:00
Chris Wilson
01d6735803 [WebHost] weighted-settings: Accept new options in switch for option type 2022-01-11 18:00:03 -05:00
Chris Wilson
4e674e0380 [WebHost] weighted-settings: Add items-list, locations-list, and custom-list to JSON config file 2022-01-11 17:36:33 -05:00
Fabian Dill
3acd966241 Options: add "VerifyKeys" Mixin and showcase it for OoT Logic Tricks 2022-01-11 22:01:54 +01:00
Chris Wilson
ee190601ee [WebHost] weighted-settings: Minor style fixes 2022-01-11 04:33:27 -05:00
Chris Wilson
240d1423a3 [WebHost] weighted-settings: Fix start_inventory using the wrong data type 2022-01-11 04:20:33 -05:00
Lincoded
e36f6d25b8 Increase contrast of SM tracker
Improve accessibility by changing text to white and page background to black.

Original contrast ratio was 3.88, and new contrast ratio is 5.4
2022-01-11 00:48:27 -08:00
Chris Wilson
9339019308 [WebHost] weighted-settings: Fix a bug in game choice validation 2022-01-11 02:26:11 -05:00
Chris Wilson
9f5a2d1eb3 [WebHost] weighted-settings: Validate settings before allowing game generation or export 2022-01-11 02:01:31 -05:00
Chris Wilson
a0ade9ea31 [WebHost] weighted-settings: Added basic validation before export 2022-01-11 01:56:14 -05:00
Chris Wilson
71c2db0829 [WebHost] weighted-settings: Improved link to /user-content 2022-01-11 01:36:06 -05:00
Chris Wilson
f33a15dc4e [WebHost] weighted-settings: Added a brief description of what a weighted setting is at the top of the page. 2022-01-11 01:34:48 -05:00
Chris Wilson
c330f4a35e [WebHost] weighted-settings: Implement item and location hints 2022-01-11 01:26:12 -05:00
Chris Wilson
fe25c9c483 Improve styling on weighted-settings 2022-01-10 23:20:15 -05:00
Chris Wilson
f6fcff6a73 Fix a typo on the player-settings page 2022-01-10 22:15:24 -05:00
Chris Wilson
d1146b4fbc Add weighted-settings link to player-settings 2022-01-10 22:08:06 -05:00
Grrmo
9be4a91028 Added German Tutorial for Timespinner 2022-01-10 22:08:25 +01:00
Grrmo
6c3a4b8ffc Added German translation for Timespinner (#200)
* German translation of the setup guide
2022-01-10 22:08:15 +01:00
alwaysintreble
faabcd8cb7 remove a double paste that somehow showed up? 2022-01-09 18:05:57 -06:00
alwaysintreble
fc7319564e properly credit @Black-Sliver for his multi trigger 2022-01-09 16:46:51 -06:00
Fabian Dill
061de66397 MultiServer: don't mark a slot as having Activity if a location check was done through Collect 2022-01-09 23:15:50 +01:00
Hussein Farran
55f21e077a Merge pull request #199 from Grrmo/patch-2
Corrected typos and wrong information
2022-01-09 15:22:28 -05:00
alwaysintreble
821f98eb46 Add a new multi trigger example and explain use of "imaginary" options 2022-01-09 14:13:00 -06:00
Hussein Farran
3ca8164326 WebHost: Address PR feedback and run another reformat. 2022-01-09 15:12:36 -05:00
Hussein Farran
88ce841bf6 WebHost: Make links to game settings less redundant in gameinfo pages.
Reformat all tutorial pages using PyCharm reformat.
2022-01-09 14:57:00 -05:00
Hussein Farran
b94d401d09 Merge branch 'main' into docs_consolidation 2022-01-09 14:50:35 -05:00
Hussein Farran
1c3b25d026 Merge pull request #197 from ThePhar/slay-the-spire-faq
WebHost: Wrote basic setup and info guide for Slay the Spire
2022-01-09 14:48:31 -05:00
Grrmo
84ec3d5353 Corrected typos and wrong information
- Game executable names for Linux and Mac were wrong
- Fixed some typos and changed grammar and semantics in some places
2022-01-09 14:47:28 +01:00
Fabian Dill
bde58fb677 LttP: remove "bonus" small key hyrule castle in case of standard + own_dungeons 2022-01-09 04:48:31 +01:00
Fabian Dill
651e22b14a LttP: keep Small Key Hyrule Castle local even if keyshuffle is wished. 2022-01-09 04:32:25 +01:00
Chris Wilson
111b7e204f [WebHost] weighted-settings: Remove debug output 2022-01-08 20:34:19 -05:00
Chris Wilson
9ff3791d9e [WebHost] weighted-settings: Implement Item Pool settings 2022-01-08 19:59:35 -05:00
Chris Wilson
7380df0256 [WebHost] weighted-settings: Add Item Management section, currently non-functional 2022-01-08 16:59:39 -05:00
Fabian Dill
7e32fa1311 WebHost: fix uploading .archipelago files 2022-01-08 21:21:29 +01:00
Zach Parks
0472147e9a Forgot to update link 2022-01-08 19:57:34 +00:00
Zach Parks
68f282ee83 Slay the Spire: Removed redundant sentence 2022-01-08 19:54:53 +00:00
Zach Parks
4909479c42 Slay the Spire: Wrote a basic set-up guide and info guide for StS 2022-01-08 13:49:58 -06:00
Fabian Dill
82e180cca8 WebHost: mark slot counts as exact, now that an entry for each slot is created in DB 2022-01-08 17:11:39 +01:00
Scipio Wright
1964547eb3 Minor fix for OoT game info
Changed Ocarina of Time to Zelda's Letter since that's what other world items look like here.
2022-01-06 06:28:58 +01:00
Scipio Wright
bce63b0dab Update Super Metroid setup tutorial (#188)
* Update Super Metroid setup tutorial

Setup no longer requires Super Metroid Client, and in fact it gives you an error if you use it. Removed references to it and updated step 5 in the snes9x Multitroid and Bizhawk sections.
2022-01-05 16:58:49 +01:00
Hussein Farran
5ca0b6b18e WebHost: Expand remote commands list. 2022-01-04 18:43:01 -05:00
Hussein Farran
19c0508b83 WebHost: Sned out a typo fix. 2022-01-04 18:36:04 -05:00
Hussein Farran
1891c95ae3 WebHost: Fix up more links and expand commands list. 2022-01-04 18:34:00 -05:00
Hussein Farran
a722ec1c37 Merge branch 'main' into docs_consolidation
# Conflicts:
#	WebHostLib/static/assets/tutorial/timespinner/setup_en.md
2022-01-04 17:20:13 -05:00
Hussein Farran
1ff5908a4c Merge branch 'main' into docs_consolidation
# Conflicts:
#	WebHostLib/static/assets/tutorial/archipelago/plando_en.md
#	WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md
2021-12-31 14:30:59 -05:00
Hussein Farran
e2f61636cc WebHost: Undo all softwrapping changes because people don't like it. Fair enough! 2021-12-31 14:12:22 -05:00
Hussein Farran
3e16593bb7 WebHost: Wrap SoE guide at 120 chars at request of black-sliver. 2021-12-27 16:08:14 -05:00
Hussein Farran
a864b893b8 WebHost: Newlines must die. 2021-12-19 23:29:04 -05:00
Hussein Farran
9212505243 WebHost: Remove newline from FAQ. 2021-12-19 23:17:25 -05:00
Hussein Farran
abbcb6dc72 WebHost: Remove links to any MSU pack downloads or pages. 2021-12-19 23:06:40 -05:00
Hussein Farran
3f49c169bb WebHost: Remove newlines and rework hyperlinks in Z5 guide. 2021-12-19 23:01:18 -05:00
Hussein Farran
16c8256f0b WebHost: Undo a negative consequence of merging from Main 2021-12-19 22:54:45 -05:00
Hussein Farran
75d94b04aa Merge branch 'main' into docs_consolidation
# Conflicts:
#	WebHostLib/static/assets/gameInfo/en_Secret of Evermore.md
#	WebHostLib/static/assets/tutorial/archipelago/advanced_settings_en.md
#	WebHostLib/static/assets/tutorial/minecraft/minecraft_en.md
2021-12-19 22:53:06 -05:00
Hussein Farran
b9c2e7636c WebHost: Continue hyperlink fixes and consolidate website usage info to website user guide. 2021-12-19 22:41:05 -05:00
Hussein Farran
df29934968 WebHost: Fix hyperlink accessibility in Factorio guide. 2021-12-19 21:21:16 -05:00
Hussein Farran
a8694cfb79 WebHost: Fix hyperlink accessibility in general AP guides. 2021-12-02 21:00:06 -05:00
Hussein Farran
0968730382 WebHost: Continue my hyperlink redemption arc. 2021-12-02 20:42:17 -05:00
Hussein Farran
71e5348cbb WebHost: I have not been doing hyperlinks in an accessible fashion. I thought I was. I have failed you.
Follow this advice: https://www.imperial.ac.uk/staff/tools-and-reference/web-guide/training-and-events/materials/accessibility/links/
2021-12-02 20:39:24 -05:00
Hussein Farran
5b399bff89 WebHost: Update English Timespinner documentation. 2021-12-02 20:25:19 -05:00
Hussein Farran
b7ff5d9a57 WebHost: Update English Super Metroid documentation. 2021-12-02 20:22:18 -05:00
Hussein Farran
bfa4d06ecf WebHost: Update English Subnautica documentation. 2021-12-02 20:16:38 -05:00
Hussein Farran
d45bbb89b9 WebHost: Update English SoE documentation. 2021-12-02 20:14:53 -05:00
Hussein Farran
40805ee870 WebHost: Update English Minecraft documentation. 2021-12-02 20:04:44 -05:00
Hussein Farran
385f41d461 WebHost: Draft of commands documentation. 2021-12-02 19:53:58 -05:00
Hussein Farran
52d8da16f6 WebHost: Alter FF1, Factorio, and Archipelago setup guides to consolidate information and remove unnecessary linebreaks. 2021-11-30 20:00:05 -05:00
Hussein Farran
fc210c2d18 WebHost: Make URLs explicit in FAQ and Archipelago category tutorials.
Remove unnecessary newlines and other tweaks.
2021-11-30 19:34:39 -05:00
Hussein Farran
56ef918a10 WebHost: Remove unnecessary linebreaks and reformat links in game info pages. 2021-11-30 19:09:18 -05:00
Hussein Farran
ac02019930 WebHost: Begin removing unnecessary line breaks. 2021-11-29 22:34:28 -05:00
Hussein Farran
5121b0d09b WebHost: Edit Archipelago category guides and enabled two-spaced nested lists. 2021-11-29 22:26:08 -05:00
574 changed files with 52713 additions and 21801 deletions

98
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
# This workflow will build a release-like distribution when manually dispatched
name: Build
on: workflow_dispatch
jobs:
# build-release-macos: # LF volunteer
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v3
with:
python-version: '3.8'
- name: Download run-time dependencies
run: |
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/7.0.1/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
python -m pip install --upgrade pip setuptools
pip install -r requirements.txt
python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z
uses: actions/upload-artifact@v2
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu1804:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
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/7.0.1/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
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
- name: Store AppImage
uses: actions/upload-artifact@v2
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v2
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
retention-days: 7

84
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
# This workflow will create a release and store builds to it when an x.y.z tag is pushed
name: Release
on:
push:
tags:
- '*.*.*'
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-ubuntu1804:
runs-on: ubuntu-18.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
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/7.0.1/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Add to Release
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -7,20 +7,34 @@ on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python:
- {version: '3.8'}
- {version: '3.9'}
- {version: '3.10'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- python: {version: '3.10'} # current
os: windows-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
python ModuleUpdate.py --yes --force
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |
pytest test

14
.gitignore vendored
View File

@@ -12,6 +12,7 @@
*.sfc
*.z64
*.n64
*.nes
*.wixobj
*.lck
*.db3
@@ -26,14 +27,9 @@ dist
README.html
.vs/
EnemizerCLI/
RaceRom.py
weights/
/MultiMystery/
/Players/
/QUsb2Snes/
/options.yaml
/config.yaml
/uploads/
/logs/
_persistent_storage.yaml
mystery_result_*.yaml
@@ -44,6 +40,10 @@ Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -77,6 +77,7 @@ MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
@@ -151,11 +152,10 @@ dmypy.json
# Cython debug symbols
cython_debug/
Archipelago.zip
#minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
#pyenv
.python-version

View File

@@ -1,17 +1,30 @@
from __future__ import annotations
import copy
from enum import Enum, unique
from enum import unique, IntEnum, IntFlag
import logging
import json
import functools
from collections import OrderedDict, Counter, deque
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
import typing # this can go away when Python 3.8 support is dropped
import secrets
import random
import Options
import Utils
import NetUtils
class Group(TypedDict, total=False):
name: str
game: str
world: auto_world
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
local_items: Set[str]
non_local_items: Set[str]
class MultiWorld():
@@ -23,11 +36,20 @@ class MultiWorld():
dark_room_logic: Dict[int, str]
restrict_dungeon_item_on_boss: Dict[int, bool]
plando_texts: List[Dict[str, str]]
plando_items: List
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, Any]
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
itempool: List[Item]
is_race: bool = False
precollected_items: Dict[int, List[Item]]
state: CollectionState
accessibility: Dict[int, Options.Accessibility]
local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]]
class AttributeProxy():
def __init__(self, rule):
@@ -39,20 +61,21 @@ class MultiWorld():
def __init__(self, players: int):
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
self.players = players
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
self.glitch_triforce = False
self.algorithm = 'balanced'
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
self.groups = {}
self.regions = []
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self.state = CollectionState(self)
self._cached_entrances = None
self._cached_locations = None
self._entrance_cache = {}
self._location_cache = {}
self._location_cache: Dict[Tuple[str, int], Location] = {}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
@@ -63,8 +86,6 @@ class MultiWorld():
self.custom = False
self.customitemarray = []
self.shuffle_ganon = True
self.dynamic_regions = []
self.dynamic_locations = []
self.spoiler = Spoiler(self)
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
@@ -74,7 +95,6 @@ class MultiWorld():
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_trock_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
for player in range(1, players + 1):
def set_player_attr(attr, val):
@@ -130,6 +150,42 @@ class MultiWorld():
self.worlds = {}
self.slot_seeds = {}
def get_all_ids(self):
return self.player_ids + tuple(self.groups)
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
"""Create a group with name and return the assigned player ID and group.
If a group of this name already exists, the set of players is extended instead of creating a new one."""
for group_id, group in self.groups.items():
if group["name"] == name:
group["players"] |= players
return group_id, group
new_id: int = self.players + len(self.groups) + 1
self.game[new_id] = game
self.custom_data[new_id] = {}
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
for option_key, option in world_type.options.items():
getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.common_options.items():
getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.per_game_common_options.items():
getattr(self, option_key)[new_id] = option(option.default)
self.worlds[new_id] = world_type(self, new_id)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.player_name[new_id] = name
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
world=self.worlds[new_id])
return new_id, new_group
def get_player_groups(self, player) -> Set[int]:
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
self.seed = get_seed(seed)
if secure:
@@ -141,24 +197,78 @@ class MultiWorld():
range(1, self.players + 1)}
def set_options(self, args):
from worlds import AutoWorld
for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
for option_key in world_type.options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options:
setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player)
def set_item_links(self):
item_links = {}
for player in self.player_ids:
for item_link in self.item_links[player].value:
if item_link["name"] in item_links:
if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
else:
if item_link["name"] in self.player_name.values():
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
item_links[item_link["name"]] = {
"players": {player: item_link["replacement_item"]},
"item_pool": set(item_link["item_pool"]),
"exclude": set(item_link.get("exclude", [])),
"game": self.game[player],
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", []))
}
for name, item_link in item_links.items():
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set()
local_items = set()
non_local_items = set()
for item in item_link["item_pool"]:
pool |= current_item_name_groups.get(item, {item})
for item in item_link["exclude"]:
pool -= current_item_name_groups.get(item, {item})
for item in item_link["local_items"]:
local_items |= current_item_name_groups.get(item, {item})
for item in item_link["non_local_items"]:
non_local_items |= current_item_name_groups.get(item, {item})
local_items &= pool
non_local_items &= pool
item_link["item_pool"] = pool
item_link["local_items"] = local_items
item_link["non_local_items"] = non_local_items
for group_name, item_link in item_links.items():
game = item_link["game"]
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
group["replacement_items"] = item_link["players"]
group["local_items"] = item_link["local_items"]
group["non_local_items"] = item_link["non_local_items"]
# intended for unittests
def set_default_common_options(self):
for option_key, option in Options.common_options.items():
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
for option_key, option in Options.per_game_common_options.items():
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
self.state = CollectionState(self)
def secure(self):
self.random = secrets.SystemRandom()
@@ -174,7 +284,8 @@ class MultiWorld():
@functools.lru_cache()
def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if self.game[player] == game_name)
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
def get_name_string_for_object(self, obj) -> str:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
@@ -182,6 +293,9 @@ class MultiWorld():
def get_player_name(self, player: int) -> str:
return self.player_name[player]
def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
def initialize_regions(self, regions=None):
for region in regions if regions else self.regions:
region.world = self
@@ -193,6 +307,7 @@ class MultiWorld():
def _recache(self):
"""Rebuild world cache"""
self._cached_locations = None
for region in self.regions:
player = region.player
self._region_cache[player][region.name] = region
@@ -241,10 +356,9 @@ class MultiWorld():
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
from worlds.alttp.Dungeons import get_dungeon_item_pool
for item in get_dungeon_item_pool(self):
subworld = self.worlds[item.player]
if item.name in subworld.dungeon_local_item_names:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_events()
@@ -252,7 +366,7 @@ class MultiWorld():
self._all_state = ret
return ret
def get_items(self) -> list:
def get_items(self) -> List[Item]:
return [loc.item for loc in self.get_filled_locations()] + self.itempool
def find_item_locations(self, item, player: int) -> List[Location]:
@@ -307,7 +421,7 @@ class MultiWorld():
def clear_location_cache(self):
self._cached_locations = None
def get_unfilled_locations(self, player=None) -> List[Location]:
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
if player is not None:
return [location for location in self.get_locations() if
location.player == player and not location.item]
@@ -316,13 +430,13 @@ class MultiWorld():
def get_unfilled_dungeon_locations(self):
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
def get_filled_locations(self, player=None) -> List[Location]:
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
if player is not None:
return [location for location in self.get_locations() if
location.player == player and location.item is not None]
return [location for location in self.get_locations() if location.item is not None]
def get_reachable_locations(self, state=None, player=None) -> List[Location]:
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
if state is None:
state = self.state
return [location for location in self.get_locations() if
@@ -334,13 +448,16 @@ class MultiWorld():
return [location for location in self.get_locations() if
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
def get_unfilled_locations_for_players(self, location_name: str, players: Iterable[int]):
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
for player in players:
location = self.get_location(location_name, player)
if location.item is None:
yield location
if len(locations) == 0:
locations = [location.name for location in self.get_unfilled_locations(player)]
for location_name in locations:
location = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None:
yield location
def unlocks_new_location(self, item) -> bool:
def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy()
temp_state.collect(item, True)
@@ -350,7 +467,7 @@ class MultiWorld():
return False
def has_beaten_game(self, state, player: Optional[int] = None):
def has_beaten_game(self, state: CollectionState, player: Optional[int] = None) -> bool:
if player:
return self.completion_condition[player](state)
else:
@@ -429,8 +546,9 @@ class MultiWorld():
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
if location.player in players["locations"] or location.event or \
(location.item and location.item.advancement):
if location.progress_type != LocationProgressType.EXCLUDED \
and (location.player in players["locations"] or location.event
or (location.item and location.item.advancement)):
return True
return False
@@ -468,17 +586,32 @@ class MultiWorld():
return False
class CollectionState(object):
PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState():
prog_items: typing.Counter[Tuple[str, int]]
world: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
events: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
stale: Dict[int, bool]
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld):
self.prog_items = Counter()
self.world = parent
self.reachable_regions = {player: set() for player in range(1, parent.players + 1)}
self.blocked_connections = {player: set() for player in range(1, parent.players + 1)}
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
self.events = set()
self.path = {}
self.locations_checked = set()
self.stale = {player: True for player in range(1, parent.players + 1)}
self.stale = {player: True for player in parent.get_all_ids()}
for function in self.additional_init_functions:
function(self, parent)
for items in parent.precollected_items.values():
for item in items:
self.collect(item, True)
@@ -504,6 +637,7 @@ class CollectionState(object):
if new_region in rrp:
bc.remove(connection)
elif connection.can_reach(self):
assert new_region, "tried to search through an Entrance with no Region"
rrp.add(new_region)
bc.remove(connection)
bc.update(new_region.exits)
@@ -520,16 +654,22 @@ class CollectionState(object):
ret = CollectionState(self.world)
ret.prog_items = self.prog_items.copy()
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
range(1, self.world.players + 1)}
self.reachable_regions}
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
range(1, self.world.players + 1)}
self.blocked_connections}
ret.events = copy.copy(self.events)
ret.path = copy.copy(self.path)
ret.locations_checked = copy.copy(self.locations_checked)
for function in self.additional_copy_functions:
ret = function(self, ret)
return ret
def can_reach(self, spot, resolution_hint=None, player=None) -> bool:
if not hasattr(spot, "spot_type"):
def can_reach(self,
spot: Union[Location, Entrance, Region, str],
resolution_hint: Optional[str] = None,
player: Optional[int] = None) -> bool:
if isinstance(spot, str):
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
spot = self.world.get_location(spot, player)
@@ -540,31 +680,34 @@ class CollectionState(object):
spot = self.world.get_region(spot, player)
return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations=None):
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.world.get_filled_locations()
new_locations = True
# since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.event}
locations = {location for location in locations if location.event and
not key_only or getattr(location.item, "locked_dungeon_item", False)}
while new_locations:
reachable_events = {location for location in locations if
(not key_only or getattr(location.item, "locked_dungeon_item", False))
and location.can_reach(self)}
reachable_events = {location for location in locations if location.can_reach(self)}
new_locations = reachable_events - self.events
for event in new_locations:
self.events.add(event)
assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event)
def has(self, item, player: int, count: int = 1):
def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[item, player] >= count
def has_all(self, items: Set[str], player: int):
def has_all(self, items: Set[str], player: int) -> bool:
return all(self.prog_items[item, player] for item in items)
def has_any(self, items: Set[str], player: int):
def has_any(self, items: Set[str], player: int) -> bool:
return any(self.prog_items[item, player] for item in items)
def has_group(self, item_name_group: str, player: int, count: int = 1):
def count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
@@ -572,7 +715,7 @@ class CollectionState(object):
return True
return False
def count_group(self, item_name_group: str, player: int):
def count_group(self, item_name_group: str, player: int) -> int:
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
@@ -638,7 +781,7 @@ class CollectionState(object):
basemagic = basemagic + basemagic * self.bottle_count(player)
return basemagic >= smallmagic
def can_kill_most_things(self, player: int, enemies=5) -> bool:
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
return (self.has_melee_weapon(player)
or self.has('Cane of Somaria', player)
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
@@ -647,7 +790,7 @@ class CollectionState(object):
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)
@@ -713,26 +856,26 @@ class CollectionState(object):
def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.world.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int):
def can_boots_clip_lw(self, player: int) -> bool:
if self.world.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int):
def can_boots_clip_dw(self, player: int) -> bool:
if self.world.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int):
def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_superbunny_mirror_with_sword(self, player: int):
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
return self.has('Magic Mirror', player) and self.has_sword(player)
def can_get_glitched_speed_dw(self, player: int):
def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player))
@@ -741,7 +884,7 @@ class CollectionState(object):
def can_bomb_clip(self, region: Region, player: int) -> bool:
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -768,7 +911,7 @@ class CollectionState(object):
@unique
class RegionType(int, Enum):
class RegionType(IntEnum):
Generic = 0
LightWorld = 1
DarkWorld = 2
@@ -781,19 +924,30 @@ class RegionType(int, Enum):
return self in (RegionType.Cave, RegionType.Dungeon)
class Region(object):
def __init__(self, name: str, type_: RegionType, hint, player: int, world: Optional[MultiWorld] = None):
class Region:
name: str
type: RegionType
hint_text: str
player: int
world: Optional[MultiWorld]
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
dungeon: Optional[Dungeon] = None
shop: Optional = None
# LttP specific. TODO: move to a LttPRegion
# will be set after making connections.
is_light_world: bool = False
is_dark_world: bool = False
def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None):
self.name = name
self.type = type_
self.entrances = []
self.exits = []
self.locations = []
self.dungeon = None
self.shop = None
self.world = world
self.is_light_world = False # will be set after making connections.
self.is_dark_world = False
self.spot_type = 'Region'
self.hint_text = hint
self.player = player
@@ -817,18 +971,21 @@ class Region(object):
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
class Entrance(object):
spot_type = 'Entrance'
class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False
player: int
name: str
parent_region: Optional[Region]
connected_region: Optional[Region] = None
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = '', parent=None):
def __init__(self, player: int, name: str = '', parent: Region = None):
self.name = name
self.parent_region = parent
self.connected_region = None
self.target = None
self.addresses = None
self.access_rule = lambda state: True
self.player = player
self.hide_path = False
def can_reach(self, state: CollectionState) -> bool:
if self.parent_region.can_reach(state) and self.access_rule(state):
@@ -838,7 +995,7 @@ class Entrance(object):
return False
def connect(self, region: Region, addresses=None, target = None):
def connect(self, region: Region, addresses=None, target=None):
self.connected_region = region
self.target = target
self.addresses = addresses
@@ -853,7 +1010,8 @@ class Entrance(object):
class Dungeon(object):
def __init__(self, name: str, regions, big_key, small_keys, dungeon_items, player: int):
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
dungeon_items: List[Item], player: int):
self.name = name
self.regions = regions
self.big_key = big_key
@@ -872,11 +1030,11 @@ class Dungeon(object):
self.bosses[None] = value
@property
def keys(self):
def keys(self) -> List[Item]:
return self.small_keys + ([self.big_key] if self.big_key else [])
@property
def all_items(self):
def all_items(self) -> List[Item]:
return self.dungeon_items + self.keys
def is_dungeon_item(self, item: Item) -> bool:
@@ -895,7 +1053,7 @@ class Dungeon(object):
class Boss():
def __init__(self, name: str, enemizer_name: str, defeat_rule, player: int):
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
self.name = name
self.enemizer_name = enemizer_name
self.defeat_rule = defeat_rule
@@ -908,27 +1066,33 @@ class Boss():
return f"Boss({self.name})"
class Location():
class LocationProgressType(IntEnum):
DEFAULT = 1
PRIORITY = 2
EXCLUDED = 3
class Location:
# If given as integer, then this is the shop's inventory index
shop_slot: Optional[int] = None
shop_slot_disabled: bool = False
event: bool = False
locked: bool = False
spot_type = 'Location'
game: str = "Generic"
show_in_spoiler: bool = True
excluded: bool = False
crystal: bool = False
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda item, state: False)
access_rule = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None
parent_region: Optional[Region]
def __init__(self, player: int, name: str = '', address: int = None, parent=None):
self.name: str = name
self.address: Optional[int] = address
self.parent_region: Region = parent
self.parent_region = parent
self.player: int = player
self.item: Optional[Item] = None
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
@@ -943,6 +1107,7 @@ class Location():
if self.item:
raise Exception(f"Location {self} already filled.")
self.item = item
item.location = self
self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True
@@ -957,7 +1122,7 @@ class Location():
def __hash__(self):
return hash((self.name, self.player))
def __lt__(self, other):
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
@property
@@ -966,24 +1131,36 @@ class Location():
return self.item and self.item.game == self.game
@property
def hint_text(self):
def hint_text(self) -> str:
hint_text = getattr(self, "_hint_text", None)
if hint_text:
return hint_text
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
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"
@@ -998,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
@@ -1012,10 +1189,30 @@ class Item():
def pedestal_hint_text(self):
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
def advancement(self) -> bool:
return ItemClassification.progression in self.classification
@property
def skip_in_prog_balancing(self) -> bool:
return ItemClassification.progression_skip_balancing in self.classification
@property
def useful(self) -> bool:
return ItemClassification.useful in self.classification
@property
def trap(self) -> bool:
return ItemClassification.trap in self.classification
@property
def flags(self) -> int:
return self.classification.as_flag()
def __eq__(self, other):
return self.name == other.name and self.player == other.player
def __lt__(self, other):
def __lt__(self, other: Item):
if other.player != self.player:
return other.player < self.player
return self.name < other.name
@@ -1032,6 +1229,7 @@ class Item():
class Spoiler():
world: MultiWorld
unreachables: Set[Location]
def __init__(self, world):
self.world = world
@@ -1039,19 +1237,19 @@ class Spoiler():
self.entrances = OrderedDict()
self.medallions = {}
self.playthrough = {}
self.unreachables = []
self.unreachables = set()
self.locations = {}
self.paths = {}
self.shops = []
self.bosses = OrderedDict()
def set_entrance(self, entrance, exit, direction, player):
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.world.players == 1:
self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit), ('direction', direction)])
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
else:
self.entrances[(entrance, direction, player)] = OrderedDict(
[('player', player), ('entrance', entrance), ('exit', exit), ('direction', direction)])
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
def parse_data(self):
self.medallions = OrderedDict()
@@ -1180,8 +1378,7 @@ class Spoiler():
return json.dumps(out)
def to_file(self, filename):
from worlds.AutoWorld import call_all, call_single, call_stage
def to_file(self, filename: str):
self.parse_data()
def bool_to_text(variable: Union[bool, str]) -> str:
@@ -1191,9 +1388,9 @@ class Spoiler():
def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.world, option_key)[player]
displayname = getattr(option_obj, "displayname", option_key)
display_name = getattr(option_obj, "display_name", option_key)
try:
outfile.write(f'{displayname + ":":33}{res.get_current_option_name()}\n')
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
except:
raise Exception
@@ -1203,7 +1400,7 @@ class Spoiler():
Utils.__version__, self.world.seed))
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
outfile.write('Players: %d\n' % self.world.players)
call_stage(self.world, "write_spoiler_header", outfile)
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
for player in range(1, self.world.players + 1):
if self.world.players > 1:
@@ -1215,7 +1412,7 @@ class Spoiler():
if options:
for f_option, option in options.items():
write_option(f_option, option)
call_single(self.world, "write_spoiler_header", player, outfile)
AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile)
if player in self.world.get_game_players("A Link to the Past"):
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
@@ -1265,7 +1462,7 @@ class Spoiler():
for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}')
call_all(self.world, "write_spoiler", outfile)
AutoWorld.call_all(self.world, "write_spoiler", outfile)
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
@@ -1306,14 +1503,32 @@ class Spoiler():
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings))
call_all(self.world, "write_spoiler_end", outfile)
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
class Tutorial(NamedTuple):
"""Class to build website tutorial pages from a .md file in the world's /docs folder. Order is as follows.
Name of the tutorial as it will appear on the site. Concise description covering what the guide will entail.
Language the guide is written in. Name of the file ex 'setup_en.md'. Name of the link on the site; game name is
filled automatically so 'setup/en' etc. Author or authors."""
tutorial_name: str
description: str
language: str
file_name: str
link: str
authors: List[str]
seeddigits = 20
def get_seed(seed=None):
def get_seed(seed=None) -> int:
if seed is None:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
return seed
from worlds import AutoWorld
auto_world = AutoWorld.World

155
ChecksFinderClient.py Normal file
View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import os
import asyncio
import ModuleUpdate
ModuleUpdate.update()
import Utils
if __name__ == "__main__":
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
class ChecksFinderClientCommandProcessor(ClientCommandProcessor):
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
self.ctx.syncing = True
class ChecksFinderContext(CommonContext):
command_processor: int = ChecksFinderClientCommandProcessor
game = "ChecksFinder"
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super(ChecksFinderContext, self).__init__(server_address, password)
self.send_index: int = 0
self.syncing = False
self.awaiting_bridge = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(ChecksFinderContext, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
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)
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
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)
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:
f.close()
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()
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()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class ChecksFinderManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago ChecksFinder Client"
self.ui = ChecksFinderManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
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:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
sending = []
victory = False
for root, dirs, files in os.walk(path):
for file in files:
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
if file.find("victory") > -1:
victory = True
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
if __name__ == '__main__':
async def main(args):
ctx = ChecksFinderContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -6,6 +6,9 @@ import sys
import typing
import time
import ModuleUpdate
ModuleUpdate.update()
import websockets
import Utils
@@ -14,13 +17,14 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
import os
logger = logging.getLogger("Client")
# without terminal we have to use gui mode
# without terminal, we have to use gui mode
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@@ -39,12 +43,14 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
self.ctx.username = 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
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True
@@ -52,7 +58,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:
@@ -104,30 +110,67 @@ class ClientCommandProcessor(CommandProcessor):
self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def _cmd_show_all_hints(self):
"""Allows the player to see all hints, not just the ones that apply to them."""
asyncio.create_task(self.ctx.update_show_all_hints("ShowAllHints" not in self.ctx.tags))
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():
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 = None
command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None
keep_alive_task = None
ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None
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.username = None
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",
"collect": "disabled",
@@ -142,26 +185,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.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")
@@ -173,50 +213,25 @@ class CommonContext():
return len(self.checked_locations | self.missing_locations)
async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None:
await self.server.socket.close()
def reset_server_state(self):
self.auth = None
self.slot = None
self.team = 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
# 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
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
async def disconnect(self):
if self.server and not self.server.socket.closed:
@@ -245,11 +260,18 @@ class CommonContext():
self.password = await self.console_input()
return self.password
async def get_username(self):
if not self.auth:
self.auth = self.username
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
async def send_connect(self, **kwargs):
payload = {
"cmd": 'Connect',
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
@@ -264,6 +286,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"])
@@ -293,7 +322,8 @@ class CommonContext():
logger.exception(e)
async def shutdown(self):
self.server_address = None
self.server_address = ""
self.username = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
@@ -303,6 +333,54 @@ class CommonContext():
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
if self.ui_task:
await self.ui_task
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
@@ -316,18 +394,28 @@ class CommonContext():
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
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
}
}])
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):
async def update_show_all_hints(self, show_all_hints: bool):
old_tags = self.tags.copy()
if show_all_hints:
self.tags.add("ShowAllHints")
else:
self.tags -= {"ShowAllHints"}
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 update_death_link(self, death_link: bool):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
@@ -336,6 +424,48 @@ 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
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
self.ui = TextManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self):
if sys.stdin:
# steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
else:
self.input_task = asyncio.create_task(console_loop(self), name="Input")
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
@@ -351,7 +481,6 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
async def server_loop(ctx: CommonContext, address=None):
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
return
@@ -364,12 +493,20 @@ async def server_loop(ctx: CommonContext, address=None):
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
address = f"ws://{address}" if "://" not in address \
else address.replace("archipelago://", "ws://")
server_url = urllib.parse.urlparse(address)
if server_url.username:
ctx.username = server_url.username
if server_url.password:
ctx.password = server_url.password
port = server_url.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.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
@@ -378,18 +515,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:
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 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:
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
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:
@@ -412,7 +553,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:')
@@ -426,33 +569,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('Players:')
for network_player in args['players']:
logger.info('Connected 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"]
@@ -460,10 +602,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
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')
@@ -475,8 +617,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
ctx.username = ctx.auth
ctx.team = args["team"]
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
@@ -514,9 +659,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
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)
for item in [NetworkItem(*item) for item in args['locations']]:
ctx.locations_info[item.location] = item
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
@@ -545,7 +689,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# 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}")
@@ -553,7 +701,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
async def console_loop(ctx: CommonContext):
import sys
commandprocessor = ctx.command_processor(ctx)
queue = asyncio.Queue()
stream_input(sys.stdin, queue)
@@ -587,52 +734,51 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame"}
game = "Archipelago"
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0 # don't receive any NetworkItems
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.get_username()
await self.send_connect()
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):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
input_task = None
if gui_enabled:
from kvui import TextManager
ctx.ui = TextManager(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()
await ctx.exit_event.wait()
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args()
if args.url:
url = urllib.parse.urlparse(args.url)
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
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

@@ -18,6 +18,8 @@ CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
@@ -28,8 +30,18 @@ class FF1CommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
@@ -37,10 +49,8 @@ class FF1Context(CommonContext):
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'Final Fantasy'
self.awaiting_rom = False
command_processor = FF1CommandProcessor
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -53,18 +63,18 @@ class FF1Context(CommonContext):
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
self.messages[(time.time(), msg_id)] = msg
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
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']
@@ -74,23 +84,35 @@ 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)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
@@ -128,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}
@@ -164,6 +186,9 @@ async def nes_sync_task(ctx: FF1Context):
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
@@ -214,18 +239,15 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
input_task = None
from kvui import FF1Manager
ctx.ui = FF1Manager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
@@ -236,20 +258,12 @@ if __name__ == '__main__':
if ctx.nes_sync_task:
await ctx.nes_sync_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
parser = get_base_parser()
args, rest = parser.parse_known_args()
args = parser.parse_args()
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -5,10 +5,12 @@ import json
import string
import copy
import subprocess
import sys
import time
import random
import ModuleUpdate
ModuleUpdate.update()
import factorio_rcon
import colorama
import asyncio
@@ -19,7 +21,7 @@ if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
get_base_parser
get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
@@ -49,6 +51,7 @@ class FactorioCommandProcessor(ClientCommandProcessor):
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
game = "Factorio"
items_handling = 0b111 # full remote
# updated by spinup server
mod_version: Utils.Version = Utils.Version(0, 0, 0)
@@ -61,6 +64,8 @@ class FactorioContext(CommonContext):
self.write_data_path = None
self.death_link_tick: int = 0 # last send death link on Factorio layer
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0
self.last_deplete = 0
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -104,6 +109,34 @@ class FactorioContext(CommonContext):
if "checked_locations" in args and args["checked_locations"]:
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment:
asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"]
}]))
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
# it's our deplete request
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.debug(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
def run_gui(self):
from kvui import GameManager
class FactorioManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
base_title = "Archipelago Factorio Client"
self.ui = FactorioManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def game_watcher(ctx: FactorioContext):
@@ -112,11 +145,14 @@ async def game_watcher(ctx: FactorioContext):
next_bridge = time.perf_counter() + 1
try:
while not ctx.exit_event.is_set():
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
# TODO: restore on-demand refresh
if ctx.rcon_client and time.perf_counter() > next_bridge:
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(
@@ -126,8 +162,7 @@ async def game_watcher(ctx: FactorioContext):
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
await ctx.update_death_link(data["death_link"])
await ctx.update_death_link(data["death_link"])
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -143,7 +178,31 @@ async def game_watcher(ctx: FactorioContext):
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags:
await ctx.send_death()
asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"]
if in_world_bridges:
in_world_energy = data["energy"]
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill
ctx.last_deplete = time.time()
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}],
"last_deplete": ctx.last_deplete
}]))
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}]
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
@@ -232,12 +291,16 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_process.wait(5)
async def get_info(ctx, rcon_client):
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
ctx.auth = info["slot_name"]
ctx.seed_name = info["seed_name"]
# 0.2.0 addition, not present earlier
death_link = bool(info.get("death_link", False))
ctx.energy_link_increment = info.get("energy_link", 0)
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
if ctx.energy_link_increment and ctx.ui:
ctx.ui.enable_energy_link()
await ctx.update_death_link(death_link)
@@ -281,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:
@@ -298,18 +363,14 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
input_task = None
if gui_enabled:
from kvui import FactorioManager
ctx.ui = FactorioManager(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()
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
succesful_launch = await factorio_server_task
if succesful_launch:
successful_launch = await factorio_server_task
if successful_launch:
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="FactorioProgressionWatcher")
@@ -322,23 +383,13 @@ async def main(args):
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
for color in colors:
if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray",
"brown", "cyan", "acid"}:
node["text"] = f"[color={color}]{node['text']}[/color]"
return self._handle_text(node)
elif color == "magenta":
node["text"] = f"[color=pink]{node['text']}[/color]"
if color in self.color_codes:
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
return self._handle_text(node)
return self._handle_text(node)
@@ -372,7 +423,5 @@ if __name__ == '__main__':
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
asyncio.run(main(args))
colorama.deinit()

556
Fill.py
View File

@@ -4,9 +4,8 @@ import collections
import itertools
from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
from BaseClasses import CollectionState, Location, MultiWorld, Item
from worlds.generic import PlandoItem
from worlds.AutoWorld import call_all
@@ -14,7 +13,7 @@ class FillError(RuntimeError):
pass
def sweep_from_pool(base_state: CollectionState, itempool):
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
@@ -22,13 +21,13 @@ def sweep_from_pool(base_state: CollectionState, itempool):
return new_state
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool: typing.List[Item],
single_player_placement=False, lock=False):
unplaced_items = []
placements = []
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items = Counter()
reachable_items: dict[str, deque] = {}
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in itempool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -38,14 +37,17 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
for items in reachable_items.values() if items]
for item in items_to_place:
itempool.remove(item)
maximum_exploration_state = sweep_from_pool(base_state, itempool)
maximum_exploration_state = sweep_from_pool(
base_state, itempool + unplaced_items)
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
for item_to_place in items_to_place:
spot_to_fill: Location = None
spot_to_fill: typing.Optional[Location] = None
if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) if single_player_placement else not has_beaten_game
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -59,68 +61,90 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
else:
# we filled all reachable spots.
# try swaping this item with previously placed items
for(i, location) in enumerate(placements):
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
if swapped_items[placed_item.player, placed_item.name] > 0:
swap_count = swapped_items[placed_item.player,
placed_item.name]
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, itempool)
swap_state = sweep_from_pool(base_state)
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the exisiting placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swapped_items[placed_item.player,
placed_item.name] += 1
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
else:
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill == None:
# Maybe the game can be beaten anyway?
# Verify that placing this item won't reduce available locations
prev_state = swap_state.copy()
prev_state.collect(placed_item)
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
if world.accessibility[item_to_place.player] != 'minimal' and world.can_beat_game():
logging.warning(
f'Not all items placed. Game beatable anyway. (Could not place {item_to_place})')
continue
raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
continue
world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = True
spot_to_fill.event = item_to_place.advancement
if len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
if world.can_beat_game():
logging.warning(
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
else:
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
# If not passed in, then get a shuffled list of locations to fill in
if not fill_locations:
fill_locations = world.get_unfilled_locations()
world.random.shuffle(fill_locations)
def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations)
# get items to distribute
world.random.shuffle(world.itempool)
progitempool = []
nonexcludeditempool = []
localrestitempool = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool = []
restitempool = []
itempool = sorted(world.itempool)
world.random.shuffle(itempool)
progitempool: typing.List[Item] = []
nonexcludeditempool: typing.List[Item] = []
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool: typing.List[Item] = []
restitempool: typing.List[Item] = []
for item in world.itempool:
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)
@@ -129,21 +153,47 @@ def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
else:
restitempool.append(item)
world.random.shuffle(fill_locations)
call_all(world, "fill_hook", progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
fill_restrictive(world, world.state, fill_locations, progitempool)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
for loc in fill_locations:
locations[loc.progress_type].append(loc)
prioritylocations = locations[LocationProgressType.PRIORITY]
defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED]
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
if prioritylocations:
defaultlocations = prioritylocations + defaultlocations
if progitempool:
fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool:
raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
if nonexcludeditempool:
world.random.shuffle(fill_locations)
fill_restrictive(world, world.state, fill_locations, nonexcludeditempool) # needs logical fill to not conflict with local items
world.random.shuffle(defaultlocations)
# needs logical fill to not conflict with local items
fill_restrictive(
world, world.state, defaultlocations, nonexcludeditempool)
if nonexcludeditempool:
raise FillError(
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
defaultlocations = defaultlocations + excludedlocations
world.random.shuffle(defaultlocations)
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
local_locations = {player: [] for player in world.player_ids}
for location in fill_locations:
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
for location in defaultlocations:
local_locations[location.player].append(location)
for locations in local_locations.values():
world.random.shuffle(locations)
for player_locations in local_locations.values():
world.random.shuffle(player_locations)
for player, items in localrestitempool.items(): # items already shuffled
player_local_locations = local_locations[player]
@@ -154,34 +204,45 @@ def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
break
spot_to_fill = player_local_locations.pop()
world.push_item(spot_to_fill, item_to_place, False)
fill_locations.remove(spot_to_fill)
defaultlocations.remove(spot_to_fill)
for item_to_place in nonlocalrestitempool:
for i, location in enumerate(fill_locations):
for i, location in enumerate(defaultlocations):
if location.player != item_to_place.player:
world.push_item(fill_locations.pop(i), item_to_place, False)
world.push_item(defaultlocations.pop(i), item_to_place, False)
break
else:
logging.warning(f"Could not place non_local_item {item_to_place} among {fill_locations}, tossing.")
logging.warning(
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
world.random.shuffle(fill_locations)
world.random.shuffle(defaultlocations)
restitempool, fill_locations = fast_fill(world, restitempool, fill_locations)
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = [location.name for location in fill_locations]
unfilled = defaultlocations
if unplaced or unfilled:
logging.warning(f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
logging.warning(
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
locations_counter = Counter(location.player for location in world.get_locations())
items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f'Per-Player counts: {print_data})')
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world: MultiWorld):
def flood_items(world: MultiWorld) -> None:
# get items to distribute
world.random.shuffle(world.itempool)
itempool = world.itempool
@@ -220,7 +281,8 @@ def flood_items(world: MultiWorld):
item_to_place = item
break
# we might be in a situation where all new locations require multiple items to reach. If that is the case, just place any advancement item we've found and continue trying
# we might be in a situation where all new locations require multiple items to reach.
# If that is the case, just place any advancement item we've found and continue trying
if item_to_place is None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
@@ -241,68 +303,135 @@ def flood_items(world: MultiWorld):
break
def balance_multiworld_progression(world: MultiWorld):
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
def balance_multiworld_progression(world: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: world.progression_balancing[player] / 100
for player in world.player_ids
if world.progression_balancing[player] > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
state = CollectionState(world)
checked_locations = set()
unchecked_locations = set(world.get_locations())
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
reachable_locations_count = {player: 0 for player in world.player_ids}
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if len(world.get_filled_locations(player)) != 0
}
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
if not location.locked
)
balanceable_players = {
player: balanceable_players[player]
for player in balanceable_players
if total_locations_count[player]
}
sphere_num: int = 1
moved_item_count: int = 0
def get_sphere_locations(sphere_state, locations):
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
return num / total_locations_count[player]
while True:
# Gather non-locked locations.
# This ensures that only shuffled locations get counted for progression balancing,
# i.e. the items the players will be checking.
sphere_locations = get_sphere_locations(state, unchecked_locations)
for location in sphere_locations:
unchecked_locations.remove(location)
reachable_locations_count[location.player] += 1
if not location.locked:
reachable_locations_count[location.player] += 1
logging.debug(f"Sphere {sphere_num}")
logging.debug(f"Reachable locations: {reachable_locations_count}")
debug_percentages = {
player: round(item_percentage(player, num), 2)
for player, num in reachable_locations_count.items()
}
logging.debug(f"Reachable percentages: {debug_percentages}\n")
sphere_num += 1
if checked_locations:
threshold = max(reachable_locations_count.values()) - 20
balancing_players = {player for player, reachables in reachable_locations_count.items() if
reachables < threshold and player in balanceable_players}
max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
reachable_locations_count))
threshold_percentages = {
player: max_percentage * balanceable_players[player]
for player in balanceable_players
}
logging.debug(f"Thresholds: {threshold_percentages}")
balancing_players = {
player
for player, reachables in reachable_locations_count.items()
if (player in threshold_percentages
and item_percentage(player, reachables) < threshold_percentages[player])
}
if balancing_players:
balancing_state = state.copy()
balancing_unchecked_locations = unchecked_locations.copy()
balancing_reachables = reachable_locations_count.copy()
balancing_sphere = sphere_locations.copy()
candidate_items = collections.defaultdict(set)
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
if location.event:
balancing_state.collect(location.item, True, location)
player = location.item.player
# only replace items that end up in another player's world
if not location.locked and player in balancing_players and location.player != player:
if (not location.locked and not location.item.skip_in_prog_balancing and
player in balancing_players and
location.player != player and
location.progress_type != LocationProgressType.PRIORITY):
candidate_items[player].add(location)
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
for location in balancing_sphere:
balancing_unchecked_locations.remove(location)
balancing_reachables[location.player] += 1
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
reachables >= threshold for reachables in balancing_reachables.values()):
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
break
elif not balancing_sphere:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
unlocked_locations = collections.defaultdict(set)
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
if l not in balancing_unchecked_locations:
unlocked_locations[l.player].add(l)
items_to_replace = []
items_to_replace: typing.List[Location] = []
for player in balancing_players:
locations_to_test = unlocked_locations[player]
items_to_test = candidate_items[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
world.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
items_to_test):
for location in itertools.chain((
l for l in items_to_replace
if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
reducing_state.sweep_for_events(locations=locations_to_test)
@@ -312,7 +441,8 @@ def balance_multiworld_progression(world: MultiWorld):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
if reachable_locations_count[player] + len(reduced_sphere) < threshold:
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
if p < threshold_percentages[player]:
items_to_replace.append(testing)
replaced_items = False
@@ -324,6 +454,7 @@ def balance_multiworld_progression(world: MultiWorld):
items_to_replace.sort()
world.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for new_location in replacement_locations:
@@ -333,6 +464,7 @@ def balance_multiworld_progression(world: MultiWorld):
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
@@ -340,10 +472,12 @@ def balance_multiworld_progression(world: MultiWorld):
logging.warning(f"Could not Progression Balance {old_location.item}")
if replaced_items:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
unchecked_locations.remove(location)
reachable_locations_count[location.player] += 1
if not location.locked:
reachable_locations_count[location.player] += 1
sphere_locations.add(location)
for location in sphere_locations:
@@ -358,7 +492,7 @@ def balance_multiworld_progression(world: MultiWorld):
break
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
def swap_location_item(location_1: Location, location_2: Location, check_locked: bool = True) -> None:
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
if check_locked:
if location_1.locked:
@@ -371,78 +505,186 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(world: MultiWorld):
def distribute_planned(world: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
else:
logging.debug(f'{warning}')
def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure']:
raise Exception(warning)
else:
warn(warning, force)
# TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup
for player in world.player_ids:
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(world.player_ids)
for player in player_ids:
for block in world.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
if 'world' not in block:
block['world'] = False
items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
if isinstance(items, dict):
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
value = world.itempool.count(world.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
items = [items]
block['items'] = items
locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
if isinstance(locations, str):
locations = [locations]
if isinstance(locations, dict):
location_list = []
for key, value in locations.items():
location_list += [key] * value
locations = location_list
block['locations'] = locations
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
if block['count']['target'] > 0:
plando_blocks.append(block)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
world.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
else len(world.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement['player']
try:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
target_world = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
from_pool = placement['from_pool']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
placement['force'])
continue
item = world.worlds[player].create_item(placement.item)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds = {world_name_lookup[target_world]}
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
for item_name in items:
item = world.worlds[player].create_item(item_name)
for location in reversed(candidates):
if location in key_drop_data:
warn(
f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
if not location.item:
if location.item_rule(item):
if location.can_fill(world.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
warn(
f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
placement['force'])
except Exception as e:
raise Exception(f"Error running plando for player {player} ({world.player_name[player]})") from e
raise Exception(
f"Error running plando for player {player} ({world.player_name[player]})") from e

View File

@@ -3,7 +3,7 @@ import logging
import random
import urllib.request
import urllib.parse
import typing
from typing import Set, Dict, Tuple, Callable, Any, Union
import os
from collections import Counter
import string
@@ -14,8 +14,8 @@ ModuleUpdate.update()
import Utils
from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoItem, PlandoConnection
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
from worlds.generic import PlandoConnection
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
@@ -27,24 +27,31 @@ import copy
categories = set(AutoWorldRegister.world_types)
def mystery_argparse():
options = get_options()
defaults = options["generator"]
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
return path if os.path.isabs(path) else resolver(path)
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true')
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], help="Path to the 1.0 JP SM Baserom.")
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
help="Path to the 1.0 JP SM Baserom.")
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults["race"])
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
parser.add_argument('--log_level', default='info', help='Sets log level')
@@ -57,12 +64,12 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
args.plando: Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args, options
def get_seed_name(random):
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain):
@@ -76,21 +83,21 @@ def main(args=None, callback=ERmain):
if args.race:
random.seed() # reset to time-based random source
weights_cache = {}
weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
except Exception as e:
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
if args.meta_file_path and os.path.exists(args.meta_file_path):
try:
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path]
meta_weights = weights_cache[args.meta_file_path][-1]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
del(meta_weights["meta_description"])
if args.samesettings:
@@ -101,17 +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_yaml(path)
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:
print(f"P{player_id} Weights: {fname} >> "
f"{get_choice('description', weights_cache[fname], '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: "
@@ -134,8 +146,9 @@ def main(args=None, callback=ERmain):
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
for k, v in weights_cache.items()}
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
@@ -146,41 +159,48 @@ def main(args=None, callback=ERmain):
option = get_choice(key, category_dict)
if option is not None:
for player, path in player_path_cache.items():
if category_name is None:
weights_cache[path][key] = option
elif category_name not in weights_cache[path]:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else:
weights_cache[path][category_name][key] = option
for yaml in weights_cache[path]:
if category_name is None:
yaml[key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else:
yaml[category_name][key] = option
name_counter = Counter()
erargs.player_settings = {}
for player in range(1, args.multi + 1):
player = 1
while player <= args.multi:
path = player_path_cache[player]
if path:
try:
settings = settings_cache[path] if settings_cache[path] else \
roll_settings(weights_cache[path], args.plando)
for k, v in vars(settings).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {erargs.name}")
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output:
import yaml
@@ -191,8 +211,6 @@ def main(args=None, callback=ERmain):
if len(player_settings.values()) > 1:
important[option] = {player: value for player, value in player_settings.items() if
player <= args.yaml_output}
elif len(player_settings.values()) > 0:
important[option] = player_settings[1]
else:
logging.debug(f"No player settings defined for option '{option}'")
@@ -209,28 +227,28 @@ def main(args=None, callback=ERmain):
callback(erargs, seed)
def read_weights_yaml(path):
def read_weights_yamls(path) -> Tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
else:
with open(path, 'rb') as f:
yaml = str(f.read(), "utf-8")
yaml = str(f.read(), "utf-8-sig")
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return parse_yaml(yaml)
return tuple(parse_yamls(yaml))
def interpret_on_off(value):
def interpret_on_off(value) -> bool:
return {"on": True, "off": False}.get(value, value)
def convert_to_on_off(value):
def convert_to_on_off(value) -> str:
return {True: "on", False: "off"}.get(value, value)
def get_choice_legacy(option, root, value=None) -> typing.Any:
def get_choice_legacy(option, root, value=None) -> Any:
if option not in root:
return value
if type(root[option]) is list:
@@ -245,7 +263,7 @@ def get_choice_legacy(option, root, value=None) -> typing.Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
def get_choice(option, root, value=None) -> typing.Any:
def get_choice(option, root, value=None) -> Any:
if option not in root:
return value
if type(root[option]) is list:
@@ -278,16 +296,16 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
def prefer_int(input_data: str) -> Union[str, int]:
try:
return int(input_data)
except:
return input_data
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none',
@@ -312,7 +330,7 @@ goals = {
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
@@ -382,7 +400,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
@@ -428,22 +446,13 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else:
# verify item names existing
if getattr(player_option, "verify_item_name", False):
for item_name in player_option.value:
if item_name not in AutoWorldRegister.world_types[ret.game].item_names:
raise Exception(f"Item {item_name} from option {player_option} "
f"is not a valid item name from {ret.game}")
elif getattr(player_option, "verify_location_name", False):
for location_name in player_option.value:
if location_name not in AutoWorldRegister.world_types[ret.game].location_names:
raise Exception(f"Location {location_name} from option {player_option} "
f"is not a valid location name from {ret.game}")
if hasattr(player_option, "verify"):
player_option.verify(AutoWorldRegister.world_types[ret.game])
else:
setattr(ret, option_key, option(option.default))
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",))):
if "linked_options" in weights:
weights = roll_linked_options(weights)
@@ -496,7 +505,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
if not (option_key in Options.common_options and option_key not in game_weights):
handle_option(ret, game_weights, option_key, option)
if "items" in plando_options:
ret.plando_items = roll_item_plando(world_type, game_weights)
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
@@ -513,47 +522,10 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_item_plando(world_type, weights):
plando_items = []
def add_plando_item(item: str, location: str):
if item not in world_type.item_name_to_id:
raise Exception(f"Could not plando item {item} as the item was not recognized")
if location not in world_type.location_name_to_id:
raise Exception(
f"Could not plando item {item} at location {location} as the location was not recognized")
plando_items.append(PlandoItem(item, location, location_world, from_pool, force))
options = weights.get("plando_items", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
from_pool = get_choice_legacy("from_pool", placement, PlandoItem._field_defaults["from_pool"])
location_world = get_choice_legacy("world", placement, PlandoItem._field_defaults["world"])
force = str(get_choice_legacy("force", placement, PlandoItem._field_defaults["force"])).lower()
if "items" in placement and "locations" in placement:
items = placement["items"]
locations = placement["locations"]
if isinstance(items, dict):
item_list = []
for key, value in items.items():
item_list += [key] * value
items = item_list
if not items or not locations:
raise Exception("You must specify at least one item and one location to place items.")
random.shuffle(items)
random.shuffle(locations)
for item, location in zip(items, locations):
add_plando_item(item, location)
else:
item = get_choice_legacy("item", placement, get_choice_legacy("items", placement))
location = get_choice_legacy("location", placement)
add_plando_item(item, location)
return plando_items
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")

View File

@@ -1,8 +1,8 @@
MIT License
Copyright (c) 2017 LLCoolDave
Copyright (c) 2021 Berserker66
Copyright (c) 2021 CaitSith2
Copyright (c) 2022 Berserker66
Copyright (c) 2022 CaitSith2
Copyright (c) 2021 LegendaryLinux
Permission is hereby granted, free of charge, to any person obtaining a copy

291
Launcher.py Normal file
View File

@@ -0,0 +1,291 @@
"""
Archipelago launcher for bundled app.
* if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments
* if run without arguments, open launcher GUI
Scroll down to components= to add components to the launcher as well as setup.py
"""
import argparse
from os.path import isfile
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, open_filename, messagebox,\
is_windows, is_macos, is_linux
from shutil import which
import shlex
from enum import Enum, auto
def open_host_yaml():
file = user_path('host.yaml')
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
import webbrowser
webbrowser.open(file)
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:
filename = open_filename('Select patch', (('Patches', suffixes),))
except Exception as e:
messagebox('Error', str(e), error=True)
else:
file, _, component = identify(filename)
if file and component:
launch([*get_exe(component), file], component.cli)
def browse_files():
file = user_path()
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
import webbrowser
webbrowser.open(file)
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component
CLIENT = auto()
ADJUSTER = auto()
class SuffixIdentifier:
suffixes: Iterable[str]
def __init__(self, *args: str):
self.suffixes = args
def __call__(self, path: str):
if isinstance(path, str):
for suffix in self.suffixes:
if path.endswith(suffix):
return True
return False
class Component:
display_name: str
type: Optional[Type]
script_name: Optional[str]
frozen_name: Optional[str]
icon: str # just the name, no suffix
cli: bool
func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
file_identifier: Optional[Callable[[str], bool]] = None):
self.display_name = display_name
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
self.type = component_type or \
None if not display_name else \
Type.FUNC if func else \
Type.CLIENT if 'Client' in display_name else \
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
self.func = func
self.file_identifier = file_identifier
def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False
components: Iterable[Component] = (
# Launcher
Component('', 'Launcher'),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
# Minecraft
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
file_identifier=SuffixIdentifier('.apmc')),
# Ocarina of Time
Component('OoT Client', 'OoTClient',
file_identifier=SuffixIdentifier('.apz5')),
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),
Component('Browse Files', func=browse_files),
)
icon_paths = {
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
'mcicon': local_path('data', 'mcicon.ico')
}
def identify(path: Union[None, str]):
if path is None:
return None, None, None
for component in components:
if component.handles_file(path):
return path, component.script_name, component
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str):
name = component
component = None
if name.startswith('Archipelago'):
name = name[11:]
if name.endswith('.exe'):
name = name[:-4]
if name.endswith('.py'):
name = name[:-3]
if not name:
return None
for c in components:
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
component = c
break
if not component:
return None
if is_frozen():
suffix = '.exe' if is_windows else ''
return [local_path(f'{component.frozen_name}{suffix}')]
else:
return [sys.executable, local_path(f'{component.script_name}.py')]
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
subprocess.Popen(['start', *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
if terminal:
subprocess.Popen([terminal, '-e', shlex.join(exe)])
return
elif is_macos:
terminal = [which('open'), '-W', '-a', 'Terminal.app']
subprocess.Popen([*terminal, *exe])
return
subprocess.Popen(exe)
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label
class Launcher(App):
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
def __init__(self, ctx=None):
self.title = self.base_title
self.ctx = ctx
self.icon = r"data/icon.png"
super().__init__()
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
button_layout = self.grid # make buttons fill the window
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
button = Button(text=tool[0])
button.component = tool[1]
button.bind(on_release=self.component_action)
button_layout.add_widget(button)
else:
button_layout.add_widget(Label())
# column 2
if client:
button = Button(text=client[0])
button.component = client[1]
button.bind(on_press=self.component_action)
button_layout.add_widget(button)
else:
button_layout.add_widget(Label())
return self.container
@staticmethod
def component_action(button):
if button.component.type == Type.FUNC:
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
Launcher().run()
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
args = {}
if "Patch|Game|Component" in args:
file, component, _ = identify(args["Patch|Game|Component"])
if file:
args['file'] = file
if component:
args['component'] = component
if 'file' in args:
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
elif 'component' in args:
subprocess.run([*get_exe(args['component']), *args['args']])
else:
run_gui()
if __name__ == '__main__':
init_logging('Launcher')
parser = argparse.ArgumentParser(description='Archipelago Launcher')
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
help="Pass either a patch file, a generated game or the name of a component to run.")
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
main(parser.parse_args())

View File

@@ -14,13 +14,19 @@ import tkinter as tk
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, LEFT, X, TOP, LabelFrame, \
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
from urllib.request import urlopen
import ModuleUpdate
ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, open_file, get_cert_none_ssl_context, persistent_store
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
class AdjusterWorld(object):
@@ -39,9 +45,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def main():
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--rom', default='ER_base.sfc', help='Path to an ALttP rom to adjust.')
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
help='Path to an ALttP JAP(1.0) rom to use as a base.')
help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
@@ -52,6 +58,7 @@ def main():
''')
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
@@ -121,15 +128,18 @@ def main():
args, path = adjust(args=args)
if isinstance(args.sprite, Sprite):
args.sprite = args.sprite.name
persistent_store("adjuster", "last_settings_3", args)
persistent_store("adjuster", GAME_ALTTP, args)
def adjust(args):
start = time.perf_counter()
init_logging("LttP Adjuster")
logger = logging.getLogger('Adjuster')
logger.info('Patching ROM.')
vanillaRom = args.baserom
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch
meta, args.rom = Patch.create_rom_file(args.rom)
@@ -154,7 +164,7 @@ def adjust(args):
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
deathlink=args.deathlink)
deathlink=args.deathlink, allowcollect=args.allowcollect)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
@@ -196,6 +206,7 @@ def adjustGUI():
def adjustRom():
guiargs = Namespace()
guiargs.auto_apply = rom_vars.auto_apply.get()
guiargs.heartbeep = rom_vars.heartbeepVar.get()
guiargs.heartcolor = rom_vars.heartcolorVar.get()
guiargs.menuspeed = rom_vars.menuspeedVar.get()
@@ -208,6 +219,7 @@ def adjustGUI():
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
guiargs.rom = romVar2.get()
guiargs.baserom = romVar.get()
guiargs.sprite = rom_vars.sprite
@@ -226,14 +238,44 @@ def adjustGUI():
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
if isinstance(guiargs.sprite, Sprite):
guiargs.sprite = guiargs.sprite.name
persistent_store("adjuster", "last_settings_3", guiargs)
delattr(guiargs, "rom")
persistent_store("adjuster", GAME_ALTTP, guiargs)
def saveGUISettings():
guiargs = Namespace()
guiargs.auto_apply = rom_vars.auto_apply.get()
guiargs.heartbeep = rom_vars.heartbeepVar.get()
guiargs.heartcolor = rom_vars.heartcolorVar.get()
guiargs.menuspeed = rom_vars.menuspeedVar.get()
guiargs.ow_palettes = rom_vars.owPalettesVar.get()
guiargs.uw_palettes = rom_vars.uwPalettesVar.get()
guiargs.hud_palettes = rom_vars.hudPalettesVar.get()
guiargs.sword_palettes = rom_vars.swordPalettesVar.get()
guiargs.shield_palettes = rom_vars.shieldPalettesVar.get()
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
guiargs.baserom = romVar.get()
if isinstance(rom_vars.sprite, Sprite):
guiargs.sprite = rom_vars.sprite.name
else:
guiargs.sprite = rom_vars.sprite
guiargs.sprite_pool = rom_vars.sprite_pool
persistent_store("adjuster", GAME_ALTTP, guiargs)
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
rom_options_frame.pack(side=TOP)
adjustButton.pack(side=BOTTOM, padx=(5, 5))
adjustButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=BOTTOM, pady=(5, 5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow)
adjustWindow.mainloop()
@@ -255,7 +297,7 @@ def run_sprite_update():
def update_sprites(task, on_finish=None):
resultmessage = ""
successful = True
sprite_dir = local_path("data", "sprites", "alttpr")
sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
def finished():
@@ -437,9 +479,14 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
if not adjuster_settings:
adjuster_settings = Namespace()
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value="Zelda no Densetsu - Kamigami no Triforce (Japan).sfc")
romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar)
def RomSelect():
@@ -465,6 +512,31 @@ def get_rom_frame(parent=None):
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
defaults = {
"auto_apply": 'ask',
"music": True,
"reduceflashing": True,
"deathlink": False,
"sprite": None,
"quickswap": True,
"menuspeed": 'normal',
"heartcolor": 'red',
"heartbeep": 'normal',
"ow_palettes": 'default',
"uw_palettes": 'default',
"hud_palettes": 'default',
"sword_palettes": 'default',
"shield_palettes": 'default',
"sprite_pool": [],
"allowcollect": False,
}
if not adjuster_settings:
adjuster_settings = Namespace()
for key, defaultvalue in defaults.items():
if not hasattr(adjuster_settings, key):
setattr(adjuster_settings, key, defaultvalue)
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
@@ -473,25 +545,29 @@ def get_rom_options_frame(parent=None):
vars = Namespace()
vars.MusicVar = IntVar()
vars.MusicVar.set(1)
vars.MusicVar.set(adjuster_settings.music)
MusicCheckbutton = Checkbutton(romOptionsFrame, text="Music", variable=vars.MusicVar)
MusicCheckbutton.grid(row=0, column=0, sticky=E)
vars.disableFlashingVar = IntVar(value=1)
vars.disableFlashingVar = IntVar(value=adjuster_settings.reduceflashing)
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)",
variable=vars.disableFlashingVar)
disableFlashingCheckbutton.grid(row=6, column=0, sticky=W)
vars.DeathLinkVar = IntVar(value=0)
vars.DeathLinkVar = IntVar(value=adjuster_settings.deathlink)
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
vars.AllowCollectVar = IntVar(value=adjuster_settings.allowcollect)
AllowCollectCheckbutton = Checkbutton(romOptionsFrame, text="Allow Collect", variable=vars.AllowCollectVar)
AllowCollectCheckbutton.grid(row=8, column=0, sticky=W)
spriteDialogFrame = Frame(romOptionsFrame)
spriteDialogFrame.grid(row=0, column=1)
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
vars.spriteNameVar = StringVar()
vars.sprite = None
vars.sprite = adjuster_settings.sprite
def set_sprite(sprite_param):
nonlocal vars
@@ -505,8 +581,8 @@ def get_rom_options_frame(parent=None):
vars.sprite = sprite_param
vars.spriteNameVar.set(vars.sprite.name)
set_sprite(None)
vars.spriteNameVar.set('(unchanged)')
set_sprite(adjuster_settings.sprite)
#vars.spriteNameVar.set(adjuster_settings.sprite)
spriteEntry = Label(spriteDialogFrame, textvariable=vars.spriteNameVar)
def SpriteSelect():
@@ -519,7 +595,7 @@ def get_rom_options_frame(parent=None):
spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT)
vars.quickSwapVar = IntVar(value=1)
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
@@ -528,7 +604,7 @@ def get_rom_options_frame(parent=None):
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
menuspeedLabel.pack(side=LEFT)
vars.menuspeedVar = StringVar()
vars.menuspeedVar.set('normal')
vars.menuspeedVar.set(adjuster_settings.menuspeed)
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple',
'quadruple', 'half')
menuspeedOptionMenu.pack(side=LEFT)
@@ -538,7 +614,7 @@ def get_rom_options_frame(parent=None):
heartcolorLabel = Label(heartcolorFrame, text='Heart color')
heartcolorLabel.pack(side=LEFT)
vars.heartcolorVar = StringVar()
vars.heartcolorVar.set('red')
vars.heartcolorVar.set(adjuster_settings.heartcolor)
heartcolorOptionMenu = OptionMenu(heartcolorFrame, vars.heartcolorVar, 'red', 'blue', 'green', 'yellow', 'random')
heartcolorOptionMenu.pack(side=LEFT)
@@ -547,7 +623,7 @@ def get_rom_options_frame(parent=None):
heartbeepLabel = Label(heartbeepFrame, text='Heartbeep')
heartbeepLabel.pack(side=LEFT)
vars.heartbeepVar = StringVar()
vars.heartbeepVar.set('normal')
vars.heartbeepVar.set(adjuster_settings.heartbeep)
heartbeepOptionMenu = OptionMenu(heartbeepFrame, vars.heartbeepVar, 'double', 'normal', 'half', 'quarter', 'off')
heartbeepOptionMenu.pack(side=LEFT)
@@ -556,7 +632,7 @@ def get_rom_options_frame(parent=None):
owPalettesLabel = Label(owPalettesFrame, text='Overworld palettes')
owPalettesLabel.pack(side=LEFT)
vars.owPalettesVar = StringVar()
vars.owPalettesVar.set('default')
vars.owPalettesVar.set(adjuster_settings.ow_palettes)
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale',
'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu.pack(side=LEFT)
@@ -566,7 +642,7 @@ def get_rom_options_frame(parent=None):
uwPalettesLabel = Label(uwPalettesFrame, text='Dungeon palettes')
uwPalettesLabel.pack(side=LEFT)
vars.uwPalettesVar = StringVar()
vars.uwPalettesVar.set('default')
vars.uwPalettesVar.set(adjuster_settings.uw_palettes)
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale',
'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu.pack(side=LEFT)
@@ -576,7 +652,7 @@ def get_rom_options_frame(parent=None):
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
hudPalettesLabel.pack(side=LEFT)
vars.hudPalettesVar = StringVar()
vars.hudPalettesVar.set('default')
vars.hudPalettesVar.set(adjuster_settings.hud_palettes)
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu.pack(side=LEFT)
@@ -586,7 +662,7 @@ def get_rom_options_frame(parent=None):
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
swordPalettesLabel.pack(side=LEFT)
vars.swordPalettesVar = StringVar()
vars.swordPalettesVar.set('default')
vars.swordPalettesVar.set(adjuster_settings.sword_palettes)
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu.pack(side=LEFT)
@@ -596,7 +672,7 @@ def get_rom_options_frame(parent=None):
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
shieldPalettesLabel.pack(side=LEFT)
vars.shieldPalettesVar = StringVar()
vars.shieldPalettesVar.set('default')
vars.shieldPalettesVar.set(adjuster_settings.shield_palettes)
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu.pack(side=LEFT)
@@ -606,7 +682,7 @@ def get_rom_options_frame(parent=None):
baseSpritePoolLabel = Label(spritePoolFrame, text='Sprite Pool:')
vars.spritePoolCountVar = StringVar()
vars.sprite_pool = []
vars.sprite_pool = adjuster_settings.sprite_pool
def set_sprite_pool(sprite_param):
nonlocal vars
@@ -625,7 +701,7 @@ def get_rom_options_frame(parent=None):
vars.spritePoolCountVar.set(str(len(vars.sprite_pool)))
set_sprite_pool(None)
vars.spritePoolCountVar.set('0')
vars.spritePoolCountVar.set(len(adjuster_settings.sprite_pool))
spritePoolEntry = Label(spritePoolFrame, textvariable=vars.spritePoolCountVar)
def SpritePoolSelect():
@@ -645,6 +721,18 @@ def get_rom_options_frame(parent=None):
spritePoolSelectButton.pack(side=LEFT)
spritePoolClearButton.pack(side=LEFT)
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5)
alwaysRadio = Radiobutton(autoApplyFrame, text='Always', variable=vars.auto_apply, value='always')
alwaysRadio.pack(side=LEFT, padx=5, pady=5)
neverRadio = Radiobutton(autoApplyFrame, text='Never', variable=vars.auto_apply, value='never')
neverRadio.pack(side=LEFT, padx=5, pady=5)
return romOptionsFrame, vars, set_sprite
@@ -693,6 +781,9 @@ class SpriteSelector():
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT,padx=(0,5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5))
@@ -710,36 +801,36 @@ class SpriteSelector():
self.randomOnItemVar = IntVar()
self.randomOnBonkVar = IntVar()
self.randomOnRandomVar = IntVar()
self.randomOnAllVar = IntVar()
if self.randomOnEvent:
button = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonHit = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
self.buttonHit.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonEnter = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
self.buttonEnter.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonExit = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
self.buttonExit.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonSlash = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
self.buttonSlash.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonItem = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
self.buttonItem.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonBonk = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
self.buttonBonk.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Random", command=self.update_random_button,
variable=self.randomOnRandomVar)
button.pack(side=LEFT, padx=(0, 5))
self.buttonRandom = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
self.buttonRandom.pack(side=LEFT, padx=(0, 5))
if adjuster:
button = Button(frame, text="Current sprite from rom", command=self.use_default_sprite)
button.pack(side=LEFT, padx=(0, 5))
self.buttonAll = Checkbutton(frame, text="All", command=self.update_random_button, variable=self.randomOnAllVar)
self.buttonAll.pack(side=LEFT, padx=(0, 5))
set_icon(self.window)
self.window.focus()
tkinter_center_window(self.window)
def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename))
@@ -879,9 +970,31 @@ class SpriteSelector():
self.add_to_sprite_pool("link")
def update_random_button(self):
if self.randomOnRandomVar.get():
if self.randomOnAllVar.get():
randomon = "all"
self.buttonHit.config(state=DISABLED)
self.buttonEnter.config(state=DISABLED)
self.buttonExit.config(state=DISABLED)
self.buttonSlash.config(state=DISABLED)
self.buttonItem.config(state=DISABLED)
self.buttonBonk.config(state=DISABLED)
self.buttonRandom.config(state=DISABLED)
elif self.randomOnRandomVar.get():
randomon = "random"
self.buttonHit.config(state=DISABLED)
self.buttonEnter.config(state=DISABLED)
self.buttonExit.config(state=DISABLED)
self.buttonSlash.config(state=DISABLED)
self.buttonItem.config(state=DISABLED)
self.buttonBonk.config(state=DISABLED)
else:
self.buttonHit.config(state=NORMAL)
self.buttonEnter.config(state=NORMAL)
self.buttonExit.config(state=NORMAL)
self.buttonSlash.config(state=NORMAL)
self.buttonItem.config(state=NORMAL)
self.buttonBonk.config(state=NORMAL)
self.buttonRandom.config(state=NORMAL)
randomon = "-hit" if self.randomOnHitVar.get() else ""
randomon += "-enter" if self.randomOnEnterVar.get() else ""
randomon += "-exit" if self.randomOnExitVar.get() else ""
@@ -920,11 +1033,11 @@ class SpriteSelector():
@property
def alttpr_sprite_dir(self):
return local_path("data", "sprites", "alttpr")
return user_path("data", "sprites", "alttpr")
@property
def custom_sprite_dir(self):
return local_path("data", "sprites", "custom")
return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):

173
Main.py
View File

@@ -1,3 +1,4 @@
import collections
from itertools import zip_longest, chain
import logging
import os
@@ -7,18 +8,17 @@ import concurrent.futures
import pickle
import tempfile
import zipfile
from typing import Dict, Tuple, Optional
from typing import Dict, Tuple, Optional, Set
from BaseClasses import MultiWorld, CollectionState, Region, RegionType
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
from worlds import AutoWorld
ordered_areas = (
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
@@ -47,13 +47,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.goal = args.goal.copy()
if hasattr(args, "algorithm"): # current GUI options
world.algorithm = args.algorithm
world.shuffleganon = args.shuffleganon
world.custom = args.custom
world.customitemarray = args.customitemarray
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
@@ -77,12 +70,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.plando_connections = args.plando_connections.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
world.set_options(args)
world.player_name = args.name.copy()
world.enemizer = args.enemizercli
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.set_options(args)
world.set_item_links()
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info("Found World Types:")
@@ -90,12 +85,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
numlength = 8
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
f"{len(cls.location_names):3} Locations")
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}} | "
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}}")
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}})")
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(world, "generate_early")
@@ -129,6 +126,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if world.players > 1:
for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else:
world.non_local_items[1].value = set()
world.local_items[1].value = set()
@@ -137,9 +135,87 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in world.player_ids:
exclusion_rules(world, player, world.exclude_locations[player].value)
world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.priority_locations[player].value:
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
AutoWorld.call_all(world, "generate_basic")
# 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]):
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
classifications[item.name] |= item.classification
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del(counters[player])
if not players:
return None, None
for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del(counters[player][item])
return counters, classifications
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():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# 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)
world.regions.append(region)
locations = region.locations = []
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)
itemcount = len(world.itempool)
world.itempool = new_itempool
while itemcount > len(world.itempool):
items_to_add = []
for player in group["players"]:
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()):
world._recache()
world._all_state = None
logger.info("Running Item Plando")
for item in world.itempool:
@@ -187,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:
@@ -227,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)
@@ -250,45 +326,60 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
import NetUtils
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 8), "clients": client_versions}
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
names = [[name for player, name in sorted(world.player_name.items())]]
for slot in world.player_ids:
client_versions[slot] = world.worlds[slot].get_required_client_version()
player_world: AutoWorld.World = world.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version
games[slot] = world.game[slot]
precollected_items = {player: [item.code for item in world_precollected]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
world.player_types[slot])
for slot, group in world.groups.items():
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1)}
# for now special case Factorio tech_tree_information
sending_visible_players = set()
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
if world.worlds[slot].sending_visible:
sending_visible_players.add(slot)
def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False)
location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
if location.item.player not in world.groups:
precollected_hints[location.item.player].add(hint)
else:
for player in world.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
# item code None should be event, location.address should then also be None
assert location.item.code is not None
locations_data[location.player][location.address] = location.item.code, location.item.player
if location.player in sending_visible_players:
precollect_hint(location)
elif location.name in world.start_location_hints[location.player]:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None"
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.name in world.start_location_hints[location.player]:
precollect_hint(location)
elif location.item.name in world.start_hints[location.item.player]:
precollect_hint(location)
elif any([location.item.name in world.start_hints[player]
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
multidata = {
"slot_data": slot_data,
"games": games,
"names": [[name for player, name in sorted(world.player_name.items())]],
"slot_info": slot_info,
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"remote_items": {player for player in world.player_ids if
world.worlds[player].remote_items},
@@ -310,7 +401,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multidata = zlib.compress(pickle.dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(bytes([3])) # version of format
f.write(multidata)
multidata_task = pool.submit(write_multidata)
@@ -320,7 +411,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occured.
# retrieve exceptions via .result() if they occurred.
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures):

View File

@@ -1,7 +1,10 @@
import argparse
import os, sys
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
@@ -10,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]?$")
forge_version = "1.17.1-37.1.1"
def prompt_yes_no(prompt):
@@ -31,8 +34,8 @@ def prompt_yes_no(prompt):
print('Please respond with "y" or "n".')
# Create mods folder if needed; find AP randomizer jar; return None if not found.
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
@@ -46,8 +49,8 @@ def find_ap_randomizer_jar(forge_dir):
return None
# Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
@@ -69,27 +72,21 @@ def replace_apmc_files(forge_dir, apmc_file):
def read_apmc_file(apmc_file):
from base64 import b64decode
import json
with open(apmc_file, 'r') as f:
data = json.loads(b64decode(f.read()))
return data
return json.loads(b64decode(f.read()))
# Check mod version, download new mod from GitHub releases page if needed.
def update_mod(forge_dir, apmc_file, get_prereleases=False):
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
if apmc_file is not None:
data = read_apmc_file(apmc_file)
minecraft_version = data.get('minecraft_version', '')
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
resp = requests.get(client_releases_endpoint)
if resp.status_code == 200: # OK
try:
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
(apmc_file is None or minecraft_version in release['assets'][0]['name']),
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
(minecraft_version in release['assets'][0]['name']),
resp.json()))
if ap_randomizer != latest_release['assets'][0]['name']:
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
@@ -125,8 +122,8 @@ def update_mod(forge_dir, apmc_file, get_prereleases=False):
sys.exit(0)
# Check if the EULA is agreed to, and prompt the user to read and agree if necessary.
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
@@ -149,31 +146,39 @@ def check_eula(forge_dir):
sys.exit(0)
# get the current JDK16
def find_jdk_dir() -> str:
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith("jdk16"):
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
# get the java exe location
def find_jdk() -> str:
jdk = find_jdk_dir()
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
# Download Corretto 16 (Amazon JDK)
def download_java():
jdk = find_jdk_dir()
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = "https://corretto.aws/downloads/latest/amazon-corretto-16-x64-windows-jdk.zip"
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
@@ -188,10 +193,11 @@ def download_java():
sys.exit(0)
# download and install forge
def install_forge(directory: str):
jdk = find_jdk()
if jdk is not None:
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
@@ -202,70 +208,144 @@ def install_forge(directory: 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)
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
# Run the Forge server. Return process object
def run_forge_server(forge_dir: str, heap_arg):
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk()
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(max_heap).group()
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
win_args = []
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
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)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except StopIteration:
logging.error(f"No compatible mod version found for client version {version}.")
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--prerelease', default=False, action='store_true',
help="Auto-update prerelease versions.")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
forge_dir = options["minecraft_options"]["forge_directory"]
channel = args.channel or options["minecraft_options"]["release_channel"]
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 = 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"]
java_dir = find_jdk_dir(java_version)
if args.install:
print("Installing Java and Minecraft Forge")
download_java()
install_forge(forge_dir)
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_file is not None and not os.path.isfile(apmc_file):
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, apmc_file, args.prerelease)
update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release")
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, max_heap)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -3,7 +3,8 @@ import sys
import subprocess
import pkg_resources
requirements_files = {'requirements.txt'}
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
@@ -11,7 +12,7 @@ if sys.version_info < (3, 8, 6):
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
if not update_ran:
for entry in os.scandir("worlds"):
for entry in os.scandir(os.path.join(local_dir, "worlds")):
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
@@ -23,7 +24,7 @@ def update_command():
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
def update(yes = False, force = False):
def update(yes=False, force=False):
global update_ran
if not update_ran:
update_ran = True
@@ -38,9 +39,8 @@ def update(yes = False, force = False):
for line in requirementsfile:
if line.startswith('https://'):
# extract name and version from url
url = line.split(';')[0]
wheel = line.split('/')[-1]
name, version, _ = wheel.split('-',2)
name, version, _ = wheel.split('-', 2)
line = f'{name}=={version}'
requirements = pkg_resources.parse_requirements(line)
for requirement in requirements:
@@ -58,9 +58,13 @@ def update(yes = False, force = False):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Install archipelago requirements')
parser.add_argument('-y', '--yes', dest='yes', action='store_true', help='answer "yes" to all questions')
parser.add_argument('-f', '--force', dest='force', action='store_true', help='force update')
parser.add_argument('-a', '--append', nargs="*", dest='additional_requirements',
help='List paths to additional requirement files.')
args = parser.parse_args()
if args.additional_requirements:
requirements_files.update(args.additional_requirements)
update(args.yes, args.force)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import logging
import typing
import enum
from json import JSONEncoder, JSONDecoder
@@ -17,6 +16,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
color: str
# owning player for location/item
player: int
# if type == item indicates item flags
flags: int
class ClientStatus(enum.IntEnum):
@@ -27,7 +28,18 @@ class ClientStatus(enum.IntEnum):
CLIENT_GOAL = 30
class Permission(enum.IntEnum):
class SlotType(enum.IntFlag):
spectator = 0b00
player = 0b01
group = 0b10
@property
def always_goal(self) -> bool:
"""Mark this slot has having reached its goal instantly."""
return self.value != 0b01
class Permission(enum.IntFlag):
disabled = 0b000 # 0, completely disables access
enabled = 0b001 # 1, allows manual use
goal = 0b010 # 2, allows manual use after goal completion
@@ -47,16 +59,26 @@ class Permission(enum.IntEnum):
class NetworkPlayer(typing.NamedTuple):
"""Represents a particular player on a particular team."""
team: int
slot: int
alias: str
name: str
class NetworkSlot(typing.NamedTuple):
"""Represents a particular slot across teams."""
name: str
game: str
type: SlotType
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
class NetworkItem(typing.NamedTuple):
item: int
location: int
player: int
flags: int = 0
def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
@@ -74,6 +96,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
separators=(',', ':'),
).encode
@@ -86,9 +109,11 @@ def get_any_version(data: dict) -> Version:
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
whitelist = {"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
}
whitelist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
}
custom_hooks = {
"Version": get_any_version
@@ -119,9 +144,6 @@ class Endpoint:
def __init__(self, socket):
self.socket = socket
async def disconnect(self):
raise NotImplementedError
class HandlerMeta(type):
def __new__(mcs, name, bases, attrs):
@@ -163,6 +185,21 @@ class JSONTypes(str, enum.Enum):
class JSONtoTextParser(metaclass=HandlerMeta):
color_codes = {
# not exact color names, close enough but decent looking
"black": "000000",
"red": "EE0000",
"green": "00FF7F",
"yellow": "FAFAD2",
"blue": "6495ED",
"magenta": "EE00EE",
"cyan": "00EEEE",
"slateblue": "6D8BE8",
"plum": "AF99EF",
"salmon": "FA8072",
"white": "FFFFFF"
}
def __init__(self, ctx):
self.ctx = ctx
@@ -176,7 +213,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_color(self, node: JSONMessagePart):
codes = node["color"].split(";")
buffer = "".join(color_code(code) for code in codes)
buffer = "".join(color_code(code) for code in codes if code in color_codes)
return buffer + self._handle_text(node) + color_code("reset")
def _handle_text(self, node: JSONMessagePart):
@@ -194,12 +231,22 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node)
def _handle_item_name(self, node: JSONMessagePart):
node["color"] = 'cyan'
flags = node.get("flags", 0)
if flags == 0:
node["color"] = 'cyan'
elif flags & 0b001: # advancement
node["color"] = 'plum'
elif flags & 0b010: # useful
node["color"] = 'slateblue'
elif flags & 0b100: # trap
node["color"] = 'salmon'
else:
node["color"] = 'cyan'
return self._handle_color(node)
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):
@@ -208,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):
@@ -238,8 +285,8 @@ def add_json_text(parts: list, text: typing.Any, **kwargs) -> None:
parts.append({"text": str(text), **kwargs})
def add_json_item(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.item_id, **kwargs})
def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int = 0, **kwargs) -> None:
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
@@ -253,13 +300,15 @@ class Hint(typing.NamedTuple):
item: int
found: bool
entrance: str = ""
item_flags: int = 0
def re_check(self, ctx, team) -> Hint:
if self.found:
return self
found = self.location in ctx.location_checks[team, self.finding_player]
if found:
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance)
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
self.item_flags)
return self
def __hash__(self):
@@ -270,7 +319,7 @@ class Hint(typing.NamedTuple):
add_json_text(parts, "[Hint]: ")
add_json_text(parts, self.receiving_player, type="player_id")
add_json_text(parts, "'s ")
add_json_item(parts, self.item, self.receiving_player)
add_json_item(parts, self.item, self.receiving_player, self.item_flags)
add_json_text(parts, " is at ")
add_json_location(parts, self.location, self.finding_player)
add_json_text(parts, " in ")
@@ -288,7 +337,7 @@ class Hint(typing.NamedTuple):
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,
"item": NetworkItem(self.item, self.location, self.finding_player),
"item": NetworkItem(self.item, self.location, self.finding_player, self.item_flags),
"found": self.found}
@property

View File

@@ -87,7 +87,7 @@ def adjustGUI():
option = sfx_options[option_name]
optionFrame = Frame(romSettingsFrame)
optionFrame.grid(row=row, column=column, sticky=E)
optionLabel = Label(optionFrame, text=option.displayname)
optionLabel = Label(optionFrame, text=option.display_name)
optionLabel.pack(side=LEFT)
setattr(opts, option_name, StringVar())
getattr(opts, option_name).set(option.name_lookup[option.default])
@@ -143,7 +143,7 @@ def adjustGUI():
option = cosmetic_options['sword_trail_duration']
optionFrame = Frame(romSettingsFrame)
optionFrame.grid(row=8, column=2, sticky=E)
optionLabel = Label(optionFrame, text=option.displayname)
optionLabel = Label(optionFrame, text=option.display_name)
optionLabel.pack(side=LEFT)
setattr(opts, 'sword_trail_duration', StringVar())
getattr(opts, 'sword_trail_duration').set(option.default)

307
OoTClient.py Normal file
View File

@@ -0,0 +1,307 @@
import asyncio
import json
import os
import multiprocessing
import subprocess
from asyncio import StreamReader, StreamWriter
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
ClientCommandProcessor, logger, get_base_parser
import Utils
from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
"""
Payload: lua -> client
{
playerName: string,
locations: dict,
deathlinkActive: bool,
isDead: bool,
gameComplete: bool
}
Payload: client -> lua
{
items: list,
playerNames: list,
triggerDeath: bool
}
Deathlink logic:
"Dead" is true <-> Link is at 0 hp.
deathlink_pending: we need to kill the player
deathlink_sent_this_death: we interacted with the multiworld on this death, waiting to reset with living link
"""
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
script_version: int = 1
def get_item_value(ap_id):
return ap_id - 66000
class OoTCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
def _cmd_n64(self):
"""Check N64 Connection State"""
if isinstance(self.ctx, OoTContext):
logger.info(f"N64 Status: {self.ctx.n64_status}")
def _cmd_deathlink(self):
"""Toggle deathlink from client. Overrides default setting."""
if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
class OoTContext(CommonContext):
command_processor = OoTCommandProcessor
items_handling = 0b001 # full local
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.game = 'Ocarina of Time'
self.n64_streams: (StreamReader, StreamWriter) = None
self.n64_sync_task = None
self.n64_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.location_table = {}
self.deathlink_enabled = False
self.deathlink_pending = False
self.deathlink_sent_this_death = False
self.deathlink_client_override = False
self.version_warning = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(OoTContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to Bizhawk to get player information')
return
await self.send_connect()
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class OoTManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Ocarina of Time Client"
self.ui = OoTManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: OoTContext):
if ctx.deathlink_enabled and ctx.deathlink_pending:
trigger_death = True
ctx.deathlink_sent_this_death = True
else:
trigger_death = False
return json.dumps({
"items": [get_item_value(item.item) for item in ctx.items_received],
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
"triggerDeath": trigger_death
})
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Turn on deathlink if it is on, and if the client hasn't overriden it
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True)
ctx.deathlink_enabled = True
# Game completion handling
if payload['gameComplete'] and not ctx.finished_game:
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": 30
}])
ctx.finished_game = True
# Locations handling
if ctx.location_table != payload['locations']:
ctx.location_table = payload['locations']
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
}])
# Deathlink handling
if ctx.deathlink_enabled:
if payload['isDead']: # link is dead
ctx.deathlink_pending = False
if not ctx.deathlink_sent_this_death:
ctx.deathlink_sent_this_death = True
await ctx.send_death()
else: # link is alive
ctx.deathlink_sent_this_death = False
async def n64_sync_task(ctx: OoTContext):
logger.info("Starting n64 connector. Use /n64 for status information.")
while not ctx.exit_event.is_set():
error_status = None
if ctx.n64_streams:
(reader, writer) = ctx.n64_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to six fields:
# 1. str: player name (always)
# 2. int: script version (always)
# 3. bool: deathlink active (always)
# 4. dict[str, bool]: checked locations
# 5. bool: whether Link is currently at 0 HP
# 6. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode())
reported_version = data_decoded.get('scriptVersion', 0)
if reported_version == script_version:
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth:
ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom:
await ctx.server_auth(False)
else:
if not ctx.version_warning:
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
"Please update to the latest version. "
"Your connection to the Archipelago server will not be accepted.")
ctx.version_warning = True
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.n64_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.n64_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.n64_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.n64_streams = None
if ctx.n64_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to N64")
ctx.n64_status = CONNECTION_CONNECTED_STATUS
else:
ctx.n64_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.n64_status = error_status
logger.info("Lost connection to N64 and attempting to reconnect. Use /n64 for status updates")
else:
try:
logger.debug("Attempting to connect to N64")
ctx.n64_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28921), timeout=10)
ctx.n64_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.n64_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(apz5_file):
base_name = os.path.splitext(apz5_file)[0]
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
apply_patch_file(rom, apz5_file)
rom.write_to_file(decomp_path)
os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path)
asyncio.create_task(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("OoTClient")
async def main():
multiprocessing.freeze_support()
parser = get_base_parser()
parser.add_argument('apz5_file', default="", type=str, nargs="?",
help='Path to an APZ5 file')
args = parser.parse_args()
if args.apz5_file:
logger.info("APZ5 file supplied, beginning patching process...")
asyncio.create_task(patch_and_run_game(args.apz5_file))
ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.n64_sync_task:
await ctx.n64_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,9 +1,15 @@
from __future__ import annotations
import abc
import math
import numbers
import typing
import random
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
class AssembleOptions(type):
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
name_lookup = attrs["name_lookup"] = {}
@@ -14,44 +20,71 @@ class AssembleOptions(type):
name_lookup.update(base.name_lookup)
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("option_")}
if "random" in new_options:
raise Exception("Choice option 'random' cannot be manually assigned.")
assert "random" not in new_options, "Choice option 'random' cannot be manually assigned."
assert len(new_options) == len(set(new_options.values())), "same ID cannot be used twice. Try alias?"
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
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():
def validate_decorator(func):
def validate(self, *args, **kwargs):
func(self, *args, **kwargs)
if "__init__" in attrs:
def validate_decorator(func):
def validate(self, *args, **kwargs):
ret = func(self, *args, **kwargs)
self.value = self.schema.validate(self.value)
return ret
return validate
attrs["__init__"] = validate_decorator(attrs["__init__"])
else:
# construct an __init__ that calls parent __init__
cls = super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
def meta__init__(self, *args, **kwargs):
super(cls, self).__init__(*args, **kwargs)
self.value = self.schema.validate(self.value)
return validate
cls.__init__ = meta__init__
return cls
attrs["__init__"] = validate_decorator(attrs["__init__"])
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
class Option(metaclass=AssembleOptions):
value: int
name_lookup: typing.Dict[int, str]
T = typing.TypeVar('T')
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
default = 0
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
autodisplayname = False
auto_display_name = False
# can be weighted between selections
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.Dict[int, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.get_current_option_name()})"
def __hash__(self):
def __hash__(self) -> int:
return hash(self.value)
@property
@@ -63,35 +96,199 @@ class Option(metaclass=AssembleOptions):
return self.get_option_name(self.value)
@classmethod
def get_option_name(cls, value: typing.Any) -> str:
if cls.autodisplayname:
def get_option_name(cls, value: T) -> str:
if cls.auto_display_name:
return cls.name_lookup[value].replace("_", " ").title()
else:
return cls.name_lookup[value]
def __int__(self) -> int:
def __int__(self) -> T:
return self.value
def __bool__(self) -> bool:
return bool(self.value)
@classmethod
def from_any(cls, data: typing.Any):
def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError
class Toggle(Option):
class NumericOption(Option[int], numbers.Integral):
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True)
# https://github.com/python/typing/issues/272
# https://github.com/python/mypy/issues/3186
# https://github.com/microsoft/pyright/issues/1575
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, NumericOption):
return self.value == other.value
else:
return typing.cast(bool, self.value == other)
def __lt__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value < other.value
else:
return self.value < other
def __le__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value <= other.value
else:
return self.value <= other
def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value > other.value
else:
return self.value > other
def __bool__(self) -> bool:
return bool(self.value)
def __int__(self) -> int:
return self.value
def __mul__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value * other.value
else:
return self.value * other
def __rmul__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return other.value * self.value
else:
return other * self.value
def __sub__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value - other.value
else:
return self.value - other
def __rsub__(self, left: typing.Any) -> typing.Any:
if isinstance(left, NumericOption):
return left.value - self.value
else:
return left - self.value
def __add__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value + other.value
else:
return self.value + other
def __radd__(self, left: typing.Any) -> typing.Any:
if isinstance(left, NumericOption):
return left.value + self.value
else:
return left + self.value
def __truediv__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value / other.value
else:
return self.value / other
def __rtruediv__(self, left: typing.Any) -> typing.Any:
if isinstance(left, NumericOption):
return left.value / self.value
else:
return left / self.value
def __abs__(self) -> typing.Any:
return abs(self.value)
def __and__(self, other: typing.Any) -> int:
return self.value & int(other)
def __ceil__(self) -> int:
return math.ceil(self.value)
def __floor__(self) -> int:
return math.floor(self.value)
def __floordiv__(self, other: typing.Any) -> int:
return self.value // int(other)
def __invert__(self) -> int:
return ~(self.value)
def __lshift__(self, other: typing.Any) -> int:
return self.value << int(other)
def __mod__(self, other: typing.Any) -> int:
return self.value % int(other)
def __neg__(self) -> int:
return -(self.value)
def __or__(self, other: typing.Any) -> int:
return self.value | int(other)
def __pos__(self) -> int:
return +(self.value)
def __pow__(self, exponent: numbers.Complex, modulus: typing.Optional[numbers.Integral] = None) -> int:
if not (modulus is None):
assert isinstance(exponent, numbers.Integral)
return pow(self.value, exponent, modulus) # type: ignore
return self.value ** exponent # type: ignore
def __rand__(self, other: typing.Any) -> int:
return int(other) & self.value
def __rfloordiv__(self, other: typing.Any) -> int:
return int(other) // self.value
def __rlshift__(self, other: typing.Any) -> int:
return int(other) << self.value
def __rmod__(self, other: typing.Any) -> int:
return int(other) % self.value
def __ror__(self, other: typing.Any) -> int:
return int(other) | self.value
def __round__(self, ndigits: typing.Optional[int] = None) -> int:
return round(self.value, ndigits)
def __rpow__(self, base: typing.Any) -> typing.Any:
return base ** self.value
def __rrshift__(self, other: typing.Any) -> int:
return int(other) >> self.value
def __rshift__(self, other: typing.Any) -> int:
return self.value >> int(other)
def __rxor__(self, other: typing.Any) -> int:
return int(other) ^ self.value
def __trunc__(self) -> int:
return math.trunc(self.value)
def __xor__(self, other: typing.Any) -> int:
return self.value ^ int(other)
class Toggle(NumericOption):
option_false = 0
option_true = 1
default = 0
def __init__(self, value: int):
assert value == 0 or value == 1
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
self.value = value
@classmethod
def from_text(cls, text: str) -> Toggle:
if text.lower() in {"off", "0", "false", "none", "null", "no"}:
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
return cls(0)
else:
return cls(1)
@@ -103,24 +300,6 @@ class Toggle(Option):
else:
return cls(data)
def __eq__(self, other):
if isinstance(other, Toggle):
return self.value == other.value
else:
return self.value == other
def __gt__(self, other):
if isinstance(other, Toggle):
return self.value > other.value
else:
return self.value > other
def __bool__(self):
return bool(self.value)
def __int__(self):
return int(self.value)
@classmethod
def get_option_name(cls, value):
return ["No", "Yes"][int(value)]
@@ -132,8 +311,8 @@ class DefaultOnToggle(Toggle):
default = 1
class Choice(Option):
autodisplayname = True
class Choice(NumericOption):
auto_display_name = True
def __init__(self, value: int):
self.value: int = value
@@ -143,8 +322,8 @@ class Choice(Option):
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
for optionname, value in cls.options.items():
if optionname == text:
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
raise KeyError(
f'Could not find option "{text}" for "{cls.__name__}", '
@@ -160,10 +339,10 @@ class Choice(Option):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
assert other in self.options
assert other in self.options, f"compared against a str that could never be equal. {self} == {other}"
return other == self.current_key
elif isinstance(other, int):
assert other in self.name_lookup
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
return other == self.value
elif isinstance(other, bool):
return other == bool(self.value)
@@ -174,10 +353,10 @@ class Choice(Option):
if isinstance(other, self.__class__):
return other.value != self.value
elif isinstance(other, str):
assert other in self.options
assert other in self.options, f"compared against a str that could never be equal. {self} != {other}"
return other != self.current_key
elif isinstance(other, int):
assert other in self.name_lookup
assert other in self.name_lookup, f"compared against am int that could never be equal. {self} != {other}"
return other != self.value
elif isinstance(other, bool):
return other != bool(self.value)
@@ -189,7 +368,7 @@ class Choice(Option):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class Range(Option, int):
class Range(NumericOption):
range_start = 0
range_end = 1
@@ -204,50 +383,155 @@ class Range(Option, int):
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)))
else:
return cls(random.randint(cls.range_start, cls.range_end))
return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"):
return cls(cls.default)
elif text == "high":
return cls(cls.range_end)
elif text == "low":
return cls(cls.range_start)
elif cls.range_start == 0 \
and hasattr(cls, "default") \
and cls.default != 0 \
and text in ("true", "false"):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls(cls.default)
else: # "false"
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:
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
@classmethod
def get_option_name(cls, value: int) -> str:
return str(value)
def __str__(self):
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 OptionNameSet(Option):
default = frozenset()
def __init__(self, value: typing.Set[str]):
self.value: typing.Set[str] = value
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) -> OptionNameSet:
return cls({option.strip() for option in text.split(",")})
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 from_any(cls, data: typing.Any) -> OptionNameSet:
if type(data) == set:
return cls(data)
return cls.from_text(str(data))
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 OptionDict(Option):
class VerifyKeys:
valid_keys = frozenset()
valid_keys_casefold: bool = False
convert_name_groups: bool = False
verify_item_name: bool = False
verify_location_name: bool = False
value: typing.Any
@classmethod
def verify_keys(cls, data):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
extra = dataset - cls.valid_keys
if extra:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
def verify(self, world):
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
new_value |= world.item_name_groups.get(item_name, {item_name})
self.value = new_value
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
elif self.verify_location_name:
for location_name in self.value:
if location_name not in world.location_names:
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
raise Exception(f"Location {location_name} from option {self} "
f"is not a valid location name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default = {}
supports_weighting = False
value: typing.Dict[str, typing.Any]
def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = value
@@ -255,6 +539,7 @@ class OptionDict(Option):
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -267,7 +552,6 @@ class OptionDict(Option):
class ItemDict(OptionDict):
# implemented by Generate
verify_item_name = True
def __init__(self, value: typing.Dict[str, int]):
@@ -276,10 +560,9 @@ class ItemDict(OptionDict):
super(ItemDict, self).__init__(value)
class OptionList(Option):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default = []
supports_weighting = False
value: list
def __init__(self, value: typing.List[typing.Any]):
self.value = value or []
@@ -292,6 +575,7 @@ class OptionList(Option):
@classmethod
def from_any(cls, data: typing.Any):
if type(data) == list:
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -302,10 +586,9 @@ class OptionList(Option):
return item in self.value
class OptionSet(Option):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
supports_weighting = False
value: set
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(value)
@@ -318,13 +601,15 @@ class OptionSet(Option):
@classmethod
def from_any(cls, data: typing.Any):
if type(data) == list:
cls.verify_keys(data)
return cls(data)
elif type(data) == set:
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
return ", ".join(value)
return ", ".join(sorted(value))
def __contains__(self, item):
return item in self.value
@@ -338,7 +623,7 @@ class Accessibility(Choice):
Locations: ensure everything can be reached and acquired.
Items: ensure all logically relevant items can be acquired.
Minimal: ensure what is needed to reach your goal can be acquired."""
displayname = "Accessibility"
display_name = "Accessibility"
option_locations = 0
option_items = 1
option_minimal = 2
@@ -346,9 +631,18 @@ class Accessibility(Choice):
default = 1
class ProgressionBalancing(DefaultOnToggle):
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
displayname = "Progression Balancing"
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 = {
@@ -358,45 +652,112 @@ common_options = {
class ItemSet(OptionSet):
# implemented by Generate
verify_item_name = True
convert_name_groups = True
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
displayname = "Local Items"
display_name = "Local Items"
class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world."""
displayname = "Not Local Items"
display_name = "Not Local Items"
class StartInventory(ItemDict):
"""Start with these items."""
verify_item_name = True
displayname = "Start Inventory"
display_name = "Start Inventory"
class StartHints(ItemSet):
"""Start with these item's locations prefilled into the !hint command."""
displayname = "Start Hints"
display_name = "Start Hints"
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
displayname = "Start Location Hints"
display_name = "Start Location Hints"
verify_location_name = True
class ExcludeLocations(OptionSet):
"""Prevent these locations from having an important item"""
displayname = "Excluded Locations"
display_name = "Excluded Locations"
verify_location_name = True
class PriorityLocations(OptionSet):
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
verify_location_name = True
class DeathLink(Toggle):
"""When you die, everyone dies. Of course the reverse is true too."""
displayname = "Death Link"
display_name = "Death Link"
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
default = []
schema = Schema([
{
"name": And(str, len),
"item_pool": [And(str, len)],
Optional("exclude"): [And(str, len)],
"replacement_item": Or(And(str, len), None),
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)]
}
])
@staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
pool = set()
for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
raise Exception(f"Item {item_name} from item link {item_link} "
f"is not a valid item from {world.game} for {pool_name}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
if allow_item_groups:
pool |= world.item_name_groups.get(item_name, {item_name})
else:
pool |= {item_name}
return pool
def verify(self, world):
super(ItemLinks, self).verify(world)
existing_links = set()
for link in self.value:
if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.")
existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
local_items = set()
non_local_items = set()
if "exclude" in link:
pool -= self.verify_items(link["exclude"], link["name"], "exclude", world)
if link["replacement_item"]:
self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False)
if "local_items" in link:
local_items = self.verify_items(link["local_items"], link["name"], "local_items", world)
local_items &= pool
if "non_local_items" in link:
non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world)
non_local_items &= pool
intersection = local_items.intersection(non_local_items)
if intersection:
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
per_game_common_options = {
@@ -406,7 +767,9 @@ per_game_common_options = {
"start_inventory": StartInventory,
"start_hints": StartHints,
"start_location_hints": StartLocationHints,
"exclude_locations": ExcludeLocations
"exclude_locations": ExcludeLocations,
"priority_locations": PriorityLocations,
"item_links": ItemLinks
}
if __name__ == "__main__":
@@ -416,8 +779,8 @@ if __name__ == "__main__":
map_shuffle = Toggle
compass_shuffle = Toggle
keyshuffle = Toggle
bigkey_shuffle = Toggle
key_shuffle = Toggle
big_key_shuffle = Toggle
hints = Toggle
test = argparse.Namespace()
test.logic = Logic.from_text("no_logic")

277
Patch.py
View File

@@ -1,5 +1,7 @@
# TODO: convert this into a system like AutoWorld
from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os
@@ -8,31 +10,183 @@ import threading
import concurrent.futures
import zipfile
import sys
from typing import Tuple, Optional
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
import ModuleUpdate
ModuleUpdate.update()
import Utils
current_patch_version = 3
current_patch_version = 4
class AutoPatchRegister(type):
patch_types: Dict[str, APDeltaPatch] = {}
file_endings: Dict[str, APDeltaPatch] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
# construct class
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class
@staticmethod
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None):
if not self.path and not file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = self.get_manifest()
try:
manifest = json.dumps(manifest)
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr("archipelago.json", manifest)
def read(self, file: Optional[Union[str, BinaryIO]] = None):
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
if not self.path and not file:
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "r") as zf:
if file:
self.path = zf.filename
self.read_contents(zf)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> dict:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 4,
"version": current_patch_version,
}
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
"""An APContainer that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
hash = Optional[str] # base checksum of source file
patch_file_ending: str = ""
delta: Optional[bytes] = None
result_file_ending: str = ".sfc"
source_data: bytes
def __init__(self, *args, patched_path: str = "", **kwargs):
self.patched_path = patched_path
super(APDeltaPatch, self).__init__(*args, **kwargs)
def get_manifest(self) -> dict:
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
return manifest
@classmethod
def get_source_data(cls) -> bytes:
"""Get Base data"""
raise NotImplementedError()
@classmethod
def get_source_data_with_cache(cls) -> bytes:
if not hasattr(cls, "source_data"):
cls.source_data = cls.get_source_data()
return cls.source_data
def write_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).write_contents(opened_zipfile)
# write Delta
opened_zipfile.writestr("delta.bsdiff4",
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).read_contents(opened_zipfile)
self.delta = opened_zipfile.read("delta.bsdiff4")
def patch(self, target: str):
"""Base + Delta -> Patched"""
if not self.delta:
self.read()
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
with open(target, "wb") as f:
f.write(result)
# legacy patch handling follows:
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
GAME_SMZ3 = "SMZ3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe"
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import JAP10HASH as HASH
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import JAP10HASH as HASH
from worlds.sm.Rom import SMJUHASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
@@ -62,7 +216,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP else ".apm3")
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
write_lzma(bytes, target)
return target
@@ -87,18 +241,29 @@ def get_base_rom_data(game: str):
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
file_name = Utils.get_options()["soe_options"]["rom_file"]
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
from worlds.soe.Patch import get_base_rom_path
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data, target
auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,
"player": handler.player,
"player_name": handler.player_name}, target
else:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data, target
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
@@ -146,10 +311,63 @@ if __name__ == "__main__":
elif rom.endswith(".apbp"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
romfile, adjusted = Utils.get_adjuster_settings(target)
#romfile, adjusted = Utils.get_adjuster_settings(target)
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
if adjuster_settings:
import pprint
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = target
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
sprite_pool = {}
for sprite in getattr(adjuster_settings, "sprite_pool"):
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
adjust_wanted = str('no')
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
adjust_wanted = 'no'
elif adjuster_settings.auto_apply == 'always':
adjust_wanted = 'yes'
if adjust_wanted and "never" in adjust_wanted:
adjuster_settings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
elif adjust_wanted and "always" in adjust_wanted:
adjuster_settings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
else:
adjusted = False
if adjusted:
try:
os.replace(romfile, target)
shutil.move(romfile, target)
romfile = target
except Exception as e:
print(e)
@@ -164,24 +382,13 @@ if __name__ == "__main__":
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".archipelago"):
import json
import zlib
with open(rom, 'rb') as fr:
multidata = zlib.decompress(fr.read()).decode("utf-8")
with open(rom + '.txt', 'w') as fw:
fw.write(multidata)
multidata = json.loads(multidata)
for romname in multidata['roms']:
Utils.persistent_store("servers", "".join(chr(byte) for byte in romname[2]), address)
from Utils import get_options
multidata["server_options"] = get_options()["server_options"]
multidata = zlib.compress(json.dumps(multidata).encode("utf-8"), 9)
with open(rom + "_updated.archipelago", 'wb') as f:
f.write(multidata)
elif rom.endswith(".apsmz"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}")
@@ -210,4 +417,4 @@ if __name__ == "__main__":
import traceback
traceback.print_exc()
input("Press enter to close.")
input("Press enter to close.")

View File

@@ -15,6 +15,17 @@ Currently, the following games are supported:
* Secret of Evermore
* Final Fantasy
* Rogue Legacy
* VVVVVV
* Raft
* Super Mario 64
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2: Wings of Liberty
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -55,6 +66,11 @@ 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 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,24 +10,30 @@ import base64
import shutil
import logging
import asyncio
import enum
import typing
from json import loads, dumps
from Utils import get_item_name_from_id, init_logging
import ModuleUpdate
ModuleUpdate.update()
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 import Items
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
import Utils
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
snes_logger = logging.getLogger("SNES")
@@ -40,7 +46,7 @@ class DeathState(enum.IntEnum):
dead = 3
class LttPCommandProcessor(ClientCommandProcessor):
class SNIClientCommandProcessor(ClientCommandProcessor):
ctx: Context
def _cmd_slow_mode(self, toggle: str = ""):
@@ -55,7 +61,8 @@ class LttPCommandProcessor(ClientCommandProcessor):
@mark_raw
def _cmd_snes(self, snes_options: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to,
otherwise show available devices; and a SNES device number if more than one SNES is detected"""
otherwise show available devices; and a SNES device number if more than one SNES is detected.
Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
snes_address = self.ctx.snes_address
snes_device_number = -1
@@ -64,16 +71,17 @@ class LttPCommandProcessor(ClientCommandProcessor):
num_options = len(options)
if num_options > 0:
snes_address = options[0]
snes_device_number = int(options[0])
if num_options > 1:
try:
snes_device_number = int(options[1])
except:
pass
snes_address = options[0]
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:
@@ -91,16 +99,26 @@ class LttPCommandProcessor(ClientCommandProcessor):
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
# self.output("No attached SNES Device.")
# return False
#
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
# asyncio.create_task(snes_flush_writes(self.ctx))
# self.output("Data Sent")
# return True
# def _cmd_snes_read(self, address, size=1):
# """Read the SNES' memory address (base16)."""
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
# self.output("No attached SNES Device.")
# return False
# data = await snes_read(self.ctx, int(address, 16), size)
# self.output(f"Data Read: {data}")
# return True
class Context(CommonContext):
command_processor = LttPCommandProcessor
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)
@@ -117,6 +135,8 @@ class Context(CommonContext):
self.snes_connector_lock = threading.Lock()
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
@@ -165,21 +185,60 @@ 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"]:
new_locations = set(args["checked_locations"])
self.checked_locations |= new_locations
self.locations_scouted |= new_locations
# Items belonging to the player should not be marked as checked in game, since the player will likely need that item.
# Once the games handled by SNIClient gets made to be remote items, this will no longer be needed.
asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
def run_gui(self):
from kvui import GameManager
class SNIManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("SNES", "SNES"),
]
base_title = "Archipelago SNI Client"
self.ui = SNIManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def deathlink_kill_player(ctx: Context):
ctx.death_state = DeathState.killing_player
while ctx.death_state == DeathState.killing_player and \
ctx.snes_state == SNESState.SNES_ATTACHED:
if ctx.game == GAME_ALTTP:
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
snes_buffered_write(ctx, WRAM_START + 0x0373, bytes([8])) # deal 1 full heart of damage at next opportunity
invincible = await snes_read(ctx, WRAM_START + 0x037B, 1)
last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
await asyncio.sleep(0.25)
health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
if not invincible or not last_health or not health:
ctx.death_state = DeathState.dead
ctx.last_death_link = time.time()
continue
if not invincible[0] and last_health[0] == health[0]:
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
snes_buffered_write(ctx, WRAM_START + 0x0373,
bytes([8])) # deal 1 full heart of damage at next opportunity
elif ctx.game == GAME_SM:
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([0, 0])) # set current health to 0
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy)
snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity
if not ctx.death_link_allow_survive:
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
await snes_flush_writes(ctx)
await asyncio.sleep(1)
gamemode = None
if ctx.game == GAME_ALTTP:
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
if not gamemode or gamemode[0] in DEATH_MODES:
@@ -189,19 +248,12 @@ async def deathlink_kill_player(ctx: Context):
health = await snes_read(ctx, WRAM_START + 0x09C2, 2)
if health is not None:
health = health[0] | (health[1] << 8)
if not gamemode or gamemode[0] in SM_DEATH_MODES or (ctx.death_link_allow_survive and health is not None and health > 0):
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
ctx.death_link_allow_survive and health is not None and health > 0):
ctx.death_state = DeathState.dead
ctx.last_death_link = time.time()
def color_item(item_id: int, green: bool = False) -> str:
item_name = get_item_name_from_id(item_id)
item_colors = ['green' if green else 'cyan']
if item_name in Items.progression_items:
item_colors.append("white_bg")
return color(item_name, *item_colors)
SNES_RECONNECT_DELAY = 5
# LttP
@@ -230,11 +282,12 @@ SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte
SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
SHOP_LEN = (len(Shops.shop_table) * 3) + 5
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
# SM
SM_ROMNAME_START = 0x1C4F00
SM_ROMNAME_START = 0x007FC0
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
SM_ENDGAME_MODES = {0x26, 0x27}
@@ -245,6 +298,19 @@ SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
# SMZ3
SMZ3_ROMNAME_START = 0x00FFC0
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
SMZ3_ENDGAME_MODES = {0x26, 0x27}
SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes
SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
@@ -469,6 +535,18 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss',
'Desert Palace - Boss',
'Tower of Hera - Boss',
'Palace of Darkness - Boss',
'Swamp Palace - Boss',
'Skull Woods - Boss',
"Thieves' Town - Boss",
'Ice Palace - Boss',
'Misery Mire - Boss',
'Turtle Rock - Boss',
'Sahasrahla'}}
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
location_table_npc = {'Mushroom': 0x1000,
@@ -537,8 +615,14 @@ def launch_sni(ctx: Context):
if not sys.stdout: # if it spawns a visible console, may as well populate it
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path))
else:
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
proc = subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
proc.wait(.1) # wait a bit to see if startup fails (missing dependencies)
snes_logger.info('Failed to start SNI. Try running it externally for error output.')
except subprocess.TimeoutExpired:
pass # seems to be running
else:
snes_logger.info(
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
@@ -570,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",
@@ -578,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):
@@ -622,24 +707,24 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
try:
devices = await get_snes_devices(ctx)
numDevices = len(devices)
device_count = len(devices)
if numDevices == 1:
if device_count == 1:
device = devices[0]
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
else:
device = devices[ctx.snes_attached_device[0]]
elif numDevices > 1:
elif device_count > 1:
if deviceIndex == -1:
snes_logger.info(
"Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
snes_logger.info(f"Found {device_count} SNES devices. "
f"Connect to one with /snes <address> <device number>. For example /snes {address} 1")
for idx, availableDevice in enumerate(devices):
snes_logger.info(str(idx + 1) + ": " + availableDevice)
elif (deviceIndex < 0) or (deviceIndex - 1) > numDevices:
elif (deviceIndex < 0) or (deviceIndex - 1) > device_count:
snes_logger.warning("SNES device number out of range")
else:
@@ -661,8 +746,6 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
ctx.snes_attached_device = (devices.index(device), device)
ctx.snes_reconnect_address = address
recv_task = asyncio.create_task(snes_recv_loop(ctx))
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
snes_logger.info(f"Attached to {device}")
except Exception as e:
if recv_task is not None:
@@ -681,6 +764,10 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
asyncio.create_task(snes_autoreconnect(ctx))
SNES_RECONNECT_DELAY *= 2
else:
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
snes_logger.info(f"Attached to {device}")
async def snes_disconnect(ctx: Context):
if ctx.snes_socket:
@@ -806,24 +893,32 @@ 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)})')
try:
if roomid in location_shop_ids:
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
for cnt, b in enumerate(misc_data):
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
new_check(Shops.SHOP_ID_START + cnt)
shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN)
shop_data_changed = False
shop_data = list(shop_data)
for cnt, b in enumerate(shop_data):
location = Shops.SHOP_ID_START + cnt
if int(b) and location not in ctx.locations_checked:
new_check(location)
if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \
and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot:
if not int(b):
shop_data[cnt] += 1
shop_data_changed = True
if shop_data_changed:
snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data))
except Exception as e:
snes_logger.info(f"Exception: {e}")
for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items():
try:
if location_id not in ctx.locations_checked and loc_roomid == roomid and (
roomdata << 4) & loc_mask != 0:
if location_id not in ctx.locations_checked and loc_roomid == roomid and \
(roomdata << 4) & loc_mask != 0:
new_check(location_id)
except Exception as e:
snes_logger.exception(f"Exception: {e}")
@@ -831,12 +926,19 @@ async def track_locations(ctx: Context, roomid, roomdata):
uw_begin = 0x129
ow_end = uw_end = 0
uw_unchecked = {}
uw_checked = {}
for location, (roomid, mask) in location_table_uw.items():
location_id = Regions.lookup_name_to_id[location]
if location_id not in ctx.locations_checked:
uw_unchecked[location_id] = (roomid, mask)
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
uw_checked[location_id] = (roomid, mask)
if uw_begin < uw_end:
uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2)
@@ -846,14 +948,27 @@ async def track_locations(ctx: Context, roomid, roomdata):
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
if roomdata & mask != 0:
new_check(location_id)
if uw_checked:
uw_data = list(uw_data)
for location_id, (roomid, mask) in uw_checked.items():
offset = (roomid - uw_begin) * 2
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
roomdata |= mask
uw_data[offset] = roomdata & 0xFF
uw_data[offset + 1] = roomdata >> 8
snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data))
ow_begin = 0x82
ow_unchecked = {}
ow_checked = {}
for location_id, screenid in location_table_ow_id.items():
if location_id not in ctx.locations_checked:
ow_unchecked[location_id] = screenid
ow_begin = min(ow_begin, screenid)
ow_end = max(ow_end, screenid + 1)
if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
ow_checked[location_id] = screenid
if ow_begin < ow_end:
ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin)
@@ -861,25 +976,49 @@ async def track_locations(ctx: Context, roomid, roomdata):
for location_id, screenid in ow_unchecked.items():
if ow_data[screenid - ow_begin] & 0x40 != 0:
new_check(location_id)
if ow_checked:
ow_data = list(ow_data)
for location_id, screenid in ow_checked.items():
ow_data[screenid - ow_begin] |= 0x40
snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data))
if not ctx.locations_checked.issuperset(location_table_npc_id):
npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2)
if npc_data is not None:
npc_value_changed = False
npc_value = npc_data[0] | (npc_data[1] << 8)
for location_id, mask in location_table_npc_id.items():
if npc_value & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
npc_value |= mask
npc_value_changed = True
if npc_value_changed:
npc_data = bytes([npc_value & 0xFF, npc_value >> 8])
snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data)
if not ctx.locations_checked.issuperset(location_table_misc_id):
misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4)
if misc_data is not None:
misc_data = list(misc_data)
misc_data_changed = False
for location_id, (offset, mask) in location_table_misc_id.items():
assert (0x3c6 <= offset <= 0x3c9)
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
misc_data_changed = True
misc_data[offset - 0x3c6] |= mask
if misc_data_changed:
snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data))
if new_locations:
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
await snes_flush_writes(ctx)
async def game_watcher(ctx: Context):
@@ -895,27 +1034,43 @@ async def game_watcher(ctx: Context):
if not ctx.rom:
ctx.finished_game = False
ctx.death_link_allow_survive = False
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
if game_name is None:
continue
elif game_name == b"SM":
elif game_name[:2] == b"SM":
ctx.game = GAME_SM
# versions lower than 0.3.0 dont have item handling flag nor remote item support
romVersion = int(game_name[2:5].decode('UTF-8'))
if romVersion < 30:
ctx.items_handling = 0b001 # full local
else:
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
else:
ctx.game = GAME_ALTTP
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
if game_name == b"ZSM":
ctx.game = GAME_SMZ3
ctx.items_handling = 0b101 # local items and remote start inventory
else:
ctx.game = GAME_ALTTP
ctx.items_handling = 0b001 # full local
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else ROMNAME_START, ROMNAME_SIZE)
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
continue
ctx.rom = rom
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if ctx.game != GAME_SMZ3:
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
ctx.allow_collect = bool(death_link[0] & 0b100)
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
ctx.locations_info = {}
ctx.prev_rom = ctx.rom
if ctx.awaiting_rom:
@@ -971,8 +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.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
color(ctx.item_names[item.item], 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
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]))
@@ -984,16 +1140,16 @@ async def game_watcher(ctx: Context):
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
bytes([scout_location]))
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
bytes([ctx.locations_info[scout_location][0]]))
bytes([ctx.locations_info[scout_location].item]))
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location][1])]))
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)]))
await snes_flush_writes(ctx)
if scout_location > 0 and scout_location not in ctx.locations_scouted:
ctx.locations_scouted.add(scout_location)
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
await track_locations(ctx, roomid, roomdata)
await track_locations(ctx, roomid, roomdata)
elif ctx.game == GAME_SM:
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
@@ -1020,14 +1176,16 @@ async def game_watcher(ctx: Context):
itemIndex = (message[4] | (message[5] << 8)) >> 3
recv_index += 1
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.sm.Locations import locations_start_id
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
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]}])
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
@@ -1038,17 +1196,88 @@ async def game_watcher(ctx: Context):
itemOutPtr = data[2] | (data[3] << 8)
from worlds.sm.Items import items_start_id
from worlds.sm.Locations import locations_start_id
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
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(
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
itemOutPtr += 1
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_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)
elif ctx.game == GAME_SMZ3:
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
if (currentGame is not None):
if (currentGame[0] != 0):
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
endGameModes = SM_ENDGAME_MODES
else:
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
endGameModes = ENDGAME_MODES
if gamemode is not None and (gamemode[0] in endGameModes):
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
continue
data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4)
if data is None:
continue
recv_index = data[0] | (data[1] << 8)
recv_item = data[2] | (data[3] << 8)
while (recv_index < recv_item):
itemAdress = recv_index * 8
message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
# worldId = message[0] | (message[1] << 8) # unused
# itemId = message[2] | (message[3] << 8) # unused
isZ3Item = ((message[5] & 0x80) != 0)
maskedPart = (message[5] & 0x7F) if isZ3Item else message[5]
itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0)
recv_index += 1
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.smz3.TotalSMZ3.Location import locations_start_id
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(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]}])
data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4)
if data is None:
continue
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
itemOutPtr = data[2] | (data[3] << 8)
from worlds.smz3.TotalSMZ3.Item import items_start_id
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
itemOutPtr += 1
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
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)
@@ -1074,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}")
@@ -1086,14 +1319,8 @@ async def main():
import time
time.sleep(3)
sys.exit()
elif args.diff_file.endswith((".apbp", "apz3")):
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
adjustedromfile = romfile
except Exception as e:
logging.exception(e)
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:
asyncio.create_task(run_game(romfile))
@@ -1101,17 +1328,12 @@ async def main():
ctx = Context(args.snes, args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
input_task = None
if gui_enabled:
from kvui import SNIManager
ctx.ui = SNIManager(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")
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
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()
@@ -1120,21 +1342,137 @@ 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()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
def get_alttp_settings(romfile: str):
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
adjustedromfile = ''
if lastSettings:
choice = 'no'
if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply:
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink", "allowcollect"}
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
if hasattr(lastSettings, "sprite_pool"):
sprite_pool = {}
for sprite in lastSettings.sprite_pool:
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
import pprint
if gui_enabled:
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'))
applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo)
applyPromptWindow.wm_title("Last adjuster settings LttP")
label = LabelFrame(applyPromptWindow,
text='Last used adjuster settings were found. Would you like to apply these?')
label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5)
label.grid_columnconfigure(0, weight=1)
label.grid_columnconfigure(1, weight=1)
label.grid_columnconfigure(2, weight=1)
label.grid_columnconfigure(3, weight=1)
def onButtonClick(answer: str = 'no'):
setattr(onButtonClick, 'choice', answer)
applyPromptWindow.destroy()
framedOptions = Frame(label)
framedOptions.grid(column=0, columnspan=4, row=0)
framedOptions.grid_columnconfigure(0, weight=1)
framedOptions.grid_columnconfigure(1, weight=1)
framedOptions.grid_columnconfigure(2, weight=1)
curRow = 0
curCol = 0
for name, value in printed_options.items():
Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5)
if (curCol == 2):
curRow += 1
curCol = 0
else:
curCol += 1
yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10)
yesButton.grid(column=0, row=1)
noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10)
noButton.grid(column=1, row=1)
alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10)
alwaysButton.grid(column=2, row=1)
neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10)
neverButton.grid(column=3, row=1)
Utils.tkinter_center_window(applyPromptWindow)
applyPromptWindow.mainloop()
choice = getattr(onButtonClick, 'choice')
else:
choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
if choice and choice.startswith("y"):
choice = 'yes'
elif choice and "never" in choice:
choice = 'no'
lastSettings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings)
elif choice and "always" in choice:
choice = 'yes'
lastSettings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings)
else:
choice = 'no'
elif 'never' in lastSettings.auto_apply:
choice = 'no'
elif 'always' in lastSettings.auto_apply:
choice = 'yes'
if 'yes' in choice:
from worlds.alttp.Rom import get_base_rom_path
lastSettings.rom = romfile
lastSettings.baserom = get_base_rom_path()
lastSettings.world = None
if hasattr(lastSettings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, adjustedromfile = LttPAdjuster.adjust(lastSettings)
if hasattr(lastSettings, "world"):
delattr(lastSettings, "world")
else:
adjusted = False
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
adjustedromfile = romfile
except Exception as e:
logging.exception(e)
else:
adjusted = False
return adjustedromfile, adjusted
if __name__ == '__main__':
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
asyncio.run(main())
colorama.deinit()

802
Starcraft2Client.py Normal file
View File

@@ -0,0 +1,802 @@
from __future__ import annotations
import multiprocessing
import logging
import 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
if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client")
logger = logging.getLogger("Client")
sc2_logger = logging.getLogger("Starcraft2")
import colorama
from NetUtils import *
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply()
class StarcraftClientProcessor(ClientCommandProcessor):
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.ctx.missions_unlocked = True
sc2_logger.info("Mission check has been disabled")
def _cmd_play(self, mission_id: str = "") -> bool:
"""Start a Starcraft 2 mission"""
options = mission_id.split()
num_options = len(options)
if num_options > 0:
mission_number = int(options[0])
self.ctx.play_mission(mission_number)
else:
sc2_logger.info(
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
return True
def _cmd_available(self) -> bool:
"""Get what missions are currently available to play"""
request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
return True
def _cmd_unfinished(self) -> bool:
"""Get what missions are currently available to play and have not had all locations checked"""
request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
return True
class SC2Context(CommonContext):
command_processor = StarcraftClientProcessor
game = "Starcraft 2 Wings of Liberty"
items_handling = 0b111
difficulty = -1
all_in_choice = 0
mission_req_table = None
items_rec_to_announce = []
rec_announce_pos = 0
items_sent_to_announce = []
sent_announce_pos = 0
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(SC2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
self.difficulty = args["slot_data"]["game_difficulty"]
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"}:
if "receiving" in args:
if self.slot_concerns_self(args["receiving"]):
self.announcements.append(args["data"])
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, 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 = [
("Client", "Archipelago"),
("Starcraft2", "Starcraft2"),
]
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(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()
parser = get_base_parser()
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
args = parser.parse_args()
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")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
maps_table = [
"ap_traynor01", "ap_traynor02", "ap_traynor03",
"ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
"ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
"ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
"ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
"ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
"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
armory1_unlocks = 0
armory2_unlocks = 0
upgrade_unlocks = 0
building_unlocks = 0
merc_unlocks = 0
lab_unlocks = 0
protoss_unlock = 0
minerals = 0
vespene = 0
supply = 0
for item in items:
data = lookup_id_to_name[item.item]
if item_table[data].type == "Unit":
unit_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Upgrade":
upgrade_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 1":
armory1_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 2":
armory2_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Building":
building_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Mercenary":
merc_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Laboratory":
lab_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Protoss":
protoss_unlock += (1 << item_table[data].number)
elif item_table[data].type == "Minerals":
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, supply]
def calc_difficulty(difficulty):
if difficulty == 0:
return 'C'
elif difficulty == 1:
return 'N'
elif difficulty == 2:
return 'H'
elif difficulty == 3:
return 'B'
return 'X'
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)
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
name="Archipelago", fullscreen=True)], realtime=True)
class ArchipelagoBot(sc2.bot_ai.BotAI):
game_running = False
mission_completed = False
first_bonus = False
second_bonus = False
third_bonus = False
fourth_bonus = False
fifth_bonus = False
sixth_bonus = False
seventh_bonus = False
eight_bonus = False
ctx: SC2Context = None
mission_id = 0
can_read_game = False
last_received_update = 0
def __init__(self, ctx: SC2Context, mission_id):
self.ctx = ctx
self.mission_id = mission_id
super(ArchipelagoBot, self).__init__()
async def on_step(self, iteration: int):
game_state = 0
if iteration == 0:
start_items = calculate_items(self.ctx.items_received)
difficulty = calc_difficulty(self.ctx.difficulty)
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, start_items[10]))
self.last_received_update = len(self.ctx.items_received)
else:
if self.ctx.announcement_pos < len(self.ctx.announcements):
index = 0
message = ""
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
index += 1
index = 0
start_rem_pos = -1
# Remove unneeded [Color] tags
while index < len(message):
if message[index] == '[':
start_rem_pos = index
index += 1
elif message[index] == ']' and start_rem_pos > -1:
temp_msg = ""
if start_rem_pos > 0:
temp_msg = message[:start_rem_pos]
if index < len(message) - 1:
temp_msg += message[index + 1:]
message = temp_msg
index += start_rem_pos - index
start_rem_pos = -1
else:
index += 1
await self.chat_send("SendMessage " + message)
self.ctx.announcement_pos += 1
# Archipelago reads the health
for unit in self.all_own_units():
if unit.health_max == 38281:
game_state = int(38281 - unit.health)
self.can_read_game = True
if iteration == 160 and not game_state & 1:
await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
"Starcraft 2 (This is likely a map issue)")
if self.last_received_update < len(self.ctx.items_received):
current_items = calculate_items(self.ctx.items_received)
await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
current_items[5], current_items[6], current_items[7]))
self.last_received_update = len(self.ctx.items_received)
if game_state & 1:
if not self.game_running:
print("Archipelago Connected")
self.game_running = True
if self.can_read_game:
if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != 29:
print("Mission Completed")
await self.ctx.send_msgs([
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
self.mission_completed = True
else:
print("Game Complete")
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
self.mission_completed = True
if game_state & (1 << 2) and not self.first_bonus:
print("1st Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
self.first_bonus = True
if not self.second_bonus and game_state & (1 << 3):
print("2nd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
self.second_bonus = True
if not self.third_bonus and game_state & (1 << 4):
print("3rd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
self.third_bonus = True
if not self.fourth_bonus and game_state & (1 << 5):
print("4th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
self.fourth_bonus = True
if not self.fifth_bonus and game_state & (1 << 6):
print("5th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
self.fifth_bonus = True
if not self.sixth_bonus and game_state & (1 << 7):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
self.sixth_bonus = True
if not self.seventh_bonus and game_state & (1 << 8):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
self.seventh_bonus = True
if not self.eight_bonus and game_state & (1 << 9):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
self.eight_bonus = True
else:
await self.chat_send("LostConnection - Lost connection to game.")
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
objectives_complete = 0
if missions_info[mission].extra_locations > 0:
for i in range(missions_info[mission].extra_locations):
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
objectives_complete += 1
else:
unfinished_locations[mission].append(ctx.location_names[
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
return objectives_complete
else:
return -1
def request_unfinished_missions(locations_done, location_table, ui, ctx):
if location_table:
message = "Unfinished Missions: "
unlocks = initialize_blank_mission_dict(location_table)
unfinished_locations = initialize_blank_mission_dict(location_table)
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(
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
ctx, unfinished_locations, mission)
for mission in unfinished_missions)
if ui:
ui.log_panels['All'].on_message_markup(message)
ui.log_panels['Starcraft2'].on_message_markup(message)
else:
sc2_logger.info(message)
else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
available_missions=[]):
unfinished_missions = []
locations_completed = []
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:
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
if objectives_completed < locations[name].extra_locations:
unfinished_missions.append(name)
locations_completed.append(objectives_completed)
else:
unfinished_missions.append(name)
locations_completed.append(-1)
return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
def is_mission_available(mission_id_to_check, locations_done, locations):
unfinished_missions = calc_available_missions(locations_done, locations)
return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
def mark_up_mission_name(mission, location_table, ui, unlock_table):
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
if location_table[mission].completion_critical:
if ui:
message = "[color=AF99EF]" + mission + "[/color]"
else:
message = "*" + mission + "*"
else:
message = mission
if ui:
unlocks = unlock_table[mission]
if len(unlocks) > 0:
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
pre_message += f"]"
message = pre_message + message + "[/ref]"
return message
def mark_up_objectives(message, ctx, unfinished_locations, mission):
formatted_message = message
if ctx.ui:
locations = unfinished_locations[mission]
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
pre_message += "<br>".join(location for location in locations)
pre_message += f"]"
formatted_message = pre_message + message + "[/ref]"
return formatted_message
def request_available_missions(locations_done, location_table, ui):
if location_table:
message = "Available Missions: "
# Initialize mission unlock table
unlocks = initialize_blank_mission_dict(location_table)
missions = calc_available_missions(locations_done, location_table, unlocks)
message += \
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
for mission in missions)
if ui:
ui.log_panels['All'].on_message_markup(message)
ui.log_panels['Starcraft2'].on_message_markup(message)
else:
sc2_logger.info(message)
else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_available_missions(locations_done, locations, unlocks=None):
available_missions = []
missions_complete = 0
# Get number of missions completed
for loc in locations_done:
if loc % 100 == 0:
missions_complete += 1
for name in locations:
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
if unlocks:
for unlock in locations[name].required_world:
unlocks[list(locations)[unlock-1]].append(name)
if mission_reqs_completed(name, missions_complete, locations_done, locations):
available_missions.append(name)
return available_missions
def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
"""Returns a bool signifying if the mission has all requirements complete and can be done
Keyword arguments:
locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed
locations_done -- a list of the location ids that have been complete
locations -- a dict of MissionInfo for mission requirements for this world"""
if len(locations[location_to_check].required_world) >= 1:
# A check for when the requirements are being or'd
or_success = False
# Loop through required missions
for req_mission in locations[location_to_check].required_world:
req_success = True
# Check if required mission has been completed
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
if not locations[location_to_check].or_requirements:
return False
else:
req_success = False
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
locations):
if not locations[location_to_check].or_requirements:
return False
else:
req_success = False
# If requirement check succeeded mark or as satisfied
if locations[location_to_check].or_requirements and req_success:
or_success = True
if locations[location_to_check].or_requirements:
# Return false if or requirements not met
if not or_success:
return False
# Check number of missions
if missions_complete >= locations[location_to_check].number:
return True
else:
return False
else:
return True
def initialize_blank_mission_dict(location_table):
unlocks = {}
for mission in list(location_table):
unlocks[mission] = []
return unlocks
if __name__ == '__main__':
colorama.init()
asyncio.run(main())
colorama.deinit()

316
Utils.py
View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import shutil
import typing
import builtins
import os
@@ -11,6 +12,12 @@ import io
import collections
import importlib
import logging
import decimal
if typing.TYPE_CHECKING:
from tkinter import Tk
else:
Tk = typing.Any
def tuplize_version(version: str) -> Version:
@@ -23,10 +30,15 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.2.3"
__version__ = "0.3.3"
version_tuple = tuplize_version(__version__)
from yaml import load, dump, safe_load
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
try:
from yaml import CLoader as Loader
@@ -34,47 +46,50 @@ except ImportError:
from yaml import Loader
def int16_as_bytes(value):
def int16_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFF
return [value & 0xFF, (value >> 8) & 0xFF]
def int32_as_bytes(value):
def int32_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFFFFFF
return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF]
def pc_to_snes(value):
def pc_to_snes(value: int) -> int:
return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000
def snes_to_pc(value):
def snes_to_pc(value: int) -> int:
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
def cache_argsless(function):
if function.__code__.co_argcount:
raise Exception("Can only cache 0 argument functions with this cache.")
RetType = typing.TypeVar("RetType")
result = sentinel = object()
def _wrap():
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
assert not function.__code__.co_argcount, "Can only cache 0 argument functions with this cache."
sentinel = object()
result: typing.Union[object, RetType] = sentinel
def _wrap() -> RetType:
nonlocal result
if result is sentinel:
result = function()
return result
return typing.cast(RetType, result)
return _wrap
def is_frozen() -> bool:
return getattr(sys, 'frozen', False)
return typing.cast(bool, getattr(sys, 'frozen', False))
def local_path(*path):
if local_path.cached_path:
return os.path.join(local_path.cached_path, *path)
def local_path(*path: str) -> str:
"""Returns path to a file in the local Archipelago installation or source."""
if hasattr(local_path, 'cached_path'):
pass
elif is_frozen():
if hasattr(sys, "_MEIPASS"):
# we are running in a PyInstaller bundle
@@ -94,21 +109,47 @@ def local_path(*path):
return os.path.join(local_path.cached_path, *path)
local_path.cached_path = None
def home_path(*path: str) -> str:
"""Returns path to a file in the user home's Archipelago directory."""
if hasattr(home_path, 'cached_path'):
pass
elif sys.platform.startswith('linux'):
home_path.cached_path = os.path.expanduser('~/Archipelago')
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
return os.path.join(home_path.cached_path, *path)
def output_path(*path):
if output_path.cached_path:
def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, 'cached_path'):
pass
elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
# populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
for dn in ('Players', 'data/sprites'):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ('manifest.json', 'host.yaml'):
shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path)
def output_path(*path: str):
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
output_path.cached_path = local_path(get_options()["general_options"]["output_path"])
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
path = os.path.join(output_path.cached_path, *path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
output_path.cached_path = None
def open_file(filename):
if sys.platform == 'win32':
os.startfile(filename)
@@ -117,7 +158,21 @@ def open_file(filename):
subprocess.call([open_command, filename])
parse_yaml = safe_load
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
class UniqueKeyLoader(SafeLoader):
def construct_mapping(self, node, deep=False):
mapping = set()
for key_node, value_node in node.value:
key = self.construct_object(key_node, deep=deep)
if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key)
return super().construct_mapping(node, deep)
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
@@ -168,7 +223,7 @@ def get_default_options() -> dict:
"output_path": "output",
},
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
},
"sm_options": {
"rom_file": "Super Metroid (JU).sfc",
@@ -205,7 +260,7 @@ def get_default_options() -> dict:
},
"generator": {
"teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
@@ -217,7 +272,8 @@ def get_default_options() -> dict:
},
"minecraft_options": {
"forge_directory": "Minecraft Forge server",
"max_heap_size": "2G"
"max_heap_size": "2G",
"release_channel": "release"
},
"oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
@@ -249,8 +305,11 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
@cache_argsless
def get_options() -> dict:
if not hasattr(get_options, "options"):
locations = ("options.yaml", "host.yaml",
local_path("options.yaml"), local_path("host.yaml"))
filenames = ("options.yaml", "host.yaml")
locations = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]
for location in locations:
if os.path.exists(location):
@@ -260,7 +319,7 @@ def get_options() -> dict:
get_options.options = update_options(get_default_options(), options, location, list())
break
else:
raise FileNotFoundError(f"Could not find {locations[1]} to load options.")
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
return get_options.options
@@ -275,7 +334,7 @@ def get_location_name_from_id(code: int) -> str:
def persistent_store(category: str, key: typing.Any, value: typing.Any):
path = local_path("_persistent_storage.yaml")
path = user_path("_persistent_storage.yaml")
storage: dict = persistent_load()
category = storage.setdefault(category, {})
category[key] = value
@@ -287,7 +346,7 @@ def persistent_load() -> typing.Dict[dict]:
storage = getattr(persistent_load, "storage", None)
if storage:
return storage
path = local_path("_persistent_storage.yaml")
path = user_path("_persistent_storage.yaml")
storage: dict = {}
if os.path.exists(path):
try:
@@ -301,63 +360,9 @@ def persistent_load() -> typing.Dict[dict]:
return storage
def get_adjuster_settings(romfile: str, skip_questions: bool = False) -> typing.Tuple[str, bool]:
if hasattr(get_adjuster_settings, "adjuster_settings"):
adjuster_settings = getattr(get_adjuster_settings, "adjuster_settings")
else:
adjuster_settings = persistent_load().get("adjuster", {}).get("last_settings_3", {})
if adjuster_settings:
import pprint
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = romfile
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
sprite_pool = {}
for sprite in getattr(adjuster_settings, "sprite_pool"):
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
if hasattr(get_adjuster_settings, "adjust_wanted"):
adjust_wanted = getattr(get_adjuster_settings, "adjust_wanted")
elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request
return romfile, False
elif skip_questions:
return romfile, False
else:
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no or never: ")
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
elif adjust_wanted and "never" in adjust_wanted:
persistent_store("adjuster", "never_adjust", True)
return romfile, False
else:
adjusted = False
if not hasattr(get_adjuster_settings, "adjust_wanted"):
logging.info(f"Skipping post-patch adjustment")
get_adjuster_settings.adjuster_settings = adjuster_settings
get_adjuster_settings.adjust_wanted = adjust_wanted
return romfile, adjusted
return romfile, False
def get_adjuster_settings(gameName: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
return adjuster_settings
@cache_argsless
@@ -389,7 +394,7 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
@@ -426,9 +431,10 @@ 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]: %(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 = local_path("logs")
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
@@ -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():
@@ -474,3 +482,125 @@ def stream_input(stream, queue):
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
thread.start()
return thread
def tkinter_center_window(window: Tk):
window.update()
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
window.geometry("+{}+{}".format(xPos, yPos))
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
value = decimal.Decimal(value)
while value >= power:
value /= power
n += 1
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]:
limit: int = limit if limit else len(wordlist)
return list(
map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted(
map(lambda candidate:
(candidate, get_fuzzy_ratio(input_word, candidate)),
wordlist),
key=lambda element: element[1],
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

@@ -1,13 +1,17 @@
import os
import sys
import multiprocessing
import logging
import typing
import ModuleUpdate
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
Utils.local_path.cached_path = os.path.dirname(__file__)
from WebHostLib import app as raw_app
@@ -18,7 +22,11 @@ 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
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app():
@@ -32,6 +40,56 @@ def get_app():
return app
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
import shutil
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
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
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
files = os.listdir(source_path)
for file in files:
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
@@ -43,13 +101,13 @@ if __name__ == "__main__":
logging.warning("Could not update LttP sprites.")
app = get_app()
create_options_files()
create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFGEN"]:
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"
@@ -70,6 +70,12 @@ app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
@@ -97,13 +103,13 @@ def weighted_settings():
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game)
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@@ -112,17 +118,21 @@ def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world.__doc__ if world.__doc__ else "No description provided."
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@@ -131,13 +141,17 @@ def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed,
rooms=[room for room in seed.rooms if room.owner == session["_id"]])
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
@@ -161,7 +175,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'])
@@ -202,7 +221,17 @@ def get_datapackge():
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@@ -1,28 +1,33 @@
"""API endpoints package."""
from uuid import UUID
from typing import List, Tuple
from flask import Blueprint, abort
from ..models import Room
from ..models import Room, Seed
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
from . import generate, user # trigger registration
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots]
@api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
return {"tracker": room.tracker,
"players": room.seed.multidata["names"],
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout}
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout
}
@api_endpoints.route('/datapackage')
@@ -39,3 +44,6 @@ def get_datapackge_versions():
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"]
return version_package
from . import generate, user # trigger registration

View File

@@ -45,7 +45,7 @@ def generate_api():
"detail": app.config["MAX_ROLL"]}, 409
meta = get_meta(meta_options_source)
meta["race"] = race
results, gen_options = roll_options(options)
results, gen_options = roll_options(options, meta["plando_options"])
if any(type(result) == str for result in results.values()):
return {"text": str(results),
"detail": results}, 400
@@ -65,7 +65,6 @@ def generate_api():
return {"text": "Uncaught Exception:" + str(e)}, 500
@api_endpoints.route('/status/<suuid:seed>')
def wait_seed_api(seed: UUID):
seed_id = seed

View File

@@ -1,7 +1,7 @@
from flask import session, jsonify
from WebHostLib.models import *
from . import api_endpoints
from . import api_endpoints, get_players
@api_endpoints.route('/get_rooms')
@@ -16,7 +16,6 @@ def get_rooms():
"last_port": room.last_port,
"timeout": room.timeout,
"tracker": room.tracker,
"players": room.seed.multidata["names"] if room.seed.multidata else [["Singleplayer"]],
})
return jsonify(response)
@@ -28,6 +27,6 @@ def get_seeds():
response.append({
"seed_id": seed.id,
"creation_time": seed.creation_time,
"players": seed.multidata["names"] if seed.multidata else [["Singleplayer"]],
"players": get_players(seed.slots),
})
return jsonify(response)

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

@@ -13,11 +13,11 @@ def allowed_file(filename):
from Generate import roll_settings
from Utils import parse_yaml
from Utils import parse_yamls
@app.route('/mysterycheck', methods=['GET', 'POST'])
def mysterycheck():
@app.route('/check', methods=['GET', 'POST'])
def check():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
@@ -30,10 +30,14 @@ def mysterycheck():
else:
results, _ = roll_options(options)
return render_template("checkResult.html", results=results)
return render_template("check.html")
@app.route('/mysterycheck')
def mysterycheck():
return redirect(url_for("check"), 301)
def get_yaml_data(file) -> Union[Dict[str, str], str]:
options = {}
# if user does not select file, browser also
@@ -58,21 +62,29 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
return options
def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = set(plando_options)
results = {}
rolled_results = {}
for filename, text in options.items():
try:
if type(text) is dict:
yaml_data = text
yaml_datas = (text, )
else:
yaml_data = parse_yaml(text)
yaml_datas = tuple(parse_yamls(text))
except Exception as e:
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
else:
try:
rolled_results[filename] = roll_settings(yaml_data,
plando_options={"bosses", "items", "connections", "texts"})
if len(yaml_datas) == 1:
rolled_results[filename] = roll_settings(yaml_datas[0],
plando_options=plando_options)
else:
for i, yaml_data in enumerate(yaml_datas):
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
except Exception as e:
results[filename] = f"Failed to generate mystery in {filename}: {e}"
else:

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

@@ -1,9 +1,12 @@
import zipfile
import json
from io import BytesIO
from flask import send_file, Response, render_template
from pony.orm import select
from Patch import update_patch_data, preferred_endings
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from WebHostLib import app, Slot, Room, Seed, cache
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -12,16 +15,34 @@ def download_patch(room_id, patch_id):
if not patch:
return "Patch not found"
else:
import io
room = Room.get(id=room_id)
last_port = room.last_port
filelike = BytesIO(patch.data)
greater_than_version_3 = zipfile.is_zipfile(filelike)
if greater_than_version_3:
# Python's zipfile module cannot overwrite/delete files in a zip, so we recreate the whole thing in ram
new_file = BytesIO()
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
new_zip.writestr("archipelago.json", json.dumps(manifest))
else:
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = io.BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
new_file.seek(0)
return send_file(new_file, as_attachment=True, attachment_filename=fname)
else:
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@@ -34,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"
@@ -50,13 +71,18 @@ 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":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
else:
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():
@@ -65,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

@@ -5,6 +5,7 @@ import json
import zipfile
from collections import Counter
from typing import Dict, Optional as TypeOptional
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template
@@ -21,11 +22,22 @@ from .upload import upload_zip_to_db
def get_meta(options_source: dict) -> dict:
plando_options = {
options_source.get("plando_bosses", ""),
options_source.get("plando_items", ""),
options_source.get("plando_connections", ""),
options_source.get("plando_texts", "")
}
plando_options -= {""}
meta = {
"hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
"remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
"server_password": options_source.get("server_password", None),
"plando_options": list(plando_options)
}
return meta
@@ -43,14 +55,13 @@ def generate(race=False):
if type(options) == str:
flash(options)
else:
results, gen_options = roll_options(options)
# get form data -> server settings
meta = get_meta(request.form)
meta["race"] = race
results, gen_options = roll_options(options, meta["plando_options"])
if race:
meta["item_cheat"] = False
meta["remaining"] = False
meta["remaining_mode"] = "disabled"
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
@@ -78,7 +89,7 @@ def generate(race=False):
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race)
return render_template("generate.html", race=race, version=__version__)
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
@@ -88,6 +99,8 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
meta.setdefault("hint_cost", 10)
race = meta.get("race", False)
del (meta["race"])
plando_options = meta.get("plando", {"bosses", "items", "connections", "texts"})
del (meta["plando_options"])
try:
target = tempfile.TemporaryDirectory()
playercount = len(gen_options)
@@ -107,6 +120,7 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = ", ".join(plando_options)
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
@@ -120,7 +134,8 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
if not erargs.name[player]:
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta)
return upload_to_db(target.name, sid, owner, race)

View File

@@ -2,7 +2,7 @@ import os
import threading
import json
from Utils import local_path
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
@@ -14,8 +14,8 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites
# Target directories
input_dir = local_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated")
input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
# update sprites through gui.py's functions

View File

@@ -12,7 +12,7 @@ STATE_ERROR = -1
class Slot(db.Entity):
id = PrimaryKey(int, auto=True)
player_id = Required(int)
player_name = Required(str, 16)
player_name = Required(str)
data = Optional(bytes, lazy=True)
seed = Optional('Seed')
game = Required(str)

View File

@@ -1,25 +1,47 @@
import logging
import os
from Utils import __version__
from jinja2 import Template
import yaml
import json
import typing
from worlds.AutoWorld import AutoWorldRegister
import Options
target_folder = os.path.join("WebHostLib", "static", "generated")
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"}
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):
@@ -59,10 +81,13 @@ def create():
game_options = {}
for option_name, option in all_options.items():
if option.options:
if option_name in handled_in_js:
pass
elif option.options:
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": None,
"options": []
@@ -82,16 +107,52 @@ 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.displayname if hasattr(option, "displayname") else option_name,
"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",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
}
elif getattr(option, "verify_location_name", False):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
}
elif hasattr(option, "valid_keys"):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"options": list(option.valid_keys),
}
else:
logging.debug(f"{option} not exported to Web Settings.")
player_settings["gameOptions"] = game_options
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
@@ -99,7 +160,7 @@ def create():
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
json.dump(player_settings, f, indent=2, separators=(',', ': '))
if not world.hidden:
if not world.hidden and world.web.settings_page is True:
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options

View File

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

View File

@@ -1,52 +1,69 @@
# Frequently Asked Questions
## What is a randomizer?
A randomizer is a modification of a video game which reorganizes the items required to progress through the game.
A normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a
randomized game, you might first find item C, then A, then B.
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
game, you might first find item C, then A, then B.
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
play a randomized game. Putting items in non-standard locations can require the player to think about the game world
and the items they encounter in new and interesting ways.
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
the items they encounter in new and interesting ways.
## What happens if an item is placed somewhere it is impossible to get?
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these
rules is to ensure items necessary to complete the game will be accessible to the player. Many games also have a
subset of rules allowing certain items to be placed in normally unreachable locations, provided the player has
indicated they are comfortable exploiting certain glitches in the game.
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example,
in a two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
player B, the item will be sent to player B's world over the internet.
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all
the items in that game which belong to other players are sent out automatically, so other players can continue to
play.
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world
allows players to randomize any of a number of supported games, and send items between them. This allows players of
different games to interact with one another in a single multiplayer environment.
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
players to randomize any of a number of supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment.
## Can I generate a single-player game with Archipelago?
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
the website is not required to generate them.
## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others,
please join our [Discord server](https://discord.gg/8Z65BR2). There are always people ready to answer any questions
you might have.
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our [code on GitHub](https://github.com/ArchipelagoMW/Archipelago).
There, you will find examples of games in the [worlds](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds)
folder, as well as some [documentation](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs) on our
network interfaces.
The best way to get started is to take a look at our code on GitHub
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
There you will find examples of games in the worlds folder
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
You may also find developer documentation in the docs folder
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -0,0 +1,94 @@
# Multiworld Glossary
There are a lot of common terms used when playing in different game randomizer communities and in multiworld as well.
This document serves as a lookup for common terms that may be used by users in the community or in various other
documentation.
## Item
Items are what get shuffled around in your world or other worlds that you then receive. This could be a sword, a stat
upgrade, a spell, or any other potential receivable for your game.
## Location
Locations are where items are placed in your game. Whenever you interact with a location, you or another player will
then receive an item. A location could be a chest, an enemy drop, a shop purchase, or any other interactable that can
contain items in your game.
## Check
A check is a common term for when you "check", or pick up, a location. In terms of Archipelago this is usually used for
when a player goes to a location and sends its item, or "checks" the location. Players will often reference their now
randomized locations as checks.
## Slot
A slot is the player name and number assigned during generation. The number of slots is equal to the number of players,
or "worlds", created. Each name must be unique as these are used to identify the slot user.
## World
World in terms of Archipelago can mean multiple things and is used interchangeably in many situations.
* During gameplay, a world is a single instance of a game, occupying one player "slot". However,
Archipelago allows multiple players to connect to the same slot; then those players can share a world
and complete it cooperatively. For games with native cooperative play, you can also play together and
share a world that way, usually with only one player connected to the multiworld.
* On the programming side, a world typically represents the package that integrates Archipelago with a
particular game. For example this could be the entire `worlds/factorio` directory.
## RNG
Acronym for "Random Number Generator." Archipelago uses its own custom Random object with a unique seed per generation,
or, if running from source, a seed can be supplied and this seed will control all randomization during generation as all
game worlds will have access to it.
## Seed
A "seed" is a number used to initialize a pseudorandom number generator. Whenever you generate a new game on Archipelago
this is a new "seed" as it has unique item placement, and you can create multiple "rooms" on the Archipelago site from a
single seed. Using the same seed results in the random placement being the same.
## Room
Whenever you generate a seed on the Archipelago website you will be put on a seed page that contains all the seed info
with a link to the spoiler if one exists and will show how many unique rooms exist per seed. Each room has its own
unique identifier that is separate from the seed. The room page is where you can find information to connect to the
multiworld and download any patches if necessary. If you have a particularly fun or interesting seed, and you want to
share it with somebody you can link them to this seed page, where they can generate a new room to play it! For seeds
generated with race mode enabled, the seed page will only show rooms created by the unique user so the seed page is
perfectly safe to share for racing purposes.
## Logic
Base behavior of all seeds generated by Archipelago is they are expected to be completable based on the requirements of
the settings. This is done by using "logic" in order to determine valid locations to place items while still being able
to reach said location without this item. For the purposes of the randomizer a location is considered "in logic" if you
can reach it with your current toolset of items or skills based on settings. Some players are able to obtain locations
"out of logic" by performing various glitches or tricks that the settings may not account for and tend to mention this
when sending out an item they obtained this way.
## Progression
Certain items will allow access to more locations and are considered progression items as they "progress" the seed.
## Trash
A term used for "filler" items that have no bearing on the generation and are either marginally useful for the player
or useless. These items can be very useful depending on the player but are never very important and as such are usually
termed trash.
## Burger King / BK Mode
A term used in multiworlds when a player is unable to continue to progress and is awaiting an item. The term came to be
after a player, allegedly, was unable to progress during a multiworld and went to Burger King while waiting to receive
items from other players.
* "Logical BK" is when the player is unable to progress according to the settings of their game but may still be able to do
things that would be "out of logic" by the generation.
* "Hard / full BK" is when the player is completely unable to progress even with tricks they may know and are unable to
continue to play, aside from doing something like killing enemies for experience or money.
## Sphere
Archipelago calculates the game playthrough by using a "sphere" system where it has a state for each player and checks
to see what the players are able to reach with their current items. Any location that is reachable with the current
state of items is a "sphere." For the purposes of Archipelago it starts playthrough calculation by distributing sphere 0
items which are items that are either forced in the player's inventory by the game or placed in the `start_inventory` in
their settings. Sphere 1 is then all accessible locations the players can reach with all the items they received from
sphere 0, or their starting inventory. The playthrough continues in this fashion calculating a number of spheres until
all players have completed their goal.
## Scouts / Scouting
In some games there are locations that have visible items even if the item itself is unobtainable at the current time.
Some games utilize a scouting feature where when the player "sees" the item it will give a free hint for the item in the
client letting the players know what the exact item is, since if the item was for that game it would know but the item
being foreign is a lot harder to represent visually.

View File

@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/assets/gameInfo/` +
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
ajax.send();
}).then((results) => {

View File

@@ -1,21 +0,0 @@
# Final Fantasy 1 (NES)
## Where is the settings page?
Unlike most games on Archipelago.gg, Final Fantasy 1's settings are controlled entirely by the original randomzier.
You can find an exhaustive list of documented settings [here](https://finalfantasyrandomizer.com/)
## What does randomization do to this game?
A better questions is what isn't randomized at this point. Enemies stats and spell, character spells, shop inventory
and boss stats and spells are all commonly randomized. Unlike most other randomizers it is also most standard to shuffle
progression items and non-progression items into separate pools and then redistribute them to their respective
locations. So ,for example, Princess Sarah may have the CANOE instead of the LUTE; however, she will never have a Heal
Pot or some armor. There are plenty of other things that can be randomized on our
[main randomizer site](https://finalfantasyrandomizer.com/)
## What Final Fantasy items can appear in other players' worlds?
All items can appear in other players worlds. This includes consumables, shards, weapons, armor and, of course,
key items.
## What does another world's item look like in Final Fantasy
All local and remote items appear the same. It will say that you received an item and then BOTH the client log and
the emulator will display what was found external to the in-game text box.

View File

@@ -1,22 +0,0 @@
# Rogue Legacy (PC)
## Where is the settings page?
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
you need to configure and export a config file.
## What does randomization do to this game?
You are not able to buy skill upgrades in the manor upgrade screen, and instead, need to find them in order to level up
your character to make fighting the 5 bosses easier.
## What items and locations get shuffled?
All the skill upgrades, class upgrades, runes packs, and equipment packs are shuffled in the manor upgrade screen,
diary checks, chests and fairy chests, and boss rewards. Skill upgrades are also grouped in packs of 5 to make the
finding of stats less of a chore. Runes and Equipment are also grouped together.
## Which items can be in another player's world?
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
limit certain items to your own world.
## When the player receives an item, what happens?
When the player receives an item, your character will hold the item above their head and display it to the world. It's
good for business!

View File

@@ -1,28 +0,0 @@
# Timespinner
## Where is the settings page?
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
you need to configure and export a config file.
## What does randomization do to this game?
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
they would in the vanilla game. All rings and spells are also randomized into those item locations, therefor you can no longer craft them at the alchemist
## What is the goal of Timespinner when randomized?
The goal remains unchanged. Kill the Sandman\Nightmare!
## What items and locations get shuffled?
All main inventory items, orbs, collectables, and familiers can be shuffled, and all locations in the game which could
contain any of those items may have their contents changed.
## Which items can be in another player's world?
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
limit certain items to your own world.
## What does another world's item look like in Timespinner?
Items belonging to other worlds are represented by the vanilla item [Elemental Beads](https://timespinnerwiki.com/Use_Items), Elemental Beads have no use in the randomizer
## When the player receives an item, what happens?
When the player receives an item, the same items popup will be displayed as when you would normally obtain the item

View File

@@ -0,0 +1,53 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('glossary-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the glossary page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the glossary.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
for (let i=0; i < headers.length; i++){
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
headers[i].setAttribute('id', headerId);
headers[i].addEventListener('click', () =>
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
}
// Manually scroll the user to the appropriate header if anchor navigation is used
if (scrollTargetIndex > -1) {
try{
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
}
}
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -6,26 +6,24 @@ window.addEventListener('load', () => {
// Update game name on page
document.getElementById('game-name').innerText = gameName;
Promise.all([fetchSettingData()]).then((results) => {
fetchSettingData().then((results) => {
let settingHash = localStorage.getItem(`${gameName}-hash`);
if (!settingHash) {
// If no hash data has been set before, set it now
localStorage.setItem(`${gameName}-hash`, md5(results[0]));
settingHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, settingHash);
localStorage.removeItem(gameName);
settingHash = md5(results[0]);
}
if (settingHash !== md5(results[0])) {
const userMessage = document.getElementById('user-message');
userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.";
userMessage.style.display = "block";
userMessage.addEventListener('click', resetSettings);
if (settingHash !== md5(JSON.stringify(results))) {
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.");
document.getElementById('user-message').addEventListener('click', resetSettings);
}
// Page setup
createDefaultSettings(results[0]);
buildUI(results[0]);
createDefaultSettings(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
@@ -38,7 +36,8 @@ window.addEventListener('load', () => {
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((error) => {
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
@@ -160,9 +159,72 @@ 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(`Unknown setting type: ${settings[setting].type}`);
console.error(setting);
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;
}
@@ -191,7 +253,9 @@ const updateGameSetting = (event) => {
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
@@ -208,21 +272,41 @@ const download = (filename, text) => {
};
const generateGame = (raceMode = false) => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: localStorage.getItem(gameName) },
presetData: { player: localStorage.getItem(gameName) },
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage.innerText += ' ' + error.response.data.text;
userMessage += ' ' + error.response.data.text;
}
userMessage.classList.add('visible');
window.scrollTo(0, 0);
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/` +
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();

View File

@@ -1,32 +0,0 @@
# Subnautica Randomizer Setup Guide
## Required Software
- [Subnautica](https://store.steampowered.com/app/264710/Subnautica/)
- [QModManager4](https://www.nexusmods.com/subnautica/mods/201)
- [Archipelago Mod for Subnautica](https://github.com/Berserker66/ArchipelagoSubnauticaModSrc/releases)
## Installation Procedures
1. Install QModManager4 as per its instructions.
2. The folder you installed QModManager4 into will now have a /QMods directory. It might appear after a start of Subnautica. You can also create this folder yourself.
3. Unpack the Archipelago Mod into this folder, so that Subnautica/QMods/Archipelago/ is a valid path.
4. Start Subnautica. You should see a Connect Menu in the topleft of your main Menu.
## Troubleshooting
If you don't see the connect window check that you see a qmodmanager_log-Subnautica.txt in Subnautica, if not QModManager4 is not correctly installed, otherwise open it and look for `[Info : BepInEx] Loading [Archipelago 1.0.0.0]`, version number doesn't matter. If it doesn't show this, then QModManager4 didn't find the Archipelago mod, so check your paths.
## Joining a MultiWorld Game
1. In Host, enter the address of the server, such as archipelago.gg:38281, your server host should be able to tell you this.
2. In Password enter the server password if one exists, otherwise leave blank.
3. In PlayerName enter your "name" field from the yaml, or website config.
4. Hit Connect. If it says succesfully authenticated you can now create a new savegame or resume the correct savegame.

View File

@@ -1,160 +0,0 @@
# Advanced Game Options Guide
The Archipelago system generates games using player configuration files as input. Generally these are going to be
YAML files and each player will have one of these containing their custom settings for the randomized game they want to play.
On the website when you customize your settings from one of the game player settings pages which you can reach from the
[supported games page](/games). Clicking on the export settings button at the bottom will provide you with a pre-filled out
YAML with your options. The player settings page also has an option to download a fully filled out yaml containing every
option with every available setting for the available options.
## YAML Formatting
YAML files are a format of <span data-tooltip="Allegedly.">human-readable</span> markup config files. The basic syntax
of a yaml file will have `root` and then different levels of `nested` text that the generator "reads" in order to determine
your settings. To nest text, the correct syntax is **two spaces over** from its root option. A YAML file can be edited
with whatever text editor you choose to use though I personally recommend that you use [Sublime Text](https://www.sublimetext.com/).
This program out of the box supports the correct formatting for the YAML file, so you will be able to tab and get proper
highlighting for any potential errors made while editing the file. If using any other text editor such as Notepad or
Notepad++ whenever you move to nest an option that it is done with two spaces and not tabs.
Typical YAML format will look as follows:
```yaml
root_option:
nested_option_one:
option_one_setting_one: 1
option_one_setting_two: 0
nested_option_two:
option_two_setting_one: 14
option_two_setting_two: 43
```
In Archipelago YAML options are always written out in full lowercase with underscores separating any words. The numbers
following the colons here are weights. The generator will read the weight of every option the roll that option that many
times, the next option as many times as its numbered and so forth. For the above example `nested_option_one` will have
`option_one_setting_one` 1 time and `option_one_setting_two` 0 times so `option_one_setting_one` is guaranteed to occur.
For `nested_option_two`, `option_two_setting_one` will be rolled 14 times and `option_two_setting_two` will be rolled 43
times against each other. This means `option_two_setting_two` will be more likely to occur but it isn't guaranteed adding
more randomness and "mystery" to your settings. Every configurable setting supports weights.
### Root Options
Currently there are only a few options that are root options. Everything else should be nested within one of these root
options or in some cases nested within other nested options. The only options that should exist in root are `description`,
`name`, `game`, `requires`, `accessibility`, `progression_balancing`, `triggers`, and the name of the games you want
settings for.
* `description` is ignored by the generator and is simply a good way for you to organize if you have multiple files using
this to detail the intention of the file.
* `name` is the player name you would like to use and is used for your slot data to connect with most games. This can also
be filled with multiple names each having a weight to it.
* `game` is where either your chosen game goes or if you would like can be filled with multiple games each with different
weights.
* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this
is good for detailing the version of Archipelago this YAML was prepared for as if it is rolled on an older version may be
missing settings and as such will not work as expected. If any plando is used in the file then requiring it here to ensure
it will be used is good practice.
* `accessibility` determines the level of access to the game the generation will expect you to have in order to reach your
completion goal. This supports `items`, `locations`, and `none` and is set to `locations` by default.
* `items` will guarantee you can acquire all items in your world but may not be able to access all locations. This mostly
comes into play if there is any entrance shuffle in the seed as locations without items in them can be placed in areas
that make them unreachable.
* `none` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but
may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest
in a dungeon in ALTTP making it impossible to get and finish the dungeon.
* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. This
primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that
players almost always have something to do. This can be turned `on` or `off` and is `on` by default.
* `triggers` is one of the more advanced options that allows you to create conditional adjustments. You can read more
about this [here](/tutorial/archipelago/triggers/en).
### Game Options
One of your root settings will be the name of the game you would like to populate with settings in the format
`GameName`. since it is possible to give a weight to any option it is possible to have one file that can generate a seed
for you where you don't know which game you'll play. For these cases you'll want to fill the game options for every game
that can be rolled by these settings. If a game can be rolled it **must** have a settings section even if it is empty.
#### Universal Game Options
Some options in Archipelago can be used by every game but must still be placed within the relevant game's section.
Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints`,
`exclude_locations`, and various [plando options](/tutorial/archipelago/plando/en).
* `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be
the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which
will give you 30 rupees.
* `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for
the location without using any hint points.
* `local_items` will force any items you want to be in your world instead of being in another world.
* `non_local_items` is the inverse of `local_items` forcing any items you want to be in another world and won't be located
in your own.
* `start_location_hints` allows you to define a location which you can then hint for to find out what item is located in
it to see how important the location is.
* `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.
### Example
```yaml
description: An example using various advanced options
name: Example Player
game: A Link to the Past
requires:
version: 0.2.0
accessibility: none
progression_balancing: on
A Link to the Past:
smallkey_shuffle:
original_dungeon: 1
any_world: 1
start_inventory:
Pegasus Boots: 1
Bombs (3): 2
start_hints:
- Hammer
local_items:
- Bombos
- Ether
- Quake
non_local_items:
- Moon Pearl
start_location_hints:
- Spike Cave
exclude_locations:
- Cave 45
triggers:
- option_category: A Link to the Past
option_name: smallkey_shuffle
option_result: any_world
options:
A Link to the Past:
bigkey_shuffle: any_world
map_shuffle: any_world
compass_shuffle: any_world
```
#### 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.
* `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 meaning we will likely receive important items earlier increasing the chance of having
things to do.
* `A Link to the Past` defines a location for us to nest all the game options we would like to use for our game `A Link to the Past`.
* `smallkey_shuffle` is an option for A Link to the Past which determines how dungeon small keys are shuffled. In this example
we have a 1/2 chance for them to either be placed in their original dungeon and a 1/2 chance for them to be placed anywhere
amongst the multiworld.
* `start_inventory` defines an area for us to determine what items we would like to start the seed with. For this example
we have:
* `Pegasus Boots: 1` which gives us 1 copy of the Pegasus Boots
* `Bombs (3)` gives us 2 packs of 3 bombs or 6 total bombs
* `start_hints` gives us a starting hint for the hammer available at the beginning of the multiworld which we can use with no cost.
* `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we
have to find it ourselves.
* `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it.
* `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.
* `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.

View File

@@ -1,180 +0,0 @@
# Archipelago Plando Guide
## What is Plando?
The purposes of randomizers is to randomize the items in a game to give a new experience.
Plando takes this concept and changes it up by allowing you to plan out certain aspects of the game by placing certain
items in certain locations, certain bosses in certain rooms, edit text for certain NPCs/signs, or even force certain region
connections. Each of these options are going to be detailed separately as `item plando`, `boss plando`, `text plando`,
and `connection plando`. Every game in archipelago supports item plando but the other plando options are only supported
by certain games. Currently, Minecraft and LTTP both support connection plando, but only LTTP supports text and boss plando.
### Enabling Plando
On the website plando will already be enabled. If you will be generating the game locally plando features must be enabled (opt-in).
* To opt-in go to the archipelago installation (default: `C:\ProgramData\Archipelago`), open the host.yaml with a text
editor and find the `plando_options` key. The available plando modules can be enabled by adding them after this such as
`plando_options: bosses, items, texts, connections`.
## Item Plando
Item plando allows a player to place an item in a specific location or specific locations, place multiple items into
a list of specific locations both in their own game or in another player's game. **Note that there's a very good chance that
cross-game plando could very well be broken i.e. placing on of your items in someone else's world playing a different game.**
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, and either item and location, or items and locations.
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or false
and defaults to true if omitted.
* `world` is the target world to place the item in.
* It gets ignored if only one world is generated.
* Can be a number, name, true, false, or null. False is the default.
* If a number is used it targets that slot or player number in the multiworld.
* If a name is used it will target the world with that player name.
* If set to true it will be any player's world besides your own.
* If set to false it will target your own world.
* If set to null it will target a random world in the multiworld.
* `force` determines whether the generator will fail if the item can't be placed in the location can be true, false,
or silent. Silent is the default.
* If set to true the item must be placed and the generator will throw an error if it is unable to do so.
* If set to false the generator will log a warning if the placement can't be done but will still generate.
* If set to silent and the placement fails it will be ignored entirely.
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and if
omitted will default to 100.
* Single Placement is when you use a plando block to place a single item at a single location.
* `item` is the item you would like to place and `location` is the location to place it.
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
* `items` defines the items to use and a number letting you place multiple of it.
* `locations` is a list of possible locations those items can be placed in.
* Using the multi placement method, placements are picked randomly.
### Available Items
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
* [Factorio Non-Progressive](https://wiki.factorio.com/Technologies) Note that these use the *internal names*. For example, `advanced-electronics`
* [Factorio Progressive](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/factorio/Technologies.py#L374)
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Items.py#L14)
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py#L61)
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Items.py#L8)
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Items.py#L13)
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/items.json)
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Items.py#L11)
### Available Locations
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L429)
* [Factorio](https://wiki.factorio.com/Technologies) Same as items
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Locations.py#L18)
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LocationList.py#L38)
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Locations.py#L17) This is a special
case. The locations are "ItemPickup[number]" up to the maximum set in the yaml.
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Locations.py)
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/locations.json)
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Locations.py#L13)
A list of all available items and locations can also be found in the [server's datapackage](/api/datapackage).
### Examples
```yaml
plando_items:
# example block 1 - Timespinner
- item:
Empire Orb: 1
Radiant Orb: 1
location: Starter chest 1
from_pool: true
world: true
percentage: 50
# example block 2 - Ocarina of Time
- items:
Kokiri Sword: 1
Biggoron Sword: 1
Bow: 1
Magic Meter: 1
Progressive Strength Upgrade: 3
Progressive Hookshot: 2
locations:
- Deku Tree Slingshot Chest
- Dodongos Cavern Bomb Bag Chest
- Jabu Jabus Belly Boomerang Chest
- Bottom of the Well Lens of Truth Chest
- Forest Temple Bow Chest
- Fire Temple Megaton Hammer Chest
- Water Temple Longshot Chest
- Shadow Temple Hover Boots Chest
- Spirit Temple Silver Gauntlets Chest
world: false
# example block 3 - Slay the Spire
- items:
Boss Relic: 3
locations:
Boss Relic 1
Boss Relic 2
Boss Relic 3
# example block 4 - Factorio
- items:
progressive-electric-energy-distribution: 2
electric-energy-accumulators: 1
progressive-turret: 2
locations:
military
gun-turret
logistic-science-pack
steel-processing
percentage: 80
force: true
```
1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another player's
Starter Chest 1 and removes the chosen item from the item pool.
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
in their own dungeon major item chests.
3. This block will always trigger and will lock boss relics on the bosses.
4. This block has an 80% chance of occuring and when it does will place all but 1 of the items randomly among the four
locations chosen here.
## Boss Plando
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
[relevant guide](/tutorial/zelda3/plando/en)
## Text Plando
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
[relevant guide](/tutorial/zelda3/plando/en)
## Connections Plando
This is currently only supported by Minecraft and A Link to the Past. As the way that these games interact with
their connections is different I will only explain the basics here while more specifics for Link to the Past connection
plando can be found in its plando guide.
* The options for connections are `percentage`, `entrance`, `exit`, and `direction`. Each of these options support subweights.
* `percentage` is the percentage chance for this connection from 0 to 100 and defaults to 100.
* Every connection has an `entrance` and an `exit`. These can be unlinked like in A Link to the Past insanity entrance shuffle.
* `direction` can be `both`, `entrance`, or `exit` and determines in which direction this connection will operate.
[Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Regions.py#L62)
### Examples
```yaml
plando_connections:
# example block 1 - Link to the Past
- entrance: Cave Shop (Lake Hylia)
exit: Cave 45
direction: entrance
- entrance: Cave 45
exit: Cave Shop (Lake Hylia)
direction: entrance
- entrance: Agahnims Tower
exit: Old Man Cave Exit (West)
direction: exit
# example block 2 - Minecraft
- entrance: Overworld Structure 1
exit: Nether Fortress
direction: both
- entrance: Overworld Structure 2
exit: Village
direction: both
```
1. These connections are decoupled so going into the lake hylia cave shop will take you to the inside of cave 45 and
when you leave the interior you will exit to the cave 45 ledge. Going into the cave 45 entrance will then take you to the
lake hylia cave shop. Walking into the entrance for the old man cave and Agahnim's Tower entrance will both take you to
their locations as normal but leaving old man cave will exit at Agahnim's Tower.
2. This will force a nether fortress and a village to be the overworld structures for your game. Note that for the Minecraft
connection plando to work structure shuffle must be enabled.

View File

@@ -1,79 +0,0 @@
# Archipelago Setup Guide
## Installing the Archipelago software
The most recent public release of Archipelago can be found [here](https://github.com/ArchipelagoMW/Archipelago/releases).
Run the exe file, and after accepting the license agreement you will be prompted on which components you would like to install.
The generator allows you to generate multiworld games on your computer. The ROM setups are optional but are required if
anyone in the game that you generate wants to play any of those games as they are needed to generate the relevant patch
files. The server will allow you to host the multiworld on your machine but this also requires you to port forward. The
default port for Archipelago is `38281`. If you are unsure how to do this there are plenty of other guides on the internet
that will be more suited to your hardware. The `Clients` are what you use to connect your game to the multiworld. If the
game/games you plan to play are available here go ahead and install these as well. If the game you choose to play is
supported by Archipelago but not listed in the installation check the relevant tutorial.
## Generating a game
### Creating a YAML
In a multiworld there must be one YAML per world. Any number of players can play on each world using either the game's
native coop system or using archipelago's coop support. Each world will hold one slot in the multiworld and will have a
slot name and, if the relevant game requires it, files to associate it with that multiworld. If multiple people plan to
play in one world cooperatively then they will only need one YAML for their coop world, but if each player is planning on
playing their own game then they will each need a YAML. These YAML files can be generated by going to the relevant game's
player settings page, entering the name they want to use for the game, setting the options to what they would like to
play with and then clicking on the export settings button. This will then download a YAML file that will contain all of
these options and this can then be given to whoever is going to generate the game.
### Gather all player YAMLS
All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with.
A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file.
Each player can go to the game's player settings page in order to determine the settings how they want them and then download a YAML file containing these settings.
After getting the YAML files of each participant for your multiworld game, these can all either be placed together in the
`Archipelago\Players` folder or compressed into a ZIP folder to then be uploaded to the [website generator](/generate).
If rolling locally ensure that the folder is clear of any files you do not wish to include in the game such as the
included default player settings files.
#### Changing local host settings for generation
Sometimes there are various settings that you may want to change before rolling a seed such as enabling race mode,
auto-forfeit, plando support, or setting a password. All of these settings plus other options are able to be changed by
modifying the `host.yaml` file in the base `Archipelago` folder. The settings chosen here are baked into
the serverdata file that gets output with the other files after generation so if rolling locally ensure this file is edited
to your liking *before* rolling the seed.
### Rolling the seed
#### On the Website
After gathering the YAML files together in one location, select all of the files and compress them into a .zip folder.
Next go to the [Start Playing](/start-playing) page and click on `generate a randomized game` to reach the website generator.
Here, you can adjust some server settings such as forfeit rules and the cost for a player to use a hint before generation.
After adjusting the host settings to your liking click on the Upload File button and using the explorer window that opens,
navigate to the location where you zipped the player files and upload this zip. The page will generate your game and refresh
multiple times to check on completion status. After the generation completes you will be on a Seed Info page that provides
the seed, the date/time of creation, a link to the spoiler log, if available, and links to any rooms created from this seed.
To begin playing, click on `Create New Room`, which will take you to the room page. From here you can navigate back to thse
Seed Info page or to the Tracker page. Sharing the link to this page with your friends will provide them with the
necessary info and files for them to connect to the multiworld.
#### Rolling using the generation program
After gathering the YAML files together in the `Archipelago\Players` folder, run the program `ArchipelagoGenerate.exe`
in the base `Archipelago` folder. This will then open a console window and either silently close itself or spit out an
error. If you receive an error, it is likely due to an error in the YAML file. If the error is unhelpful in figuring
out the issue asking in the ***#tech-support*** channel of our Discord for help with finding it is highly recommended.
The generator will put a zip folder into your `Archipelago\output` folder with the format `AP_XXXXXXXXX`.zip.
This contains the patch files and relevant mods for the players as well as the serverdata for the host.
## Hosting a multiworld
### Uploading the seed to the website
The easiest and most recommended method is to generate the game on the website which will allow you to create a private
room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games.
If for some reason the seed was rolled on a machine, then either the resulting zip file or the resulting `AP_XXXXX.archipelago`
inside the zip file can be uploaded to the [upload page](/uploads). This will give a page with the seed info and have a
link to the spoiler if it exists. Click on Create New room and then share the link for the room with the other players
so that they can download their patches or mods. The room will also have a link to a Multiworld Tracker and tell you
what the players need to connect to from their clients.
### Hosting a seed locally
For this we'll assume you have already port forwarding `38281` and have generated a seed that is still in the `outputs`
folder. Next, you'll want to run `ArchipelagoServer.exe`. A window will open in order to open the multiworld data for the
game. You can either use the generated zip folder or extract the .archipelago file and use it. If everything worked correctly the console window should tell you it's now hosting a game with the IP, port, and password that clients will need in order to connect.
Extract the patch and mod files then send those to your friends, and you're done!

View File

@@ -1,69 +0,0 @@
# Archipelago Triggers Guide
## What are triggers?
Triggers allow you to customize your game settings by allowing you to define certain options or even a variety of
settings to occur or "trigger" under certain conditions. These are essentially "if, then statements" for options in your game.
A good example of what you can do with triggers is the custom
[mercenary mode](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
that was created using entirely triggers and plando. For more information on plando you can reference
[this guide](/tutorial/archipelago/plando/en) or [this guide](/tutorial/zelda3/plando/en).
## Trigger use
Triggers have to be defined in the root of the yaml file meaning it must be outside of a game section.
The best place to do this is the bottom of the yaml.
- Triggers comprise of the trigger section and then each trigger must have an `option_category`, `option_name`, and
`option_result` from which it will react to and then an `options` section where the definition of what will happen.
- `option_category` is the defining section from which the option is defined in.
- Example: `A Link to the Past`
- This is the root category the option is located in. If the option you're triggering off of is in root then you
would use `null`, otherwise this is the game for which you want this option trigger to activate.
- `option_name` is the option setting from which the triggered choice is going to react to.
- Example: `shop_item_slots`
- This can be any option from any category defined in the yaml file in either root or a game section except for `game`.
- `option_result` is the result of this option setting from which you would like to react.
- Example: `15`
- Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple
results you would need multiple triggers for this.
- `options` is where you define what will happen when this is detected. This can be something as simple as ensuring
another option also gets selected or placing an item in a certain location.
- Example:
```yaml
A Link to the Past:
start_inventory:
Rupees (300): 2
```
This format must be:
```yaml
root option:
option to change:
desired result
```
### Examples
The above examples all together will end up looking like this:
```yaml
triggers:
- option_category: A Link to the Past
option_name: shop_item_slots
option_result: 15
options:
A Link to the Past:
start_inventory:
Rupees(300): 2
```
For this example if the generator happens to roll 15 shuffled in shop item slots for your game you'll be granted 600 rupees at the beginning.
These can also be used to change other options.
For example:
```yaml
triggers:
- option_category: Timespinner
option_name: SpecificKeycards
option_result: true
options:
Timespinner:
Inverted: true
```
In this example if your world happens to roll SpecificKeycards then your game will also start in inverted.

View File

@@ -1,128 +0,0 @@
# Factorio Randomizer Setup Guide
## Required Software
##### Players
- [Factorio](https://factorio.com) - Needed by Players and Hosts
##### Server Hosts
- [Factorio](https://factorio.com) - Needed by Players and Hosts
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - Needed by Hosts
## Create a Config (.yaml) File
### What is a config file and why do I need one?
Your config file contains a set of configuration options which provide the generator with information about how it
should generate your game. Each player of a multiworld will provide their own config file. This setup allows each
player to enjoy an experience customized for their taste, and different players in the same multiworld can all have
different options.
### Where do I get a config file?
The [Player Settings](/games/Factorio/player-settings) page on the website allows you to configure
your personal settings and export a config file from them.
### Verifying your config file
If you would like to validate your config file to make sure it works, you may do so on
the [YAML Validator](/mysterycheck) page.
## Connecting to Someone Else's Factorio Game
Connecting to someone else's game is the simplest way to play Factorio with Archipelago. It allows multiple
people to play in a single world, all contributing to the completion of the seed.
1. Acquire the Archipelago mod for this seed. It should be named `AP_*.zip`, where `*` is the seed number.
2. Copy the mod file into your Factorio `mods` folder, which by default is located at:
`C:\Users\YourName\AppData\Roaming\Factorio\mods`
3. Get the server address from the person hosting the game you are joining
4. Launch Factorio
5. Click on "Multiplayer" in the main menu
6. Click on "Connect to address"
7. Enter the address into this box
8. Click "Connect"
## Prepare to Host Your Own Factorio Game
### Defining Some Terms
In Archipelago, multiple Factorio worlds may be played simultaneously. Each of these worlds must be hosted by a
Factorio server, which is connected to the Archipelago Server via middleware.
This guide uses the following terms to refer to the software:
- **Factorio Client** - The Factorio instance which will be used to play the game.
- **Factorio Server** - The Factorio instance which will be used to host the Factorio world. Any number of
Factorio Clients may connect to this server.
- **Archipelago Client** - The middleware software used to connect the Factorio Server to the Archipelago Server.
- **Archipelago Server** - The central Archipelago server, which connects all games to each other.
### What a Playable State Looks Like
- An Archipelago Server
- The generated Factorio Mod, created as a result of running `ArchipelagoGenerate.exe`
- One running instance of `ArchipelagoFactorioClient.exe` (the Archipelago Client) per Factorio world
- A running modded Factorio Server, which should have been started by the Archipelago Client automatically
- A running modded Factorio Client
### Dedicated Server Setup
To play Factorio with Archipelago, a dedicated server setup is required. This dedicated Factorio Server must be
installed separately from your main Factorio Client installation. The recommended way to install two instances
of Factorio on your computer is to download the Factorio installer file directly from
[factorio.com](https://factorio.com/download).
#### If you purchased Factorio on Steam, GOG, etc.
You can register your copy of Factorio on [factorio.com](https://factorio.com/). You will be required to
create an account, if you have not done so already. As part of that process, you will be able to enter your
Factorio product code. This will allow you to download the game directly from their website.
#### Download the Standalone Version
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).<br />
<img src="/static/assets/tutorial/factorio/factorio-download.png" />
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`.
You will be asked to log-in to your Factorio account using the same credentials you used on Factorio's website.
After you 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:
```yaml
factorio_options:
executable: C:\\factorio\\bin\\x64\\factorio"
```
With all that complete, you are now able to...
## Host Your Own Factorio Game
1. Obtain the Factorio mod for this Archipelago seed. It should be named `AP_*.zip`, where `*` is the seed number.
2. Install the mod into your Factorio Server by copying the zip file into the `mods` folder.
3. Install the mod into your Factorio Client by copying the zip file into the `mods` folder, which is likely located
at `C:\Users\YourName\AppData\Roaming\Factorio\mods`.
4. Obtain the Archipelago Server address from the website's host room, or from the server host.
5. Run your Archipelago Client, which is named `ArchilepagoFactorioClient.exe`. This was installed along with
Archipelago if you chose to include it during the installation process.
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
<br /><img src="/static/assets/tutorial/factorio/connect-to-ap-server.png" />
7. Launch your Factorio Client
8. Click on "Multiplayer" in the main menu
9. Click on "Connect to address"
10. Enter `localhost` into the server address box
11. Click "Connect"
For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP
server, you can also issue the `!help` command to learn about additional commands like `!hint`.
## Allowing Other People to Join Your Game
1. Ensure your Archipelago Client is running.
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.
3. Obtain your IP address by visiting [this website](https://whatismyip.com/).
4. Provide your IP address to anyone you want to join your game, and have them follow the steps for
"Connecting to Someone Else's Factorio Game" above.
## Troubleshooting
In case any problems should occur, the Archipelago Client will create a file `FactorioClient.txt` in the `/logs`.
The contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other
people in Archipelago.
## Additional Resources
- [Alternate Tutorial by Umenen](https://docs.google.com/document/d/1yZPAaXB-QcetD8FJsmsFrenAHO5V6Y2ctMAyIoT9jS4)
- [Factorio Speedrun Guide](https://www.youtube.com/watch?v=ExLrmK1c7tA)
- [Factorio Wiki](https://wiki.factorio.com/)

View File

@@ -1,114 +0,0 @@
# Final Fantasy 1 (NES) Multiworld Setup Guide
## Required Software
- The FF1Client which is bundled with [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- The [BizHawk](http://tasvideos.org/BizHawk.html) emulator. Versions 2.3.1 and higher are supported.
Version 2.7 is recommended
- Your Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither Archipelago.gg nor the
Final Fantasy Randomizer Community can supply you with this.
## Installation Procedures
1. Download and install the latest version of [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
1. On Windows, download Setup.Archipelago.<HighestVersion>.exe and run it
2. Assign Bizhawk version 2.3.1 or higher as your default program for launching `.nes` files.
1. Extract your Bizhawk folder to your Desktop, or somewhere you will remember. Below are optional additional
steps for loading ROMs more conveniently
1. Right-click on a ROM file and select **Open with...**
2. Check the box next to **Always use this app to open .nes files**
3. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
4. Browse for `EmuHawk.exe` located inside your Bizhawk folder (from step 1) and click **Open**.
## Playing a Multiworld
Playing a multiworld on Archipelago.gg has 3 key components:
1. The Server which is hosting a game for all players.
2. The Client Program. For Final Fantasy 1, it is a standalone program but other randomizers may build it in.
3. The Game itself, in this case running on Bizhawk, which then connects to the Client running on your computer.
To set this up the following steps are required:
1. (Each Player) Generate your own yaml file and randomized ROM
2. (Host Only) Generate a randomized game with you and 0 or more players using Archipelago
3. (Host Only) Run the Archipelago Server
4. (Each Player) Run your client program and connect it to the Server
5. (Each Player) Run your game and connect it to your client program
6. (Each Player) Play the game and have fun!
### Obtaining your Archipelago yaml file and randomized ROM
Unlike most other Archipelago.gg games Final Fantasy 1 is randomized by the
[main randomizer](https://finalfantasyrandomizer.com/). Generate a game by going to the site and performing the
following steps:
1. Select the randomization options (also known as `Flags` in the community) of your choice. If you do not know what
you prefer, or it is your first time playing select the "Archipelago" preset on the main page.
2. Go to the `Beta` tab and ensure `Archipelago` is enabled. Set your player name to any name that represents you.
3. Upload you `Final Fantasy(USA).nes` (and click `Remember ROM` for the future!)
4. Press the `NEW` button beside `Seed` a few times
5. Click `GENERATE ROM`
It should download two files. One is the `*.nes` file which your emulator will run and the other is the yaml file
required by Archipelago.gg
### Generating the Multiworld and Starting the Server
The game can be generated locally or by Archipelago.gg.
#### Generating on Archipelago.gg (Recommended)
1. Gather all yaml files
2. Create a zip file containing all of the yaml files. Make sure it is a `*.zip` not a `*.7z` or a `*.rar`
3. Navigate to the [Generate Page](https://archipelago.gg/generate) and click `Upload File`
1. For your first game keep `Forfeit Permission` as `Automatic on goal completion`. Forfeiting actually means
giving out all of the items remaining in your game in this case so you do not block anyone else.
2. For your first game keep `Hint Cost` at 10%
4. Select your zip file
#### Generating Locally
1. Navigate to your Archipelago install directory
2. Empty the `Players` directory then fill it with one yaml per player including your own which you got from the
finalfantasyrandomizer website above
3. Run `ArchipelagoGenerate.exe` (double-click it in File Explorer)
4. You will find your generated game in the `output` directory
#### Starting the server
If you generated on Archipelago.gg click `Create New Room` on the results page to start your server
If you generated locally simply navigate to the [Host Game Page](https://archipelago.gg/uploads) and upload the file
in the `output` directory
### Running the Client Program and Connecting to the Server
1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe`
2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****` where
***** are numbers)
3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should
already say `archipelago.gg`) and click `connect`
#### Running Your Game and Connecting to the Client Program
1. Open Bizhawk 2.3.1 or higher and load your ROM OR
click your ROM file if it is already associated with the extension `*.nes`
2. Click on the Tools menu and click on **Lua Console**
3. Click the folder button to open a new Lua script. (CTL-O or **Script** -> **Open Script**)
4. Navigate to the location you installed Archipelago to. Open data/lua/FF1/ff1_connector.lua
1. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception
close your emulator entirely, restart it and re-run these steps
2. If it says `Must use a version of bizhawk 2.3.1 or higher`, double-check your Bizhawk version by clicking
**Help** -> **About**
### Play the game
When the client shows both NES and server are connected you are good to go. You can check the connection status of the
NES at any time by running `/nes`
### Helpful Commands
Commands are broken into two types: `/` and `!` commands. The difference is that `/commands` are local to your machine
and game whereas `!` commands ask the server. Most of the time you can use local commands.
#### Local Commands
- `/connect <address with port number>` connect to the multiworld server
- `/disconnect` if you accidentally connected to the wrong port run this to disconnect and then reconnect using
- `/nes` Shows the current status of the NES connection
- `/received` Displays all the items you have found or been sent
- `/missing` Displays all the locations along with their current status (checked/missing)
- Just typing anything will broadcast a message to all players
#### Remote Commands
- `!hint <item name>` Tells you at which location in whose game your Item is. Note you need to have checked some locations
to earn a hint. You can check how many you have by just running `!hint`
- `!forfeit` If you didn't turn on auto-forfeit or you allowed forfeiting prior to goal completion. Remember that
"forfeiting" actually means sending out your remaining items in your world
#### Host only (on Archipelago.gg)
`/forfeit <Player Name>` Forfeits someone regardless of settings and game completion status

View File

@@ -1,58 +0,0 @@
# Minecraft Randomizer Setup Guide
## Required Software
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) (update 1.17.1)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) (select `Minecraft Client` during installation.)
## Configuring your YAML file
### What is a YAML file and why do I need one?
Your YAML file contains a set of configuration options which provide the generator with information about how
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
each player to enjoy an experience customized for their taste, and different players in the same multiworld
can all have different options.
### Where do I get a YAML file?
you can customize your settings by visiting the [minecraft player settings](/games/Minecraft/player-settings)
## Joining a MultiWorld Game
### Obtain your Minecraft data file
**Only one yaml file needs to be submitted per minecraft world regardless of how many players play on it.**
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
is done, the host will provide you with either a link to download your data file, or with a zip file containing
everyone's data files. Your data file should have a `.apmc` extension.
double-click on your `.apmc` file to have the minecraft client auto-launch the installed forge server.
make sure to leave this window open as this is your server console.
### Connect to the MultiServer
Using minecraft 1.17.1 connect to the server `localhost`.
Once 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 Archipleago server you are using has a password set.
### Play the game
When the console tells you that you have joined the room, you're all set. Congratulations
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
forge server. to star the game once everyone is ready type `/start`.
### Useful commands
- `!help` displays a list all server commands
- `!hint` will display how many hint points you have, along with any hints that have been given that are related to your game.
- `!hint (item)` will ask the server to tell you where (item) is
- `!hint_location (location)` will ask the server to tell you what item is on (location)
## Manual Installation
it is highly recommended to ues the Archipelago installer to handle the installation of the forge server for you.
support will not be given for those wishing to manually install forge. but for those of you who know how, and wish to
do so the following links are the versions of the software we use.
### Manual install Software links
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.17.1.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
**DO NOT INSTALL THIS ON YOUR CLIENT**
- [Java 16](https://docs.aws.amazon.com/corretto/latest/corretto-16-ug/downloads-list.html)

View File

@@ -1,98 +0,0 @@
# Risk of Rain 2 Setup Guide
## Install using r2modman
### Install r2modman
Head on over to the r2modman page on Thunderstore and follow the installation instructions.
[https://thunderstore.io/package/ebkr/r2modman/](https://thunderstore.io/package/ebkr/r2modman/)
### Install Archipelago Mod using r2modman
You can install the Archipelago mod using r2modman in one of two ways.
[https://thunderstore.io/package/ArchipelagoMW/Archipelago/](https://thunderstore.io/package/ArchipelagoMW/Archipelago/)
One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
You can also search for the "Archipelago" mod in the r2modman interface.
The mod manager should automatically install all necessary dependencies as well.
### Running the Modded Game
Click on the "Start modded" button in the top left in r2modman to start the game with the
Archipelago mod installed.
## Joining an Archipelago Session
There will be a menu button on the right side of the screen in the character select menu.
Click it in order to bring up the in lobby mod config.
From here you can expand the Archipelago sections and fill in the relevant info.
Keep password blank if there is no password on the server.
Simply check `Enable Archipelago?` and when you start the run it will automatically connect.
## Gameplay
The Risk of Rain 2 players send checks by causing items to spawn in-game. That means opening chests or killing bosses, generally.
An item check is only sent out after a certain number of items are picked up. This count is configurable in the player's YAML.
## YAML Settings
An example YAML would look like this:
```yaml
description: Ijwu-ror2
name: Ijwu
game:
Risk of Rain 2: 1
Risk of Rain 2:
total_locations: 15
total_revivals: 4
start_with_revive: true
item_pickup_step: 1
enable_lunar: true
item_weights:
default: 50
new: 0
uncommon: 0
legendary: 0
lunartic: 0
chaos: 0
no_scraps: 0
even: 0
scraps_only: 0
item_pool_presets: true
# custom item weights
green_scrap: 16
red_scrap: 4
yellow_scrap: 1
white_scrap: 32
common_item: 64
uncommon_item: 32
legendary_item: 8
boss_item: 4
lunar_item: 16
equipment: 32
```
| Name | Description | Allowed values |
| ---- | ----------- | -------------- |
| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 100 |
| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 |
| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false |
| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 |
| enable_lunar | Allows for lunar items to be shuffled into the item pool on behalf of the Risk of Rain player. | true/false |
| item_weights | Each option here is a preset item weight that can be used to customize your generate item pool with certain settings. | default, new, uncommon, legendary, lunartic, chaos, no_scraps, even, scraps_only |
| item_pool_presets | A simple toggle to determine whether the item_weight presets are used or the custom item pool as defined below | true/false |
| custom item weights | Each defined item here is a single item in the pool that will have a weight against the other items when the item pool gets generated. These values can be modified to adjust how frequently certain items appear | 0-100|
Using the example YAML above: the Risk of Rain 2 player will have 15 total items which they can pick up for other players. (total_locations = 15)
They will have 15 items waiting for them in the item pool which will be distributed out to the multiworld. (total_locations = 15)
They will complete a location check every second item. (item_pickup_step = 1)
They will have 4 of the items which other players can grant them replaced with `Dio's Best Friend`. (total_revivals = 4)
The player will also start with a `Dio's Best Friend`. (start_with_revive = true)
The player will have lunar items shuffled into the item pool on their behalf. (enable_lunar = true)
The player will have the default preset generated item pool with the custom item weights being ignored. (item_weights: default and item_pool_presets: true)

View File

@@ -1,119 +0,0 @@
# Secret of Evermore Setup Guide
## Required Software
- [SNI](https://github.com/alttpo/sni/releases) v0.0.59 or newer (included in Archipelago 0.2.1 setup)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI with ROM access
- [snes9x-rr](https://github.com/gocha/snes9x-rr/releases) or
- [BizHawk](http://tasvideos.org/BizHawk.html) or
- [bsnes-plus-nwa](https://github.com/black-sliver/bsnes-plus)
- Or SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
- Your Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
## Create a Config (.yaml) File
### What is a config file and why do I need one?
Your config file contains a set of configuration options which provide the generator with information about how
it should generate your game. Each player of a multiworld will provide their own config file. This setup allows
each player to enjoy an experience customized for their taste, and different players in the same multiworld
can all have different options.
### Where do I get a config file?
The [Player Settings](/games/Secret%20of%20Evermore/player-settings) page on the website allows you to configure your
personal settings and export a config file from them.
### Verifying your config file
If you would like to validate your config file to make sure it works, you may do so on the
[YAML Validator](/mysterycheck) page.
## Generating a Single-Player Game
Stand-alone "Evermizer" has a way of balancing single-player games, but may not always be on par feature-wise.
Head over to [evermizer.com](https://evermizer.com) if you want to try the official stand-alone, otherwise read below.
1. Navigate to the [Player Settings](/games/Secret%20of%20Evermore/player-settings) page, configure your options, and
click the "Generate Game" button.
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. Run your patch file through [apbpatch](https://evermizer.com/apbpatch) and load it in your emulator or console.
## Joining a MultiWorld Game
### Obtain your patch file and create your ROM
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 `.apsoe` extension.
Put your patch file on your desktop or somewhere convenient, open [apbpatch](https://evermizer.com/apbpatch) and
generate your ROM from it. Load the ROM file in your emulator or console.
### Connect to SNI
#### With an emulator
Start SNI either from the Archipelago install folder or the stand-alone version.
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
##### snes9x-rr
1. Load your ROM file if it hasn't already been loaded.
2. Click on the File menu and hover on **Lua Scripting**
3. Click on **New Lua Script Window...**
4. In the new window, click **Browse...**
5. Select the `Connector.lua` file from your SNI installation:
* `SNI/lua/x86/Connector.lua` for 32bit snes9x-rr or
* `SNI/lua/x64/Connector.lua` for "x64" snes9x-rr
6. Leave the Lua window open while you are playing.
* If the script window complains about missing `socket.dll` make sure it is in the lua directory.
* If the script window complains about "Bad EXE format", use the other Connector above (x86 <-> x64)
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
these menu options:
`Config --> Cores --> SNES --> BSNES`
Once you have changed the loaded core, you must restart BizHawk.
2. Load your ROM file if it hasn't already been loaded.
3. Click on the Tools menu and click on **Lua Console**
4. Click the button to open a new Lua script.
5. Select any `Connector.lua` file from your SNI installation
##### bsnes-plus-nwa
This should automatically connect to SNI.
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
[on this page](http://usb2snes.com/#supported-platforms).
1. Copy the ROM file to your SD card.
2. Load the ROM file from the menu.
### Open the client
Open [ap-soeclient](http://evermizer.com/apclient) in a modern browser. Do not switch tabs, open it in a new window
if you want to use the browser while playing. Do not minimize the window with the client.
The client should automatically connect to SNI, the "SNES" status should change to green.
### Connect to the Archipelago Server
Enter `/connect server:port` in the client's command prompt and press enter. You'll find `server:port` on the same page
that had the patch file.
### Play the game
When the game is loaded but not yet past the intro cutscene, the "Game" status is yellow. When the client shows "AP" as
green and "Game" as yellow, you're ready to play. The intro can be skipped pressing the START button and "Game" should
change to green. Congratulations on successfully joining a multiworld game!
## Hosting a MultiWorld game
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
1. Collect config files from your players.
2. Create a zip file containing your players' config files.
3. Upload that zip file to the website linked above.
4. Wait a moment while the seed is generated.
5. When the seed is generated, you will be redirected to a "Seed Info" page.
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
so they may download their patch files from there.
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
players in the game. Any observers may also be given the link to this page.
8. Once all players have joined, you may begin playing.

View File

@@ -1,30 +0,0 @@
# Timespinner Randomizer Setup Guide
## Required Software
- [Timespinner (steam)](https://store.steampowered.com/app/368620/Timespinner/), [Timespinner (humble)](https://www.humblebundle.com/store/timespinner) or [Timespinner (GOG)](https://www.gog.com/game/timespinner) (other versions are not supported)
- [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer)
## General Concept
The timespinner Randomizer loads Timespinner.exe from the same folder, and alters its state in memory to allow for randomization of the items
## Installation Procedures
Download latest release on [Timespinner Randomizer Releases](https://github.com/JarnoWesthof/TsRandomizer/releases) you can find the .zip files on the releases page, download the zip for your current platform. Then extract the zip to the folder where your Timespinner game is installed. Then just run TsRandomizer.exe (on windows) or TsRandomizerItemTracker.bin.x86_64 (on linux) or TsRandomizerItemTracker.bin.osx (on mac) instead of Timespinner.exe to start the game in randomized mode, for more info see the [ReadMe](https://github.com/JarnoWesthof/TsRandomizer)
## Joining a MultiWorld Game
1. Run TsRandomizer.exe
2. Select "New Game"
3. Switch "<< Select Seed >>" to "<< Archiplago >>" by pressing left on the controller or keyboard
4. Select "<< Archiplago >>" to open a new menu where you can enter your Archipelago login credentails
* NOTE: the input fields support Ctrl + V pasting of values
5. Select "Connect"
6. If all went well you will be taken back the difficulty selection menu and the game will start as soon as you select a difficulty
## Where do I get a config file?
The [Player Settings](https://archipelago.gg/games/Timespinner/player-settings) page on the website allows you to configure your personal settings and export a config file from them.
* The Timespinner Randomizer option "StinkyMaw" is currently always enabled for Archipelago generated seeds
* The Timespinner Randomizer options "ProgressiveVerticalMovement" & "ProgressiveKeycards" are currently not supported on Archipelago generated seeds

View File

@@ -1,365 +0,0 @@
[
{
"gameTitle": "Archipelago",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
"files": [
{
"language": "English",
"filename": "archipelago/setup_en.md",
"link": "archipelago/setup/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Using Advanced Settings",
"description": "A guide to reading yaml files and editing them to fully customize your game.",
"files": [
{
"language": "English",
"filename": "archipelago/advanced_settings_en.md",
"link": "archipelago/advanced_settings/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Triggers Guide",
"description": "A guide to setting up and using triggers in your game settings.",
"files": [
{
"language": "English",
"filename": "archipelago/triggers_en.md",
"link": "archipelago/triggers/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Plando Guide",
"description": "A guide to understanding and using plando for your game.",
"files": [
{
"language": "English",
"filename": "archipelago/plando_en.md",
"link": "archipelago/plando/en",
"authors": [
"alwaysintreble"
]
}
]
}
]
},
{
"gameTitle": "The Legend of Zelda: A Link to the Past",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago ALttP software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "zelda3/multiworld_en.md",
"link": "zelda3/multiworld/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Deutsch",
"filename": "zelda3/multiworld_de.md",
"link": "zelda3/multiworld/de",
"authors": [
"Fischfilet"
]
},
{
"language": "Español",
"filename": "zelda3/multiworld_es.md",
"link": "zelda3/multiworld/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "zelda3/multiworld_fr.md",
"link": "zelda3/multiworld/fr",
"authors": [
"Coxla"
]
}
]
},
{
"name": "MSU-1 Setup Tutorial",
"description": "A guide to setting up MSU-1, which allows for custom in-game music.",
"files": [
{
"language": "English",
"filename": "zelda3/msu1_en.md",
"link": "zelda3/msu1/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Español",
"filename": "zelda3/msu1_es.md",
"link": "zelda3/msu1/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "msu1_fr.md",
"link": "zelda3/msu1/fr",
"authors": [
"Coxla"
]
}
]
},
{
"name": "Plando Tutorial",
"description": "A guide to creating Multiworld Plandos",
"files": [
{
"language": "English",
"filename": "zelda3/plando_en.md",
"link": "zelda3/plando/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "The Legend of Zelda: Ocarina of Time",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Ocarina of Time software on your computer.",
"files": [
{
"language": "English",
"filename": "zelda5/setup_en.md",
"link": "zelda5/setup/en",
"authors": [
"Edos"
]
},
{
"language": "Spanish",
"filename": "zelda5/setup_es.md",
"link": "zelda5/setup/es",
"authors": [
"Edos"
]
}
]
}
]
},
{
"gameTitle": "Factorio",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
"files": [
{
"language": "English",
"filename": "factorio/setup_en.md",
"link": "factorio/setup/en",
"authors": [
"Berserker",
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Minecraft",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "minecraft/minecraft_en.md",
"link": "minecraft/minecraft/en",
"authors": [
"Kono Tyran"
]
},
{
"language": "Spanish",
"filename": "minecraft/minecraft_es.md",
"link": "minecraft/minecraft/es",
"authors": [
"Edos"
]
},
{
"language": "Swedish",
"filename": "minecraft/minecraft_sv.md",
"link": "minecraft/minecraft/sv",
"authors": [
"Albinum"
]
}
]
}
]
},
{
"gameTitle": "Risk of Rain 2",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.",
"files": [
{
"language": "English",
"filename": "ror2/setup_en.md",
"link": "ror2/setup/en",
"authors": [
"Ijwu"
]
}
]
}
]
},
{
"gameTitle": "Timespinner",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Timespinner randomizer connected to an Archipelago Multiworld",
"files": [
{
"language": "English",
"filename": "timespinner/setup_en.md",
"link": "timespinner/setup/en",
"authors": [
"Jarno"
]
}
]
}
]
},
{
"gameTitle": "Subnautica",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Subnautica randomizer connected to an Archipelago Multiworld",
"files": [
{
"language": "English",
"filename": "Subnautica/setup_en.md",
"link": "Subnautica/setup/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "Super Metroid",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Super Metroid Client on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "super-metroid/multiworld_en.md",
"link": "super-metroid/multiworld/en",
"authors": [
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Secret of Evermore",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related software.",
"files": [
{
"language": "English",
"filename": "secret-of-evermore/multiworld_en.md",
"link": "secret-of-evermore/multiworld/en",
"authors": [
"Black Sliver"
]
}
]
}
]
},
{
"gameTitle": "Final Fantasy",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.",
"files": [
{
"language": "English",
"filename": "ff1/multiworld_en.md",
"link": "ff1/multiworld/en",
"authors": [
"jat2980"
]
}
]
}
]
},
{
"gameTitle": "Rogue Legacy",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Rogue Legacy Randomizer software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "rogue-legacy/rogue-legacy_en.md",
"link": "rogue-legacy/rogue-legacy/en",
"authors": [
"Phar"
]
}
]
}
]
}
]

View File

@@ -1,77 +0,0 @@
# MSU-1 Setup Guide
## What is MSU-1?
MSU-1 allows for the use of custom in-game music. It works on original hardware, the SuperNT, and certain emulators.
This guide will explain how to find custom music packages, often called MSU packs, and how to configure
them for use with original hardware, the SuperNT, and the snes9x emulator.
## Where to find MSU Packs
MSU packs are constantly in development. You can find a list of completed packs, as well as in-development packs on
[this Google Spreadsheet](https://docs.google.com/spreadsheets/d/1XRkR4Xy6S24UzYkYBAOv-VYWPKZIoUKgX04RbjF128Q).
## What an MSU pack should look like
MSU packs contain many files, most of which are the music files which will be used when playing the game. These files
should be named similarly, with a hyphenated number at the end, and with a `.pcm` extension. It does not matter what
each music file is named, so long as they all follow the same pattern. The most popular filename you will find is
`alttp_msu-X.pcm`, where X is replaced by a number.
There is one other type of file you should find inside an MSU pack's folder. This file indicates to the hardware or
to the emulator that MSU should be enabled for this game. This file should be named similarly to the other files in
the folder, but will have a `.msu` extension and be 0 KB in size.
A short example of the contents of an MSU pack folder are as follows:
```
List of files inside an MSU pack folder:
alttp_msu.msu
alttp_msu-1.pcm
alttp_msu-2.pcm
...
alttp_msu-34.pcm
```
## How to use an MSU Pack
In all cases, you must rename your ROM file to match the pattern of names inside your MSU pack's folder, then place
your ROM file inside that folder.
This will cause the folder contents to look like the following:
```
List of files inside an MSU pack folder:
alttp_msu.msu
alttp_msu.sfc <-- Add your ROM file
alttp_msu-1.pcm
alttp_msu-2.pcm
...
alttp_msu-34.pcm
```
### With snes9x
1. Load the ROM file from snes9x.
### With SD2SNES / FXPak on original hardware
1. Load the MSU pack folder onto your SD2SNES / FXPak.
2. Navigate into the MSU pack folder and load your ROM.
### With SD2SNES / FXPak on SuperNT
1. Load the MSU pack folder onto your SD2SNES / FXPak.
2. Power on your SuperNT and navigate to the `Settings` menu.
3. Enter the `Audio` settings.
4. Check the box marked `Cartridge Audio Enable.`
5. Navigate back to the previous menu.
6. Choose `Save/Clear Settings`.
7. Choose `Save Settings`.
8. Choose `Run Cartridge` from the main menu.
9. Navigate into your MSU pack folder and load your ROM.
## A word of caution to streamers
Many MSU packs use copyrighted music which is not permitted for use on platforms like Twitch and YouTube.
If you choose to stream music from an MSU pack, please ensure you have permission to do so. If you stream
music which has not been licensed to you, or licensed for use in a stream in general, your VOD may be muted.
In the worst case, you may receive a DMCA take-down notice. Please be careful to only stream music for which
you have the rights to do so.
##### Stream-safe MSU packs
Below is a list of MSU packs which, so far as we know, are safe to stream. More will be added to this list as
we learn of them. If you know of any we missed, please let us know!
- Vanilla Game Music
- [Smooth McGroove](https://drive.google.com/open?id=1JDa1jCKg5hG0Km6xNpmIgf4kDMOxVp3n)
- Zelda community member Amarith assembled the following list for the purpose of competitive restreams. While we have not ourselves verified this list, all submissions required VoD proof they were not muted. Generally speaking, MSU-1 packs are less safe if they contain lyrics at any point. This list was only tested on Twitch and results for other platforms may vary. [Restream-Safe List](https://tinyurl.com/MSUsApprovedForLeagueChannels)

View File

@@ -66,6 +66,6 @@ window.addEventListener('load', () => {
console.error(error);
}
};
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/tutorials.json`, true);
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();
});

View File

@@ -3,16 +3,16 @@ window.addEventListener('load', () => {
let settingHash = localStorage.getItem('weighted-settings-hash');
if (!settingHash) {
// If no hash data has been set before, set it now
localStorage.setItem('weighted-settings-hash', md5(results));
settingHash = md5(JSON.stringify(results));
localStorage.setItem('weighted-settings-hash', settingHash);
localStorage.removeItem('weighted-settings');
settingHash = md5(results);
}
if (settingHash !== md5(results)) {
if (settingHash !== md5(JSON.stringify(results))) {
const userMessage = document.getElementById('user-message');
userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.";
userMessage.style.display = "block";
userMessage.classList.add('visible');
userMessage.addEventListener('click', resetSettings);
}
@@ -45,7 +45,7 @@ const resetSettings = () => {
const fetchSettingData = () => new Promise((resolve, reject) => {
fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => {
try{ resolve(response.json()); }
try{ response.json().then((jsonObj) => resolve(jsonObj)); }
catch(error){ reject(error); }
});
});
@@ -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;
@@ -85,24 +86,30 @@ const createDefaultSettings = (settingData) => {
newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0;
break;
case 'items-list':
case 'locations-list':
case 'custom-list':
newSettings[game][gameSetting] = [];
break;
default:
console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`);
}
}
newSettings[game].start_inventory = [];
newSettings[game].start_inventory = {};
newSettings[game].exclude_locations = [];
newSettings[game].local_items = [];
newSettings[game].non_local_items = [];
newSettings[game].start_hints = [];
newSettings[game].start_location_hints = [];
}
localStorage.setItem('weighted-settings', JSON.stringify(newSettings));
}
};
// TODO: Include item configs: start_inventory, local_items, non_local_items, start_hints
// TODO: Include location configs: exclude_locations
const buildUI = (settingData) => {
// Build the game-choice div
buildGameChoice(settingData.games);
@@ -128,19 +135,30 @@ const buildUI = (settingData) => {
expandButton.classList.add('invisible');
gameDiv.appendChild(expandButton);
const optionsDiv = buildOptionsDiv(game, settingData.games[game].gameSettings);
gameDiv.appendChild(optionsDiv);
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings);
gameDiv.appendChild(weightedSettingsDiv);
const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems);
gameDiv.appendChild(itemsDiv);
const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
gameDiv.appendChild(hintsDiv);
gamesWrapper.appendChild(gameDiv);
collapseButton.addEventListener('click', () => {
collapseButton.classList.add('invisible');
optionsDiv.classList.add('invisible');
weightedSettingsDiv.classList.add('invisible');
itemsDiv.classList.add('invisible');
hintsDiv.classList.add('invisible');
expandButton.classList.remove('invisible');
});
expandButton.addEventListener('click', () => {
collapseButton.classList.remove('invisible');
optionsDiv.classList.remove('invisible');
weightedSettingsDiv.classList.remove('invisible');
itemsDiv.classList.remove('invisible');
hintsDiv.classList.remove('invisible');
expandButton.classList.add('invisible');
});
});
@@ -207,10 +225,10 @@ const buildGameChoice = (games) => {
gameChoiceDiv.appendChild(table);
};
const buildOptionsDiv = (game, settings) => {
const buildWeightedSettingsDiv = (game, settings) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const optionsWrapper = document.createElement('div');
optionsWrapper.classList.add('settings-wrapper');
const settingsWrapper = document.createElement('div');
settingsWrapper.classList.add('settings-wrapper');
Object.keys(settings).forEach((settingName) => {
const setting = settings[settingName];
@@ -268,27 +286,7 @@ const buildOptionsDiv = (game, settings) => {
break;
case 'range':
const hintText = document.createElement('p');
hintText.classList.add('hint-text');
hintText.innerHTML = 'This is a range option. You may enter valid numerical values 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}`;
settingWrapper.appendChild(hintText);
const addOptionDiv = document.createElement('div');
addOptionDiv.classList.add('add-option-div');
const optionInput = document.createElement('input');
optionInput.setAttribute('id', `${game}-${settingName}-option`);
optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`);
addOptionDiv.appendChild(optionInput);
const addOptionButton = document.createElement('button');
addOptionButton.innerText = 'Add';
addOptionDiv.appendChild(addOptionButton);
settingWrapper.appendChild(addOptionDiv);
optionInput.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
});
case 'special_range':
const rangeTable = document.createElement('table');
const rangeTbody = document.createElement('tbody');
@@ -324,6 +322,87 @@ const buildOptionsDiv = (game, settings) => {
rangeTbody.appendChild(tr);
}
} else {
const hintText = document.createElement('p');
hintText.classList.add('hint-text');
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');
addOptionDiv.classList.add('add-option-div');
const optionInput = document.createElement('input');
optionInput.setAttribute('id', `${game}-${settingName}-option`);
optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`);
addOptionDiv.appendChild(optionInput);
const addOptionButton = document.createElement('button');
addOptionButton.innerText = 'Add';
addOptionDiv.appendChild(addOptionButton);
settingWrapper.appendChild(addOptionDiv);
optionInput.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
});
addOptionButton.addEventListener('click', () => {
const optionInput = document.getElementById(`${game}-${settingName}-option`);
let option = optionInput.value;
if (!option || !option.trim()) { return; }
option = parseInt(option, 10);
if ((option < setting.min) || (option > setting.max)) { return; }
optionInput.value = '';
if (document.getElementById(`${game}-${settingName}-${option}-range`)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${game}-${settingName}-${option}-range`);
range.setAttribute('data-game', game);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.value = currentSettings[game][settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
tdDelete.classList.add('td-delete');
const deleteButton = document.createElement('span');
deleteButton.classList.add('range-option-delete');
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
range.dispatchEvent(new Event('change'));
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
});
Object.keys(currentSettings[game][settingName]).forEach((option) => {
if (currentSettings[game][settingName][option] > 0) {
const tr = document.createElement('tr');
@@ -403,69 +482,377 @@ const buildOptionsDiv = (game, settings) => {
rangeTable.appendChild(rangeTbody);
settingWrapper.appendChild(rangeTable);
break;
addOptionButton.addEventListener('click', () => {
const optionInput = document.getElementById(`${game}-${settingName}-option`);
let option = optionInput.value;
if (!option || !option.trim()) { return; }
option = parseInt(option, 10);
if ((option < setting.min) || (option > setting.max)) { return; }
optionInput.value = '';
if (document.getElementById(`${game}-${settingName}-${option}-range`)) { return; }
case 'items-list':
// TODO
break;
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
tr.appendChild(tdLeft);
case 'locations-list':
// TODO
break;
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${game}-${settingName}-${option}-range`);
range.setAttribute('data-game', game);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.value = currentSettings[game][settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
tdDelete.classList.add('td-delete');
const deleteButton = document.createElement('span');
deleteButton.classList.add('range-option-delete');
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
range.dispatchEvent(new Event('change'));
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
});
case 'custom-list':
// TODO
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;
}
optionsWrapper.appendChild(settingWrapper);
settingsWrapper.appendChild(settingWrapper);
});
return optionsWrapper;
return settingsWrapper;
};
const buildItemsDiv = (game, items) => {
// Sort alphabetical, in pace
items.sort();
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const itemsDiv = document.createElement('div');
itemsDiv.classList.add('items-div');
const itemsDivHeader = document.createElement('h3');
itemsDivHeader.innerText = 'Item Pool';
itemsDiv.appendChild(itemsDivHeader);
const itemsDescription = document.createElement('p');
itemsDescription.classList.add('setting-description');
itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' +
'your seed or someone else\'s.';
itemsDiv.appendChild(itemsDescription);
const itemsHint = document.createElement('p');
itemsHint.classList.add('hint-text');
itemsHint.innerText = 'Drag and drop items from one box to another.';
itemsDiv.appendChild(itemsHint);
const itemsWrapper = document.createElement('div');
itemsWrapper.classList.add('items-wrapper');
// Create container divs for each category
const availableItemsWrapper = document.createElement('div');
availableItemsWrapper.classList.add('item-set-wrapper');
availableItemsWrapper.innerText = 'Available Items';
const availableItems = document.createElement('div');
availableItems.classList.add('item-container');
availableItems.setAttribute('id', `${game}-available_items`);
availableItems.addEventListener('dragover', itemDragoverHandler);
availableItems.addEventListener('drop', itemDropHandler);
const startInventoryWrapper = document.createElement('div');
startInventoryWrapper.classList.add('item-set-wrapper');
startInventoryWrapper.innerText = 'Start Inventory';
const startInventory = document.createElement('div');
startInventory.classList.add('item-container');
startInventory.setAttribute('id', `${game}-start_inventory`);
startInventory.setAttribute('data-setting', 'start_inventory');
startInventory.addEventListener('dragover', itemDragoverHandler);
startInventory.addEventListener('drop', itemDropHandler);
const localItemsWrapper = document.createElement('div');
localItemsWrapper.classList.add('item-set-wrapper');
localItemsWrapper.innerText = 'Local Items';
const localItems = document.createElement('div');
localItems.classList.add('item-container');
localItems.setAttribute('id', `${game}-local_items`);
localItems.setAttribute('data-setting', 'local_items')
localItems.addEventListener('dragover', itemDragoverHandler);
localItems.addEventListener('drop', itemDropHandler);
const nonLocalItemsWrapper = document.createElement('div');
nonLocalItemsWrapper.classList.add('item-set-wrapper');
nonLocalItemsWrapper.innerText = 'Non-Local Items';
const nonLocalItems = document.createElement('div');
nonLocalItems.classList.add('item-container');
nonLocalItems.setAttribute('id', `${game}-non_local_items`);
nonLocalItems.setAttribute('data-setting', 'non_local_items');
nonLocalItems.addEventListener('dragover', itemDragoverHandler);
nonLocalItems.addEventListener('drop', itemDropHandler);
// Populate the divs
items.forEach((item) => {
if (Object.keys(currentSettings[game].start_inventory).includes(item)){
const itemDiv = buildItemQtyDiv(game, item);
itemDiv.setAttribute('data-setting', 'start_inventory');
startInventory.appendChild(itemDiv);
} else if (currentSettings[game].local_items.includes(item)) {
const itemDiv = buildItemDiv(game, item);
itemDiv.setAttribute('data-setting', 'local_items');
localItems.appendChild(itemDiv);
} else if (currentSettings[game].non_local_items.includes(item)) {
const itemDiv = buildItemDiv(game, item);
itemDiv.setAttribute('data-setting', 'non_local_items');
nonLocalItems.appendChild(itemDiv);
} else {
const itemDiv = buildItemDiv(game, item);
availableItems.appendChild(itemDiv);
}
});
availableItemsWrapper.appendChild(availableItems);
startInventoryWrapper.appendChild(startInventory);
localItemsWrapper.appendChild(localItems);
nonLocalItemsWrapper.appendChild(nonLocalItems);
itemsWrapper.appendChild(availableItemsWrapper);
itemsWrapper.appendChild(startInventoryWrapper);
itemsWrapper.appendChild(localItemsWrapper);
itemsWrapper.appendChild(nonLocalItemsWrapper);
itemsDiv.appendChild(itemsWrapper);
return itemsDiv;
};
const buildItemDiv = (game, item) => {
const itemDiv = document.createElement('div');
itemDiv.classList.add('item-div');
itemDiv.setAttribute('id', `${game}-${item}`);
itemDiv.setAttribute('data-game', game);
itemDiv.setAttribute('data-item', item);
itemDiv.setAttribute('draggable', 'true');
itemDiv.innerText = item;
itemDiv.addEventListener('dragstart', (evt) => {
evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id'));
});
return itemDiv;
};
const buildItemQtyDiv = (game, item) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const itemQtyDiv = document.createElement('div');
itemQtyDiv.classList.add('item-qty-div');
itemQtyDiv.setAttribute('id', `${game}-${item}`);
itemQtyDiv.setAttribute('data-game', game);
itemQtyDiv.setAttribute('data-item', item);
itemQtyDiv.setAttribute('draggable', 'true');
itemQtyDiv.innerText = item;
const inputWrapper = document.createElement('div');
inputWrapper.classList.add('item-qty-input-wrapper')
const itemQty = document.createElement('input');
itemQty.setAttribute('value', currentSettings[game].start_inventory.hasOwnProperty(item) ?
currentSettings[game].start_inventory[item] : '1');
itemQty.setAttribute('data-game', game);
itemQty.setAttribute('data-setting', 'start_inventory');
itemQty.setAttribute('data-option', item);
itemQty.setAttribute('maxlength', '3');
itemQty.addEventListener('keyup', (evt) => {
evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value);
updateItemSetting(evt);
});
inputWrapper.appendChild(itemQty);
itemQtyDiv.appendChild(inputWrapper);
itemQtyDiv.addEventListener('dragstart', (evt) => {
evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id'));
});
return itemQtyDiv;
};
const itemDragoverHandler = (evt) => {
evt.preventDefault();
};
const itemDropHandler = (evt) => {
evt.preventDefault();
const sourceId = evt.dataTransfer.getData('text/plain');
const sourceDiv = document.getElementById(sourceId);
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const game = sourceDiv.getAttribute('data-game');
const item = sourceDiv.getAttribute('data-item');
const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null;
const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null;
const itemDiv = newSetting === 'start_inventory' ? buildItemQtyDiv(game, item) : buildItemDiv(game, item);
if (oldSetting) {
if (oldSetting === 'start_inventory') {
if (currentSettings[game][oldSetting].hasOwnProperty(item)) {
delete currentSettings[game][oldSetting][item];
}
} else {
if (currentSettings[game][oldSetting].includes(item)) {
currentSettings[game][oldSetting].splice(currentSettings[game][oldSetting].indexOf(item), 1);
}
}
}
if (newSetting) {
itemDiv.setAttribute('data-setting', newSetting);
document.getElementById(`${game}-${newSetting}`).appendChild(itemDiv);
if (newSetting === 'start_inventory') {
currentSettings[game][newSetting][item] = 1;
} else {
if (!currentSettings[game][newSetting].includes(item)){
currentSettings[game][newSetting].push(item);
}
}
} else {
// No setting was assigned, this item has been removed from the settings
document.getElementById(`${game}-available_items`).appendChild(itemDiv);
}
// Remove the source drag object
sourceDiv.parentElement.removeChild(sourceDiv);
// Save the updated settings
localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
};
const buildHintsDiv = (game, items, locations) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
// Sort alphabetical, in place
items.sort();
locations.sort();
const hintsDiv = document.createElement('div');
hintsDiv.classList.add('hints-div');
const hintsHeader = document.createElement('h3');
hintsHeader.innerText = 'Item & Location Hints';
hintsDiv.appendChild(hintsHeader);
const hintsDescription = document.createElement('p');
hintsDescription.classList.add('setting-description');
hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
' items are, or what those locations contain. Excluded locations will not contain progression items.';
hintsDiv.appendChild(hintsDescription);
const itemHintsContainer = document.createElement('div');
itemHintsContainer.classList.add('hints-container');
const itemHintsWrapper = document.createElement('div');
itemHintsWrapper.classList.add('hints-wrapper');
itemHintsWrapper.innerText = 'Starting Item Hints';
const itemHintsDiv = document.createElement('div');
itemHintsDiv.classList.add('item-container');
items.forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.classList.add('hint-div');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
const itemCheckbox = document.createElement('input');
itemCheckbox.setAttribute('type', 'checkbox');
itemCheckbox.setAttribute('id', `${game}-start_hints-${item}`);
itemCheckbox.setAttribute('data-game', game);
itemCheckbox.setAttribute('data-setting', 'start_hints');
itemCheckbox.setAttribute('data-option', item);
if (currentSettings[game].start_hints.includes(item)) {
itemCheckbox.setAttribute('checked', 'true');
}
itemCheckbox.addEventListener('change', hintChangeHandler);
itemLabel.appendChild(itemCheckbox);
const itemName = document.createElement('span');
itemName.innerText = item;
itemLabel.appendChild(itemName);
itemDiv.appendChild(itemLabel);
itemHintsDiv.appendChild(itemDiv);
});
itemHintsWrapper.appendChild(itemHintsDiv);
itemHintsContainer.appendChild(itemHintsWrapper);
const locationHintsWrapper = document.createElement('div');
locationHintsWrapper.classList.add('hints-wrapper');
locationHintsWrapper.innerText = 'Starting Location Hints';
const locationHintsDiv = document.createElement('div');
locationHintsDiv.classList.add('item-container');
locations.forEach((location) => {
const locationDiv = document.createElement('div');
locationDiv.classList.add('hint-div');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${game}-start_location_hints-${location}`);
locationCheckbox.setAttribute('data-game', game);
locationCheckbox.setAttribute('data-setting', 'start_location_hints');
locationCheckbox.setAttribute('data-option', location);
if (currentSettings[game].start_location_hints.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', hintChangeHandler);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationDiv.appendChild(locationLabel);
locationHintsDiv.appendChild(locationDiv);
});
locationHintsWrapper.appendChild(locationHintsDiv);
itemHintsContainer.appendChild(locationHintsWrapper);
const excludeLocationsWrapper = document.createElement('div');
excludeLocationsWrapper.classList.add('hints-wrapper');
excludeLocationsWrapper.innerText = 'Exclude Locations';
const excludeLocationsDiv = document.createElement('div');
excludeLocationsDiv.classList.add('item-container');
locations.forEach((location) => {
const locationDiv = document.createElement('div');
locationDiv.classList.add('hint-div');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${game}-exclude_locations-${location}`);
locationCheckbox.setAttribute('data-game', game);
locationCheckbox.setAttribute('data-setting', 'exclude_locations');
locationCheckbox.setAttribute('data-option', location);
if (currentSettings[game].exclude_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', hintChangeHandler);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationDiv.appendChild(locationLabel);
excludeLocationsDiv.appendChild(locationDiv);
});
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
itemHintsContainer.appendChild(excludeLocationsWrapper);
hintsDiv.appendChild(itemHintsContainer);
return hintsDiv;
};
const hintChangeHandler = (evt) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
if (evt.target.checked) {
if (!currentSettings[game][setting].includes(option)) {
currentSettings[game][setting].push(option);
}
} else {
if (currentSettings[game][setting].includes(option)) {
currentSettings[game][setting].splice(currentSettings[game][setting].indexOf(option), 1);
}
}
localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
};
const updateVisibleGames = () => {
@@ -511,25 +898,44 @@ const updateBaseSetting = (event) => {
localStorage.setItem('weighted-settings', JSON.stringify(settings));
};
const updateGameSetting = (event) => {
const updateGameSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = event.target.getAttribute('data-game');
const setting = event.target.getAttribute('data-setting');
const option = event.target.getAttribute('data-option');
const type = event.target.getAttribute('data-type');
document.getElementById(`${game}-${setting}-${option}`).innerText = event.target.value;
options[game][setting][option] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
options[game][setting][option] = isNaN(evt.target.value) ?
evt.target.value : parseInt(evt.target.value, 10);
localStorage.setItem('weighted-settings', JSON.stringify(options));
};
const exportSettings = () => {
const updateItemSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
if (setting === 'start_inventory') {
options[game][setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0;
} else {
options[game][setting][option] = isNaN(evt.target.value) ?
evt.target.value : parseInt(evt.target.value, 10);
}
localStorage.setItem('weighted-settings', JSON.stringify(options));
};
const validateSettings = () => {
const settings = JSON.parse(localStorage.getItem('weighted-settings'));
const userMessage = document.getElementById('user-message');
let errorMessage = null;
// User must choose a name for their file
if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'You forgot to set your player name at the top of the page!';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
return;
}
@@ -549,9 +955,41 @@ const exportSettings = () => {
delete settings[game][setting][option];
}
});
if (
Object.keys(settings[game][setting]).length === 0 &&
!Array.isArray(settings[game][setting]) &&
setting !== 'start_inventory'
) {
errorMessage = `${game} // ${setting} has no values above zero!`;
}
});
});
if (Object.keys(settings.game).length === 0) {
errorMessage = 'You have not chosen a game to play!';
}
// If an error occurred, alert the user and do not export the file
if (errorMessage) {
userMessage.innerText = errorMessage;
userMessage.classList.add('visible');
userMessage.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
return;
}
// If no error occurred, hide the user message if it is visible
userMessage.classList.remove('visible');
return settings;
};
const exportSettings = () => {
const settings = validateSettings();
if (!settings) { return; }
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
@@ -568,9 +1006,12 @@ const download = (filename, text) => {
};
const generateGame = (raceMode = false) => {
const settings = validateSettings();
if (!settings) { return; }
axios.post('/api/generate', {
weights: { player: localStorage.getItem('weighted-settings') },
presetData: { player: localStorage.getItem('weighted-settings') },
weights: { player: JSON.stringify(settings) },
presetData: { player: JSON.stringify(settings) },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
@@ -582,7 +1023,10 @@ const generateGame = (raceMode = false) => {
userMessage.innerText += ' ' + error.response.data.text;
}
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
console.error(error);
});
};

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 240 38" style="enable-background:new 0 0 240 38;" xml:space="preserve">
<style type="text/css">
.st0{fill:#316B84;}
</style>
<g>
<g>
<path class="st0" d="M59.72,27.96L53.03,4.21L42.25,4.04l1.42,4.37l1.41-0.26l-7.9,24.22h8.44l-0.56-2.27l-0.81-3.27l8.9-5.7
l1.78,11.24h7.97v-4.73L59.72,27.96z M45.62,20.21l3.13-10.84h1.5l2.02,7.44L45.62,20.21z"/>
<path class="st0" d="M78.67,27.96V20.4l-4.11-2.5l3.29-3.78l-0.47-7.46l-2.82-2.45H56.65v5.27l3.81-1.11l2.31,13.36l-2.79,0.73
L61,26.34l5.06-0.52l0.36-6.15l4.32,0.13l3.16,3.62v8.94l12.89,1.49v-5.34L78.67,27.96z M73.27,13.33l-2.18,1.45h-4.64l-0.42-6.57
h5.68l1.55,1.37V13.33z"/>
<polygon class="st0" points="84.65,4.21 93.01,4.21 95.75,6.46 96.26,10.9 92.23,12.43 91.77,9.74 88.97,8.28 85.86,9.82
83.88,15.02 85.51,20.94 88.49,22.38 91.99,20.59 91.99,18.59 96.26,16.96 95.85,22.85 91.81,26.87 84.17,26.55 80.79,23.58
78.87,14.87 80.79,6.94 "/>
<polygon class="st0" points="97.62,4.21 103.33,4.21 102.96,21.08 108.7,20.14 108.34,6.42 113.85,3.28 113.9,19.9 115.75,19.71
115.27,25.86 113.88,25.86 114.27,32.36 108.7,32.36 108.7,26.39 102.96,26.39 102.96,32.36 91.77,33.85 92.2,28.85 97.88,27.96
"/>
<polygon class="st0" points="147.43,28.86 147.43,32.36 162.85,32.36 162.48,25.36 159.5,26 158.89,27.68 154.1,27.24
154.1,21.51 160.81,20.85 160.81,16.48 153.86,16.54 153.86,9.18 158.62,8.43 159.22,9.77 161.85,10.06 162.59,4 147.43,4
147.43,6.54 148.68,7.46 148.68,28.4 "/>
<polygon class="st0" points="163.89,9.24 163.89,4 172.31,4 170.35,26.87 179.55,24.74 179.55,32.36 164.51,32.34 164.65,28.71
165.73,27.84 165.73,9.59 "/>
<path class="st0" d="M193.69,32.36l-0.63-2.51l-2.84-1.89l-4.29-20.14L185.9,4h-11.27l-0.03,3.2l1.87-0.34l-2.79,14.07l-1.37,0.57
v2.85l6.29-1.33l0.4-2.7l4.65-0.89l1.69,12.93H193.69z M179.39,15.11l1.65-6.52l0.89,0.25l0.92,5.45L179.39,15.11z"/>
<polygon class="st0" points="208.47,21.68 210.62,21.12 210.04,18.15 200.51,17.46 198.87,21.13 203.56,21.9 203.32,23.91
200.58,25.19 196.44,23.77 194.48,17.19 196.2,10.02 200.08,8.52 203.31,9.62 202.85,11.75 207.79,13.6 208.83,9.69 204.71,4.21
195.57,4.21 191.24,7.36 189.29,16.87 192.06,27.54 199.03,30.53 203.2,29.3 203.09,32.36 209.01,32.36 209.4,29.95 207.38,28.99
"/>
<path class="st0" d="M230.45,6.26L226.39,4l-8.59-0.01l-4.07,2.86l-2.58,8.9l1.52,11.82l5.61,4.73l7.65,0.01l5.72-4.59l2.47-12.46
L230.45,6.26z M228.23,21.75l-3.95,5.45l-2.16,0.43l-4.6-3.46L216,15.72l2.4-7.02l5.14-0.48l2.97,1.79l1.74,5.83L228.23,21.75z"/>
<path class="st0" d="M116.13,27.48l-0.24,4.88l12.26,0.09l-0.83-5.01l-2.86-0.48l0.14-17.62l2.45-0.42l-0.14-4.85l-10.92,0.36
l0.1,4.6l3.2,0.63l-0.42,17.67L116.13,27.48z"/>
<path class="st0" d="M141.34,4.21l-12.88-0.39v4.26l1.95,0.62v25.15l-1.8,1.41l-0.02,2.63h8.23L136,27.96h6.09l4.57-4.46V7.27
L141.34,4.21z M141.38,20.51l-2.54,1.89l-3.23,0.16L135.4,9.32h3.88l2.1,1.68V20.51z"/>
</g>
<g>
<path class="st0" d="M14.14,11.28c0,0.35-0.02,0.71-0.07,1.05c0.38,0.07,0.76,0.11,1.16,0.11s0.79-0.04,1.16-0.11
c-0.05-0.34-0.07-0.7-0.07-1.05c0-3.23,1.94-6.02,4.72-7.25C20.17,1.68,17.9,0,15.24,0S10.3,1.68,9.42,4.03
C12.2,5.26,14.14,8.04,14.14,11.28z"/>
<path class="st0" d="M18.04,11.28c0,0.16,0.01,0.32,0.02,0.48c0.02,0.3,0.06,0.6,0.13,0.88c0.06,0.28,0.15,0.56,0.25,0.83
c0.11,0.3,0.24,0.58,0.39,0.85c1.42-1.33,3.33-2.15,5.42-2.15s4.01,0.82,5.42,2.15c0.51-0.9,0.79-1.94,0.79-3.04
c0-3.42-2.79-6.22-6.22-6.22c-0.4,0-0.79,0.04-1.16,0.11c-0.28,0.06-0.56,0.13-0.83,0.22c-0.28,0.09-0.56,0.21-0.83,0.35
C19.42,6.77,18.04,8.87,18.04,11.28z"/>
<path class="st0" d="M6.22,12.16c2.1,0,4.01,0.82,5.42,2.15c0.15-0.27,0.28-0.55,0.39-0.85c0.1-0.27,0.19-0.54,0.25-0.83
c0.06-0.28,0.11-0.58,0.13-0.88c0.02-0.15,0.02-0.32,0.02-0.48c0-2.41-1.38-4.51-3.39-5.54C8.77,5.6,8.5,5.49,8.21,5.39
c-0.27-0.1-0.55-0.17-0.83-0.22C7,5.1,6.61,5.06,6.22,5.06C2.79,5.06,0,7.85,0,11.28c0,1.1,0.28,2.14,0.79,3.04
C2.21,12.98,4.12,12.16,6.22,12.16z"/>
<path class="st0" d="M29.21,16.33c-0.18-0.23-0.36-0.44-0.57-0.65c-1.12-1.12-2.67-1.81-4.38-1.81c-1.71,0-3.25,0.69-4.38,1.81
c-0.2,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72c-0.2,0.34-0.36,0.71-0.48,1.09c2.83,1.21,4.81,4.02,4.81,7.28
c0,0.26-0.01,0.52-0.04,0.78c0.37,0.07,0.75,0.1,1.13,0.1c3.43,0,6.22-2.79,6.22-6.22c0-1.11-0.29-2.14-0.8-3.04
C29.54,16.8,29.38,16.56,29.21,16.33z"/>
<path class="st0" d="M12.12,18.14c-0.13-0.38-0.28-0.75-0.48-1.09c-0.14-0.26-0.3-0.5-0.47-0.72c-0.17-0.23-0.36-0.44-0.56-0.64
c-1.12-1.12-2.67-1.81-4.38-1.81s-3.26,0.69-4.38,1.81c-0.21,0.2-0.39,0.42-0.56,0.64c-0.18,0.23-0.34,0.47-0.47,0.72
C0.29,17.94,0,18.98,0,20.08c0,3.43,2.79,6.22,6.22,6.22c0.39,0,0.76-0.03,1.13-0.1c-0.03-0.26-0.04-0.52-0.04-0.78
C7.31,22.15,9.29,19.34,12.12,18.14z"/>
<path class="st0" d="M18.04,19.87c-0.27-0.14-0.55-0.26-0.84-0.35c-0.27-0.09-0.55-0.17-0.84-0.22c-0.37-0.07-0.75-0.1-1.13-0.1
s-0.76,0.03-1.13,0.1c-0.28,0.05-0.57,0.13-0.84,0.22c-0.29,0.1-0.57,0.22-0.84,0.35C10.4,20.9,9.02,23,9.02,25.42
c0,0.07,0,0.14,0.01,0.21c0.01,0.31,0.04,0.61,0.1,0.9c0.05,0.28,0.12,0.57,0.21,0.84c0.82,2.48,3.16,4.27,5.9,4.27
s5.08-1.79,5.9-4.27c0.09-0.27,0.17-0.55,0.21-0.84c0.06-0.3,0.09-0.6,0.1-0.91c0.01-0.07,0.01-0.14,0.01-0.21
C21.45,23,20.07,20.9,18.04,19.87z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

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