Compare commits

...

178 Commits
0.3.1 ... 0.3.2

Author SHA1 Message Date
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
249 changed files with 22828 additions and 15755 deletions

View File

@@ -26,7 +26,7 @@ jobs:
# build-release-macos: # LF volunteer
build-release-ubuntu1804:
runs-on: ubuntu-latest
runs-on: ubuntu-18.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV

View File

@@ -34,7 +34,7 @@ jobs:
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

2
.gitignore vendored
View File

@@ -77,6 +77,7 @@ MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
@@ -154,6 +155,7 @@ cython_debug/
#minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
#pyenv
.python-version

View File

@@ -6,7 +6,8 @@ import logging
import json
import functools
from collections import OrderedDict, Counter, deque
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable
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
@@ -22,6 +23,8 @@ class Group(TypedDict, total=False):
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
local_items: Set[str]
non_local_items: Set[str]
class MultiWorld():
@@ -35,7 +38,7 @@ class MultiWorld():
plando_texts: List[Dict[str, str]]
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
@@ -46,6 +49,7 @@ class MultiWorld():
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):
@@ -212,31 +216,51 @@ class MultiWorld():
for player in self.player_ids:
for item_link in self.item_links[player].value:
if item_link["name"] in item_links:
if item_link["game"] != self.game[player]:
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"]),
"game": self.game[player]
"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):
@@ -562,9 +586,20 @@ class MultiWorld():
return False
PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState():
additional_init_functions: List[Callable] = []
additional_copy_functions: List[Callable] = []
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()
@@ -602,6 +637,7 @@ class CollectionState():
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)
@@ -632,7 +668,8 @@ class CollectionState():
spot: Union[Location, Entrance, Region, str],
resolution_hint: Optional[str] = None,
player: Optional[int] = None) -> bool:
if not hasattr(spot, "can_reach"):
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)
@@ -643,7 +680,7 @@ class CollectionState():
spot = self.world.get_region(spot, player)
return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Set[Location] = 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
@@ -655,6 +692,7 @@ class CollectionState():
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: str, player: int, count: int = 1) -> bool:
@@ -669,7 +707,7 @@ class CollectionState():
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):
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]
@@ -677,7 +715,7 @@ class CollectionState():
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]
@@ -1442,6 +1480,19 @@ class Spoiler():
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
author: List[str]
seeddigits = 20
@@ -1454,4 +1505,4 @@ def get_seed(seed=None) -> int:
from worlds import AutoWorld
auto_world = AutoWorld.World
auto_world = AutoWorld.World

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
@@ -115,11 +119,14 @@ class CommonContext():
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: int = ClientCommandProcessor
game = None
game: typing.Optional[str] = None
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
items_handling: typing.Optional[int] = None
current_energy_link_value = 0 # to display in UI, gets set by server
slot_info: typing.Dict[int, NetworkSlot]
current_energy_link_value: int = 0 # to display in UI, gets set by server
def __init__(self, server_address, password):
# server state
@@ -130,6 +137,7 @@ class CommonContext():
self.server_version = Version(0, 0, 0)
self.hint_cost: typing.Optional[int] = None
self.games: typing.Dict[int, str] = {}
self.slot_info = {}
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
@@ -175,14 +183,26 @@ 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
self.games = {}
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
# noinspection PyAttributeOutsideInit
def set_getters(self, data_package: dict, network=False):
@@ -203,23 +223,16 @@ class CommonContext():
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):
def get_item_name_from_id(code: int) -> str:
return item_lookup.get(code, f'Unknown item (ID:{code})')
self.item_name_getter = get_item_name_from_id
def get_location_name_from_address(address: int):
def get_location_name_from_address(address: int) -> str:
return locations_lookup.get(address, f'Unknown location (ID:{address})')
self.location_name_getter = get_location_name_from_address
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
@@ -305,6 +318,10 @@ 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()
# DeathLink hooks
@@ -330,7 +347,7 @@ class CommonContext():
}
}])
async def update_death_link(self, death_link):
async def update_death_link(self, death_link: bool):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
@@ -339,6 +356,27 @@ class CommonContext():
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def 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)
@@ -354,7 +392,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
@@ -382,16 +419,12 @@ async def server_loop(ctx: CommonContext, address=None):
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')
logger.exception('Connection refused by the server. May not be running Archipelago on that address or port.')
except websockets.InvalidURI:
logger.exception('Failed to connect to the multiworld server (invalid URI)')
except (OSError, websockets.InvalidURI):
except OSError:
logger.exception('Failed to connect to the multiworld server')
except Exception as e:
except Exception:
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
finally:
await ctx.connection_closed()
@@ -463,8 +496,6 @@ 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:
@@ -482,6 +513,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == 'Connected':
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:
@@ -561,7 +594,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)
@@ -596,7 +628,7 @@ if __name__ == '__main__':
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "Archipelago"
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):
@@ -615,33 +647,33 @@ if __name__ == '__main__':
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
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

@@ -102,6 +102,18 @@ class FF1Context(CommonContext):
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()
@@ -175,6 +187,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:
@@ -232,14 +247,8 @@ if __name__ == '__main__':
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()
@@ -250,20 +259,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
@@ -118,10 +120,24 @@ class FactorioContext(CommonContext):
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.info(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
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):
bridge_logger = logging.getLogger("FactorioWatcher")
@@ -184,7 +200,7 @@ async def game_watcher(ctx: FactorioContext):
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.info(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
@@ -343,15 +359,11 @@ 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")
successful_launch = await factorio_server_task
if successful_launch:
@@ -367,12 +379,6 @@ 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):
@@ -413,7 +419,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()

74
Fill.py
View File

@@ -166,7 +166,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED]
fill_restrictive(world, world.state, prioritylocations, progitempool)
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
if prioritylocations:
defaultlocations = prioritylocations + defaultlocations
@@ -220,15 +220,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = [location.name for location in defaultlocations]
unfilled = defaultlocations
if unplaced or 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])
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})')
@@ -305,29 +305,42 @@ def flood_items(world: MultiWorld) -> None:
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:
# 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 = {player for player in world.player_ids if world.progression_balancing[player]}
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)
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
checked_locations: typing.Set[Location] = set()
unchecked_locations = set(world.get_locations())
unchecked_locations: typing.Set[Location] = set(world.get_locations())
reachable_locations_count = {
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 = Counter(location.player for location in world.get_locations() if not location.locked)
balanceable_players = {player for player in balanceable_players if total_locations_count[player]}
sphere_num = 1
moved_item_count = 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: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
@@ -357,13 +370,19 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
sphere_num += 1
if checked_locations:
# The 10% threshold can be modified for "progression balancing strength"
# right now it approximates the old 20/216 bound.
threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
reachable_locations_count)) - 0.10
logging.debug(f"Threshold: {threshold_percentage}")
balancing_players = {player for player, reachables in reachable_locations_count.items() if
item_percentage(player, reachables) < threshold_percentage 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()
@@ -389,8 +408,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentage
for player, reachables in balancing_reachables.items()):
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.')
@@ -402,7 +422,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
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()
@@ -420,7 +442,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
if p < threshold_percentage:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
replaced_items = False

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
@@ -15,7 +15,7 @@ ModuleUpdate.update()
import Utils
from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoConnection
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
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
@@ -32,7 +32,7 @@ def mystery_argparse():
options = get_options()
defaults = options["generator"]
def resolve_path(path: str, resolver: typing.Callable[[str], str]) -> str:
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.")
@@ -64,7 +64,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: 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
@@ -83,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:
@@ -111,14 +111,15 @@ def main(args=None, callback=ERmain):
if file.is_file() 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
for yaml in weights_cache[fname]:
print(f"P{player_id} Weights: {fname} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = fname
player_id += 1
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: "
@@ -141,8 +142,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)
@@ -153,38 +155,45 @@ 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: {Counter(erargs.name.values())}")
@@ -214,7 +223,7 @@ 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 in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -224,7 +233,7 @@ def read_weights_yaml(path):
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) -> bool:
@@ -235,7 +244,7 @@ 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:
@@ -250,7 +259,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:
@@ -283,16 +292,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',
@@ -317,7 +326,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)
@@ -387,7 +396,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:
@@ -439,7 +448,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
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)

View File

@@ -152,6 +152,8 @@ components: Iterable[Component] = (
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),

View File

@@ -20,10 +20,15 @@ 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, user_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
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):
def __init__(self, sprite_pool):
import random
@@ -40,7 +45,7 @@ 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.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
@@ -53,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'],
@@ -127,9 +133,12 @@ def main():
def adjust(args):
start = time.perf_counter()
init_logging("LttP Adjuster")
logger = logging.getLogger('Adjuster')
logger.info('Patching ROM.')
vanillaRom = args.baserom
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)
@@ -155,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)
@@ -210,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
@@ -246,6 +256,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.baserom = romVar.get()
if isinstance(rom_vars.sprite, Sprite):
guiargs.sprite = rom_vars.sprite.name
@@ -502,24 +513,29 @@ 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()
adjuster_settings.auto_apply = 'ask'
adjuster_settings.music = True
adjuster_settings.reduceflashing = True
adjuster_settings.deathlink = False
adjuster_settings.sprite = None
adjuster_settings.quickswap = True
adjuster_settings.menuspeed = 'normal'
adjuster_settings.heartcolor = 'red'
adjuster_settings.heartbeep = 'normal'
adjuster_settings.ow_palettes = 'default'
adjuster_settings.uw_palettes = 'default'
adjuster_settings.hud_palettes = 'default'
adjuster_settings.sword_palettes = 'default'
adjuster_settings.shield_palettes = 'default'
if not hasattr(adjuster_settings, 'sprite_pool'):
adjuster_settings.sprite_pool = []
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)
@@ -542,6 +558,10 @@ def get_rom_options_frame(parent=None):
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:')
@@ -703,7 +723,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=8, column=0, columnspan=2, sticky=W)
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')

23
Main.py
View File

@@ -17,7 +17,7 @@ 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 = (
@@ -86,12 +86,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")
@@ -125,6 +127,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()
@@ -326,11 +329,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
slot_data = {}
client_versions = {}
games = {}
minimum_versions = {"server": (0, 2, 4), "clients": client_versions}
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]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
world.player_types[slot])

View File

@@ -1,5 +1,7 @@
import argparse
import os, sys
import json
import os
import sys
import re
import atexit
import shutil
@@ -16,7 +18,6 @@ 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"
is_windows = sys.platform in ("win32", "cygwin", "msys")
@@ -33,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):
@@ -48,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')
@@ -71,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: "
@@ -127,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
@@ -151,17 +146,18 @@ 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:
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir()
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
@@ -172,16 +168,17 @@ def find_jdk() -> str:
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...")
@@ -196,9 +193,10 @@ def download_java():
sys.exit(0)
# download and install forge
def install_forge(directory: str):
jdk = find_jdk()
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
jdk = find_jdk(java_version)
if jdk is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
@@ -210,20 +208,20 @@ 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 + "\""])
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar + "\"", "--installServer", "\"" + directory + "\""])
install_process = Popen(argstring, shell=not is_windows)
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
@@ -241,46 +239,109 @@ def run_forge_server(forge_dir: str, heap_arg):
return Popen(argstring, shell=not is_windows)
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.local_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.local_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()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = None
if apmc_file is not None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
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:
if is_windows:
print("Installing Java and Minecraft Forge")
download_java()
download_java(java_version)
else:
print("Installing Minecraft Forge")
install_forge(forge_dir)
install_forge(forge_dir, forge_version, java_version)
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):
if prompt_yes_no("Did not find forge directory. Download and install forge now?"):
install_forge(forge_dir)
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

@@ -62,6 +62,9 @@ if __name__ == "__main__":
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)

View File

@@ -24,8 +24,6 @@ ModuleUpdate.update()
import websockets
import colorama
from thefuzz import process as fuzzy_process
import NetUtils
from worlds.AutoWorld import AutoWorldRegister
@@ -37,6 +35,7 @@ from Utils import get_item_name_from_id, get_location_name_from_id, \
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType
min_client_version = Version(0, 1, 6)
colorama.init()
# functions callable on storable data on the server by clients
@@ -181,6 +180,7 @@ class Context:
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
self.groups = {}
self.group_collected: typing.Dict[int, typing.Set[int]] = {}
self.random = random.Random()
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
@@ -301,7 +301,7 @@ class Context:
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for player, version in clients_ver.items():
self.minimum_client_versions[player] = Utils.Version(*version)
self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version)
self.clients = {}
for team, names in enumerate(decoded_obj['names']):
@@ -432,6 +432,7 @@ class Context:
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
"random_state": self.random.getstate(),
"group_collected": dict(self.group_collected),
"stored_data": self.stored_data,
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
"server_password": self.server_password, "password": self.password, "forfeit_mode":
@@ -486,6 +487,9 @@ class Context:
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
if "group_collected" in savedata:
self.group_collected = savedata["group_collected"]
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for item_handling = remote
@@ -764,7 +768,7 @@ def forfeit_player(ctx: Context, team: int, slot: int):
update_checked_locations(ctx, team, slot)
def collect_player(ctx: Context, team: int, slot: int):
def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
"""register any locations that are in the multidata, pointing towards this player"""
all_locations = collections.defaultdict(set)
for source_slot, location_data in ctx.locations.items():
@@ -777,6 +781,14 @@ def collect_player(ctx: Context, team: int, slot: int):
register_location_checks(ctx, team, source_player, location_ids, count_activity=False)
update_checked_locations(ctx, team, source_player)
if not is_group:
for group, group_players in ctx.groups.items():
if slot in group_players:
group_collected_players = ctx.group_collected.setdefault(group, set())
group_collected_players.add(slot)
if set(group_players) == group_collected_players:
collect_player(ctx, team, group, True)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
items = []
@@ -893,7 +905,7 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
@@ -1046,7 +1058,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_admin(self, command: str = ""):
"""Allow remote administration of the multiworld server"""
"""Allow remote administration of the multiworld server
Usage: "!admin login <password>" in order to log in to the remote interface.
Once logged in, you can then use "!admin <command>" to issue commands.
If you need further help once logged in. use "!admin /help" """
output = f"!admin {command}"
if output.lower().startswith(
@@ -1388,9 +1403,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
if "IgnoreGame" not in args["tags"] and args['game'] != game:
ignore_game = "IgnoreGame" in args["tags"] or ( # IgnoreGame is deprecated. TODO: remove after 0.3.3?
("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game"))
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = ctx.minimum_client_versions[slot]
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
if args.get('items_handling', None) is None:
@@ -1450,7 +1467,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == "GetDataPackage":
exclusions = args.get("exclusions", [])
if exclusions:
if "games" in args:
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name in set(args.get("games", []))}
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": games}}])
# TODO: remove exclusions behaviour around 0.5.0
elif exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions}
@@ -1458,6 +1481,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
package["games"] = games
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}])
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
@@ -1795,7 +1819,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
return False
def _cmd_option(self, option_name: str, option: str):
"""Set options for the server. Warning: expires on restart"""
"""Set options for the server."""
attrtype = self.ctx.simple_options.get(option_name, None)
if attrtype:

View File

@@ -111,6 +111,7 @@ def get_any_version(data: dict) -> Version:
whitelist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
}
custom_hooks = {

View File

@@ -48,9 +48,12 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
script_version: int = 1
def get_item_value(ap_id):
return ap_id - 66000
class OoTCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
@@ -60,6 +63,13 @@ class OoTCommandProcessor(ClientCommandProcessor):
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
@@ -76,6 +86,8 @@ class OoTContext(CommonContext):
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:
@@ -91,6 +103,18 @@ class OoTContext(CommonContext):
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:
@@ -108,8 +132,8 @@ def get_payload(ctx: OoTContext):
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Turn on deathlink if it is on
if payload['deathlinkActive'] and not ctx.deathlink_enabled:
# 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
@@ -152,21 +176,30 @@ async def n64_sync_task(ctx: OoTContext):
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to five fields:
# Data will return a dict with up to six fields:
# 1. str: player name (always)
# 2. bool: deathlink active (always)
# 3. dict[str, bool]: checked locations
# 4. bool: whether Link is currently at 0 HP
# 5. bool: whether the game currently registers as complete
# 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())
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)
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
@@ -253,13 +286,8 @@ if __name__ == '__main__':
ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
if gui_enabled:
input_task = None
from kvui import OoTManager
ctx.ui = OoTManager(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.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
@@ -271,17 +299,9 @@ if __name__ == '__main__':
if ctx.n64_sync_task:
await ctx.n64_sync_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,11 +1,15 @@
from __future__ import annotations
import abc
import math
import numbers
import typing
import random
from schema import Schema, And, Or
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"] = {}
@@ -76,7 +80,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
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
@@ -101,11 +105,173 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
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[int]):
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
@@ -130,24 +296,6 @@ class Toggle(Option[int]):
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)]
@@ -159,7 +307,7 @@ class DefaultOnToggle(Toggle):
default = 1
class Choice(Option[int]):
class Choice(NumericOption):
auto_display_name = True
def __init__(self, value: int):
@@ -216,7 +364,7 @@ class Choice(Option[int]):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class Range(Option[int], int):
class Range(NumericOption):
range_start = 0
range_end = 1
@@ -260,6 +408,21 @@ class Range(Option[int], int):
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
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
@@ -268,10 +431,11 @@ class Range(Option[int], 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)
@@ -302,13 +466,17 @@ class VerifyKeys:
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"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"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):
@@ -413,8 +581,12 @@ 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."""
class ProgressionBalancing(Range):
"""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"
@@ -453,6 +625,7 @@ class StartHints(ItemSet):
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
verify_location_name = True
class ExcludeLocations(OptionSet):
@@ -479,10 +652,31 @@ class ItemLinks(OptionList):
{
"name": And(str, len),
"item_pool": [And(str, len)],
"replacement_item": Or(And(str, len), None)
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()
@@ -490,13 +684,27 @@ class ItemLinks(OptionList):
if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.")
existing_links.add(link["name"])
for item_name in link["item_pool"]:
if item_name not in world.item_names and item_name not in world.item_name_groups:
raise Exception(f"Item {item_name} from item link {link} "
f"is not a valid item name from {world.game}")
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
raise Exception(f"Item {link['replacement_item']} from item link {link} "
f"is not a valid item name from {world.game}")
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 = {

View File

@@ -12,6 +12,9 @@ import zipfile
import sys
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
import ModuleUpdate
ModuleUpdate.update()
import Utils
current_patch_version = 4

View File

@@ -23,6 +23,9 @@ Currently, the following games are supported:
* 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

View File

@@ -12,6 +12,9 @@ import logging
import asyncio
from json import loads, dumps
import ModuleUpdate
ModuleUpdate.update()
from Utils import init_logging
if __name__ == "__main__":
@@ -25,7 +28,7 @@ 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 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")
@@ -124,6 +127,7 @@ 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.awaiting_rom = False
self.rom = None
@@ -182,6 +186,19 @@ class Context(CommonContext):
# 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
@@ -513,7 +530,8 @@ boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace -
"Thieves' Town - Boss",
'Ice Palace - Boss',
'Misery Mire - Boss',
'Turtle Rock - Boss'}}
'Turtle Rock - Boss',
'Sahasrahla'}}
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
@@ -872,7 +890,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
location = Shops.SHOP_ID_START + cnt
if int(b) and location not in ctx.locations_checked:
new_check(location)
if location in ctx.checked_locations and location not in ctx.locations_checked \
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
@@ -900,9 +918,9 @@ async def track_locations(ctx: Context, roomid, roomdata):
uw_unchecked[location_id] = (roomid, mask)
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
if 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 and \
location_id not in boss_locations:
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)
@@ -933,7 +951,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
ow_unchecked[location_id] = screenid
ow_begin = min(ow_begin, screenid)
ow_end = max(ow_end, screenid + 1)
if location_id in ctx.checked_locations and location_id in ctx.locations_info \
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
@@ -957,8 +975,9 @@ async def track_locations(ctx: Context, roomid, roomdata):
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 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:
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:
@@ -974,7 +993,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
assert (0x3c6 <= offset <= 0x3c9)
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
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
@@ -1030,6 +1049,7 @@ async def game_watcher(ctx: Context):
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:
@@ -1286,15 +1306,10 @@ 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")
ctx.run_gui()
ctx.run_cli()
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")
@@ -1310,12 +1325,6 @@ async def main():
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)
@@ -1327,7 +1336,7 @@ def get_alttp_settings(romfile: str):
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
"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 = {}
@@ -1427,7 +1436,7 @@ def get_alttp_settings(romfile: str):
if hasattr(lastSettings, "world"):
delattr(lastSettings, "world")
else:
adjusted = False;
adjusted = False
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
@@ -1441,7 +1450,5 @@ def get_alttp_settings(romfile: str):
if __name__ == '__main__':
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
asyncio.run(main())
colorama.deinit()

646
Starcraft2Client.py Normal file
View File

@@ -0,0 +1,646 @@
from __future__ import annotations
import multiprocessing
import logging
import asyncio
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 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: Context
missions_unlocked = False
def _cmd_disable_mission_check(self) -> bool:
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
the next mission in a chain the other player is doing."""
self.missions_unlocked = True
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])
if self.missions_unlocked or \
is_mission_available(mission_number, self.ctx.checked_locations, self.ctx.mission_req_table):
if self.ctx.sc2_run_task:
if not self.ctx.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!")
self.ctx.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
if self.ctx.slot is None:
sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.")
self.ctx.sc2_run_task = asyncio.create_task(starcraft_launch(self.ctx, mission_number),
name="Starcraft 2 Launch")
else:
sc2_logger.info(
"This mission is not currently unlocked. Use /unfinished or /available to see what is available.")
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 Context(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
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(Context, self).server_auth(password_requested)
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd 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 = {}
for mission in slot_req_table:
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
if cmd in {"PrintJSON"}:
noted = False
if "receiving" in args:
if args["receiving"] == self.slot:
self.announcements.append(args["data"])
noted = True
if not noted and "item" in args:
if args["item"].player == self.slot:
self.announcements.append(args["data"])
def run_gui(self):
from kvui import GameManager
class SC2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("Starcraft2", "Starcraft2"),
]
base_title = "Archipelago Starcraft 2 Client"
self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def shutdown(self):
await super(Context, self).shutdown()
if self.sc2_run_task:
self.sc2_run_task.cancel()
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 = Context(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"
]
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
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
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
lab_unlocks, protoss_unlock, minerals, vespene]
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: Context, 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: Context = None
mission_id = 0
can_read_game = False
last_received_update = 0
def __init__(self, ctx: Context, 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))
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.")
mission_req_table = {
"Liberation Day": MissionInfo(1, 7, [], completion_critical=True),
"The Outlaws": MissionInfo(2, 2, [1], completion_critical=True),
"Zero Hour": MissionInfo(3, 4, [2], completion_critical=True),
"Evacuation": MissionInfo(4, 4, [3]),
"Outbreak": MissionInfo(5, 3, [4]),
"Safe Haven": MissionInfo(6, 1, [5], number=7),
"Haven's Fall": MissionInfo(7, 1, [5], number=7),
"Smash and Grab": MissionInfo(8, 5, [3], completion_critical=True),
"The Dig": MissionInfo(9, 4, [8], number=8, completion_critical=True),
"The Moebius Factor": MissionInfo(10, 9, [9], number=11, completion_critical=True),
"Supernova": MissionInfo(11, 5, [10], number=14, completion_critical=True),
"Maw of the Void": MissionInfo(12, 6, [11], completion_critical=True),
"Devil's Playground": MissionInfo(13, 3, [3], number=4),
"Welcome to the Jungle": MissionInfo(14, 4, [13]),
"Breakout": MissionInfo(15, 3, [14], number=8),
"Ghost of a Chance": MissionInfo(16, 6, [14], number=8),
"The Great Train Robbery": MissionInfo(17, 4, [3], number=6),
"Cutthroat": MissionInfo(18, 5, [17]),
"Engine of Destruction": MissionInfo(19, 6, [18]),
"Media Blitz": MissionInfo(20, 5, [19]),
"Piercing the Shroud": MissionInfo(21, 6, [20]),
"Whispers of Doom": MissionInfo(22, 4, [9]),
"A Sinister Turn": MissionInfo(23, 4, [22]),
"Echoes of the Future": MissionInfo(24, 3, [23]),
"In Utter Darkness": MissionInfo(25, 3, [24]),
"Gates of Hell": MissionInfo(26, 2, [12], completion_critical=True),
"Belly of the Beast": MissionInfo(27, 4, [26], completion_critical=True),
"Shatter the Sky": MissionInfo(28, 5, [26], completion_critical=True),
"All-In": MissionInfo(29, -1, [27, 28], completion_critical=True, or_requirements=True)
}
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
objectives_complete = 0
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_name_getter(
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, unlocks, unfinished_locations, ctx)
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, unlocks, unfinished_locations, ctx):
unfinished_missions = []
locations_completed = []
available_missions = 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()

View File

@@ -12,7 +12,11 @@ import io
import collections
import importlib
import logging
from tkinter import Tk
if typing.TYPE_CHECKING:
from tkinter import Tk
else:
Tk = typing.Any
def tuplize_version(version: str) -> Version:
@@ -25,10 +29,11 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.1"
__version__ = "0.3.2"
version_tuple = tuplize_version(__version__)
from yaml import load, dump, SafeLoader
import jellyfish
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as Loader
@@ -36,41 +41,44 @@ 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: str) -> str:
@@ -159,6 +167,7 @@ class UniqueKeyLoader(SafeLoader):
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
@@ -258,7 +267,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",
@@ -416,7 +426,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
log_format: str = "[%(name)s at %(asctime)s]: %(message)s", exception_logger: str = ""):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
@@ -477,7 +487,8 @@ class VersionException(Exception):
pass
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")):
# noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
n = 0
while value > power:
@@ -487,3 +498,24 @@ def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P
return f"{value} {power_labels[n]}"
else:
return f"{value:0.3f} {power_labels[n]}"
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]
)
)

View File

@@ -1,6 +1,8 @@
import os
import sys
import multiprocessing
import logging
import typing
import ModuleUpdate
@@ -20,6 +22,8 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister, WebWorld
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'))
@@ -36,13 +40,55 @@ def get_app():
return app
def create_ordered_tutorials_file():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
with open(os.path.join("WebHostLib", "static", "assets", "tutorial", "tutorials.json")) as source:
data = json.load(source)
data = sorted(data, key=lambda entry: entry["gameTitle"].lower())
with open(os.path.join("WebHostLib", "static", "generated", "tutorials.json"), "w") as target:
json.dump(data, target)
import shutil
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials'):
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.author
}]
}
# 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])
added = True
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__":

View File

@@ -129,6 +129,10 @@ def tutorial(game, file, lang):
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@@ -207,7 +211,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

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

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

@@ -22,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
@@ -44,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)
@@ -89,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)
@@ -108,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):

View File

@@ -1,6 +1,7 @@
flask>=2.0.3
flask>=2.1.2
pony>=0.7.16
waitress>=2.1.0
waitress>=2.1.1
flask-caching>=1.10.1
Flask-Compress>=1.11
Flask-Limiter>=2.2.0
Flask-Compress>=1.12
Flask-Limiter>=2.4.5.1
bokeh>=2.4.3

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

@@ -6,24 +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])) {
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
@@ -36,7 +36,7 @@ window.addEventListener('load', () => {
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((error) => {
}).catch(() => {
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
@@ -159,8 +159,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
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;
}

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

View File

@@ -1,578 +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": "Archipelago Website User Guide",
"description": "A guide to using the Archipelago website to generate multiworlds or host pre-generated multiworlds.",
"files": [
{
"language": "English",
"filename": "Archipelago/using_website.md",
"link": "Archipelago/using_website/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Server and Client Commands",
"description": "A guide detailing the commands available to the user when participating in an Archipelago session.",
"files": [
{
"language": "English",
"filename": "Archipelago/commands_en.md",
"link": "Archipelago/commands/en",
"authors": [
"jat2980",
"Ijwu"
]
}
]
},
{
"name": "Advanced YAML Guide",
"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",
"Alchav"
]
}
]
},
{
"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",
"Alchav"
]
}
]
}
]
},
{
"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": "A Link to the Past/multiworld_en.md",
"link": "A Link to the Past/multiworld/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Deutsch",
"filename": "A Link to the Past/multiworld_de.md",
"link": "A Link to the Past/multiworld/de",
"authors": [
"Fischfilet"
]
},
{
"language": "Español",
"filename": "A Link to the Past/multiworld_es.md",
"link": "A Link to the Past/multiworld/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "A Link to the Past/multiworld_fr.md",
"link": "A Link to the Past/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": "A Link to the Past/msu1_en.md",
"link": "A Link to the Past/msu1/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Español",
"filename": "A Link to the Past/msu1_es.md",
"link": "A Link to the Past/msu1/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "A Link to the Past/msu1_fr.md",
"link": "A Link to the Past/msu1/fr",
"authors": [
"Coxla"
]
}
]
},
{
"name": "Plando Tutorial",
"description": "A guide to creating Multiworld Plandos",
"files": [
{
"language": "English",
"filename": "A Link to the Past/plando_en.md",
"link": "A Link to the Past/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": "Ocarina of Time/setup_en.md",
"link": "Ocarina of Time/setup/en",
"authors": [
"Edos"
]
},
{
"language": "Spanish",
"filename": "Ocarina of Time/setup_es.md",
"link": "Ocarina of Time/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": "Meritous",
"tutorials": [
{
"name": "Meritous Setup Tutorial",
"description": "A guide to setting up the Archipelago Meritous software on your computer.",
"files": [
{
"language": "English",
"filename": "Meritous/setup_en.md",
"link": "Meritous/setup/en",
"authors": [
"KewlioMZX"
]
}
]
}
]
},
{
"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": "Risk of Rain 2/setup_en.md",
"link": "Risk of Rain 2/setup/en",
"authors": [
"Ijwu"
]
}
]
}
]
},
{
"gameTitle": "Raft",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up Raft integration for Archipelago multiworld games.",
"files": [
{
"language": "English",
"filename": "Raft/setup_en.md",
"link": "Raft/setup/en",
"authors": [
"SunnyBat",
"Awareqwx"
]
}
]
}
]
},
{
"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"
]
},
{
"language": "German",
"filename": "Timespinner/setup_de.md",
"link": "Timespinner/setup/de",
"authors": [
"Grrmo",
"Fynxes",
"Blaze0168"
]
}
]
}
]
},
{
"gameTitle": "SMZ3",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Archipelago Super Metroid and A Link to the Past Crossover randomizer on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "SMZ3/multiworld_en.md",
"link": "SMZ3/multiworld/en",
"authors": [
"lordlou"
]
}
]
}
]
},
{
"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": "Final Fantasy/multiworld_en.md",
"link": "Final Fantasy/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"
]
}
]
}
]
},
{
"gameTitle": "Slay the Spire",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up Slay the Spire for Archipelago. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "Slay the Spire/slay-the-spire_en.md",
"link": "Slay the Spire/slay-the-spire/en",
"authors": [
"Phar"
]
}
]
}
]
},
{
"gameTitle": "Super Mario 64 EX",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up SM64EX for MultiWorld.",
"files": [
{
"language": "English",
"filename": "Super Mario 64/setup_en.md",
"link": "Super Mario 64/setup/en",
"authors": [
"N00byKing"
]
}
]
}
]
},
{
"gameTitle": "VVVVVV",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up VVVVVV for MultiWorld.",
"files": [
{
"language": "English",
"filename": "VVVVVV/setup_en.md",
"link": "VVVVVV/setup/en",
"authors": [
"N00byKing"
]
}
]
}
]
},
{
"gameTitle": "ChecksFinder",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "ChecksFinder/checksfinder_en.md",
"link": "ChecksFinder/checksfinder/en",
"authors": [
"Mewlif"
]
}
]
}
]
},
{
"gameTitle": "ArchipIDLE",
"tutorials": [
{
"name": "Setup Guide",
"description": "A guide to playing ArchipIDLE",
"files": [
{
"language": "English",
"filename": "ArchipIDLE/guide_en.md",
"link": "ArchipIDLE/guide/en",
"authors": [
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Hollow Knight",
"tutorials": [
{
"name": "Mod Setup and Use Guide",
"description": "A guide to playing Hollow Knight with Archipelago.",
"files": [
{
"language": "English",
"filename": "Hollow Knight/setup_en.md",
"link": "Hollow Knight/setup/en",
"authors": [
"ijwu"
]
}
]
}
]
}
]

View File

@@ -3,9 +3,9 @@ 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(JSON.stringify(results)));
localStorage.removeItem('weighted-settings');
settingHash = md5(JSON.stringify(results));
localStorage.setItem('weighted-settings-hash', settingHash);
localStorage.removeItem('weighted-settings');
}
if (settingHash !== md5(JSON.stringify(results))) {

View File

@@ -6,7 +6,7 @@
}
#generate-game{
width: 700px;
width: 990px;
min-height: 360px;
text-align: center;
}
@@ -25,9 +25,26 @@
margin-bottom: 1rem;
}
#generate-game-tables-container{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
}
.table-wrapper select {
width: 200px;
}
.table-wrapper input:not([type]){
width: 200px;
}
#generate-game-form-wrapper table td{
text-align: left;
padding-right: 0.5rem;
vertical-align: top;
width: 230px;
}
#generate-form-button-row{

View File

@@ -0,0 +1,15 @@
#charts-wrapper{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.chart-container{
display: flex;
flex-direction: column;
width: calc(50% - 15px);
min-width: 400px;
margin-bottom: 30px;
}

View File

@@ -47,7 +47,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/** Directional arrow styles */
.tooltip:before, [data-tooltip]:before {
z-index: 1001;
z-index: 10000;
border: 6px solid transparent;
background: transparent;
content: "";
@@ -55,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/** Content styles */
.tooltip:after, [data-tooltip]:after {
z-index: 1000;
z-index: 10000;
padding: 8px;
width: 160px;
border-radius: 4px;

76
WebHostLib/stats.py Normal file
View File

@@ -0,0 +1,76 @@
from collections import Counter, defaultdict
from itertools import cycle
from datetime import datetime, timedelta, date
from math import tau
from bokeh.embed import components
from bokeh.palettes import Dark2_8 as palette
from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE
from flask import render_template
from pony.orm import select
from . import app, cache
from .models import Room
def get_db_data():
games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today()-timedelta(days=30000)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played
@app.route('/stats')
@cache.memoize(timeout=60*60) # regen once per hour should be plenty
def stats():
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=500, height=500)
total_games, games_played = get_db_data()
days = sorted(games_played)
cyc_palette = cycle(palette)
for game in sorted(total_games):
occurences = []
for day in days:
occurences.append(games_played[day][game])
plot.line([datetime.combine(day, datetime.min.time()) for day in days],
occurences, legend_label=game, line_width=2, color=next(cyc_palette))
total = sum(total_games.values())
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=500, height=500)
pie.axis.visible = False
data = {
"games": [],
"count": [],
"start_angles": [],
"end_angles": [],
}
current_angle = 0
for i, (game, count) in enumerate(total_games.most_common()):
data["games"].append(game)
data["count"].append(count)
data["start_angles"].append(current_angle)
angle = count / total * tau
current_angle += angle
data["end_angles"].append(current_angle)
data["colors"] = [element[1] for element in sorted((game, color) for game, color in
zip(data["games"], cycle(palette)))]
pie.wedge(x=0.5, y=0.5, radius=0.5,
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games")
script, charts = components((plot, pie))
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
chart_data=script, charts=charts)

View File

@@ -8,7 +8,7 @@
{% endblock %}
{% block body %}
{% include 'header/oceanHeader.html' %}
{% include 'header/oceanIslandHeader.html' %}
<div id="check-wrapper">
<div id="check" class="grass-island">
<h3>Upload Yaml</h3>
@@ -24,5 +24,4 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% endblock %}
{% block body %}
{% include 'header/oceanHeader.html' %}
{% include 'header/oceanIslandHeader.html' %}
<div id="check-result-wrapper">
<div id="check-result" class="grass-island">
<h1>Verification Results</h1>
@@ -30,5 +30,4 @@
</table>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -35,86 +35,157 @@
</p>
<div id="generate-game-form-wrapper">
<form id="generate-game-form" method="post" enctype="multipart/form-data">
<table>
<tbody>
<tr>
<td>
<label for="forfeit_mode">Forfeit Permission:</label>
<span
class="interactive"
data-tooltip="A forfeit releases all remaining items from the locations
in your world.">(?)
</span>
</td>
<td>
<select name="forfeit_mode" id="forfeit_mode">
<option value="auto">Automatic on goal completion</option>
<option value="goal">Allow !forfeit after goal completion</option>
<option value="auto-enabled">Automatic on goal completion and manual !forfeit</option>
<option value="enabled">Manual !forfeit</option>
<option value="disabled">Disabled</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="collect_mode">Collect Permission:</label>
<span
class="interactive"
data-tooltip="A collect releases all of your remaining items to you
from across the multiworld.">(?)
</span>
</td>
<td>
<select name="collect_mode" id="collect_mode">
<option value="goal">Allow !collect after goal completion</option>
<option value="auto">Automatic on goal completion</option>
<option value="auto-enabled">Automatic on goal completion and manual !collect</option>
<option value="enabled">Manual !collect</option>
<option value="disabled">Disabled</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="remaining_mode">Remaining Permission:</label>
<span
class="interactive"
data-tooltip="Remaining lists all items still in your world by name only.">(?)
</span>
</td>
<td>
<select name="remaining_mode" id="remaining_mode">
<option value="disabled">Disabled</option>
<option value="goal">Allow !remaining after goal completion</option>
<option value="enabled">Manual !remaining</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="hint_cost"> Hint Cost:</label>
<span
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
to get the location of that hint item.">(?)
</span>
</td>
<td>
<select name="hint_cost" id="hint_cost">
{% for n in range(0, 110, 5) %}
<option {% if n == 10 %}selected="selected" {% endif %} value="{{ n }}">
{% if n > 100 %}Off{% else %}{{ n }}%{% endif %}
<div id="generate-game-tables-container">
<div class="table-wrapper">
<table>
<tbody>
<tr>
<td>
<label for="forfeit_mode">Forfeit Permission:</label>
<span
class="interactive"
data-tooltip="A forfeit releases all remaining items from the locations
in your world.">(?)
</span>
</td>
<td>
<select name="forfeit_mode" id="forfeit_mode">
<option value="auto">Automatic on goal completion</option>
<option value="goal">Allow !forfeit after goal completion</option>
<option value="auto-enabled">
Automatic on goal completion and manual !forfeit
</option>
{% endfor %}
</select>
</td>
</tr>
</tbody>
</table>
<option value="enabled">Manual !forfeit</option>
<option value="disabled">Disabled</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="collect_mode">Collect Permission:</label>
<span
class="interactive"
data-tooltip="A collect releases all of your remaining items to you
from across the multiworld.">(?)
</span>
</td>
<td>
<select name="collect_mode" id="collect_mode">
<option value="goal">Allow !collect after goal completion</option>
<option value="auto">Automatic on goal completion</option>
<option value="auto-enabled">
Automatic on goal completion and manual !collect
</option>
<option value="enabled">Manual !collect</option>
<option value="disabled">Disabled</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="remaining_mode">Remaining Permission:</label>
<span
class="interactive"
data-tooltip="Remaining lists all items still in your world by name only."
>(?)
</span>
</td>
<td>
<select name="remaining_mode" id="remaining_mode">
{% if race -%}
<option value="disabled">Disabled in Race mode</option>
{%- else -%}
<option value="disabled">Disabled</option>
<option value="goal">Allow !remaining after goal completion</option>
<option value="enabled">Manual !remaining</option>
{%- endif -%}
</select>
</td>
</tr>
<tr>
<td>
<label for="item_cheat">Item Cheat:</label>
<span
class="interactive"
data-tooltip="Allows players to use the !getitem command.">(?)
</span>
</td>
<td>
<select name="item_cheat" id="item_cheat">
{% if race -%}
<option value="0">Disabled in Race mode</option>
{%- else -%}
<option value="1">Enabled</option>
<option value="0">Disabled</option>
{%- endif -%}
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-wrapper">
<table>
<tbody>
<tr>
<td>
<label for="hint_cost"> Hint Cost:</label>
<span
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
to get the location of that hint item.">(?)
</span>
</td>
<td>
<select name="hint_cost" id="hint_cost">
{% for n in range(0, 110, 5) %}
<option {% if n == 10 %}selected="selected" {% endif %} value="{{ n }}">
{% if n > 100 %}Off{% else %}{{ n }}%{% endif %}
</option>
{% endfor %}
</select>
</td>
</tr>
<tr>
<td>
<label for="server_password">Server Password:</label>
<span
class="interactive"
data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
</span>
</td>
<td>
<input id="server_password" name="server_password">
</td>
</tr>
<tr>
<td>
<label for="plando_options">Plando Options:</label>
<span
class="interactive"
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
</span>
</td>
<td>
<input type="checkbox" name="plando_bosses" value="bosses" checked>
<label for="plando_bosses">Bosses</label><br>
<input type="checkbox" name="plando_items" value="items" checked>
<label for="plando_items">Items</label><br>
<input type="checkbox" name="plando_connections" value="connections" checked>
<label for="plando_connections">Connections</label><br>
<input type="checkbox" name="plando_texts" value="texts" checked>
<label for="plando_texts">Text</label>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="generate-form-button-row">
<input id="file-input" type="file" name="file">
</div>

View File

@@ -20,7 +20,10 @@
later,
you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %}
You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}

View File

@@ -2,6 +2,8 @@
<footer id="island-footer">
<div id="copyright-notice">Copyright 2022 Archipelago</div>
<div id="links">
<a href="/sitemap">Site Map</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>

View File

@@ -1,7 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>MultiWorld</title>
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/landing.css") }}" />
{% endblock %}

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr>
<td>{{ patch.player_id }}</td>
<td>{{ patch.player_name }}</td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.game == "Minecraft" %}

View File

@@ -0,0 +1,45 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Site Map</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %}
{% block body %}
{% include 'header/grassFlowersHeader.html' %}
<div id="site-map" class="markdown">
<h1>Site Map</h1>
<h2>Base Pages</h2>
<ul>
<li><a href="/discord">Discord Link</a></li>
<li><a href="/faq/en">F.A.Q. Page</a></li>
<li><a href="/favicon.ico">Favicon</a></li>
<li><a href="/generate">Generate Game Page</a></li>
<li><a href="/">Homepage</a></li>
<li><a href="/uploads">Host Game Page</a></li>
<li><a href="/datapackage">Raw Data Package</a></li>
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
<li><a href="/sitemap">Site Map</a></li>
<li><a href="/start-playing">Start Playing</a></li>
<li><a href="/games">Supported Games Page</a></li>
<li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="/user-content">User Content</a></li>
<li><a href="/weighted-settings">Weighted Settings Page</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
</ul>
<h2>Game Info Pages</h2>
<ul>
{% for game in games %}
<li><a href="{{ url_for('game_info', game=game, lang='en') }}">{{ game }}</a></li>
{% endfor %}
</ul>
<h2>Game Settings Pages</h2>
<ul>
{% for game in games %}
<li><a href="{{ url_for('player_settings', game=game) }}">{{ game }}</a></li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Archipelago Game Statistics</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/stats.css") }}" />
{{ css_resources|indent(4)|safe }}
{{ js_resources|indent(4)|safe }}
{{ chart_data|indent(4)|safe }}
{% endblock %}
{% block body %}
{% include 'header/grassFlowersHeader.html' %}
<div id="stats" class="markdown">
<h1>Archipelago Game Statistics</h1>
<h5>
The data on this page is updated hourly.
</h5>
<div id="charts-wrapper">
{% for chart in charts %}
<div class="chart-container">
{{ chart|indent(16)|safe }}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -21,6 +21,10 @@
{% elif world.web.settings_page %}
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
{% endif %}
{% if world.web.bug_report_page %}
<span class="link-spacer">|</span>
<a href="{{ world.web.bug_report_page }}">Report a Bug</a>
{% endif %}
</p>
{% endfor %}
</div>

View File

@@ -634,7 +634,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
"Shadow Temple": (67485, 67532),
"Spirit Temple": (67533, 67582),
"Ice Cavern": (67583, 67596),
"Gerudo Training Grounds": (67597, 67635),
"Gerudo Training Ground": (67597, 67635),
"Thieves' Hideout": (67259, 67263),
"Ganon's Castle": (67636, 67673),
}
@@ -642,7 +643,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
full_name = lookup_any_location_id_to_name[id]
if id == 67673:
return full_name[13:] # Ganons Tower Boss Key Chest
if area != 'Overworld':
if area not in ["Overworld", "Thieves' Hideout"]:
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
return full_name[len(area):]
return full_name
@@ -653,6 +654,13 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
for area, (min_id, max_id) in area_id_ranges.items()}
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
for loc in location_info["Thieves' Hideout"]:
del location_info["Overworld"][loc]
checks_done['Total'] = sum(checks_done.values())
checks_in_area['Total'] = sum(checks_in_area.values())
@@ -670,7 +678,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
"Spirit Temple": inventory[66178],
"Shadow Temple": inventory[66179],
"Bottom of the Well": inventory[66180],
"Gerudo Training Grounds": inventory[66181],
"Gerudo Training Ground": inventory[66181],
"Thieves' Hideout": inventory[66182],
"Ganon's Castle": inventory[66183],
}
boss_key_counts = {

View File

@@ -30,7 +30,7 @@
size_hint_x: 1
size_hint_y: 1
pos: (0, 0)
<ServerToolTip>:
<ToolTip>:
size: self.texture_size
size_hint: None, None
font_size: dp(18)
@@ -51,4 +51,6 @@
rgba: 0.235, 0.678, 0.843, 1
Line:
width: 1
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}

View File

@@ -2,14 +2,14 @@ local socket = require("socket")
local json = require('json')
local math = require('math')
local script_version = '2022-03-22' -- Should be the last modified date
local last_modified_date = '2022-05-25' -- Should be the last modified date
local script_version = 1
--------------------------------------------------
-- Heavily modified form of RiptideSage's tracker
--------------------------------------------------
-- TODO: read this from the ROM
local NUM_BIG_POES_REQUIRED = 1
local NUM_BIG_POES_REQUIRED = 10
-- The offset constants are all from N64 RAM start. Offsets in the check statements are relative.
local save_context_offset = 0x11A5D0
@@ -464,7 +464,7 @@ local read_graveyard_checks = function()
local checks = {}
checks["Graveyard Shield Grave Chest"] = chest_check(0x40, 0x00)
checks["Graveyard Heart Piece Grave Chest"] = chest_check(0x3F, 0x00)
checks["Graveyard Composers Grave Chest"] = chest_check(0x41, 0x00)
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
@@ -899,11 +899,11 @@ end
local read_gerudo_fortress_checks = function()
local checks = {}
checks["GF North F1 Carpenter"] = on_the_ground_check(0xC, 0xC)
checks["GF North F2 Carpenter"] = on_the_ground_check(0xC, 0xA)
checks["GF South F1 Carpenter"] = on_the_ground_check(0xC, 0xE)
checks["GF South F2 Carpenter"] = on_the_ground_check(0xC, 0xF)
checks["GF Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
checks["GF Chest"] = chest_check(0x5D, 0x0)
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
checks["GF HBA 1500 Points"] = item_get_info_check(0x0, 0x7)
@@ -915,46 +915,46 @@ end
local read_gerudo_training_ground_checks = function(mq_table_address)
local checks = {}
if not is_master_quest_dungeon(mq_table_address, 0xB) then
checks["Gerudo Training Grounds Lobby Left Chest"] = chest_check(0x0B, 0x13)
checks["Gerudo Training Grounds Lobby Right Chest"] = chest_check(0x0B, 0x07)
checks["Gerudo Training Grounds Stalfos Chest"] = chest_check(0x0B, 0x00)
checks["Gerudo Training Grounds Before Heavy Block Chest"] = chest_check(0x0B, 0x11)
checks["Gerudo Training Grounds Heavy Block First Chest"] = chest_check(0x0B, 0x0F)
checks["Gerudo Training Grounds Heavy Block Second Chest"] = chest_check(0x0B, 0x0E)
checks["Gerudo Training Grounds Heavy Block Third Chest"] = chest_check(0x0B, 0x14)
checks["Gerudo Training Grounds Heavy Block Fourth Chest"] = chest_check(0x0B, 0x02)
checks["Gerudo Training Grounds Eye Statue Chest"] = chest_check(0x0B, 0x03)
checks["Gerudo Training Grounds Near Scarecrow Chest"] = chest_check(0x0B, 0x04)
checks["Gerudo Training Grounds Hammer Room Clear Chest"] = chest_check(0x0B, 0x12)
checks["Gerudo Training Grounds Hammer Room Switch Chest"] = chest_check(0x0B, 0x10)
checks["Gerudo Training Grounds Freestanding Key"] = on_the_ground_check(0x0B, 0x1)
checks["Gerudo Training Grounds Maze Right Central Chest"] = chest_check(0x0B, 0x05)
checks["Gerudo Training Grounds Maze Right Side Chest"] = chest_check(0x0B, 0x08)
checks["Gerudo Training Grounds Underwater Silver Rupee Chest"] = chest_check(0x0B, 0x0D)
checks["Gerudo Training Grounds Beamos Chest"] = chest_check(0x0B, 0x01)
checks["Gerudo Training Grounds Hidden Ceiling Chest"] = chest_check(0x0B, 0x0B)
checks["Gerudo Training Grounds Maze Path First Chest"] = chest_check(0x0B, 0x06)
checks["Gerudo Training Grounds Maze Path Second Chest"] = chest_check(0x0B, 0x0A)
checks["Gerudo Training Grounds Maze Path Third Chest"] = chest_check(0x0B, 0x09)
checks["Gerudo Training Grounds Maze Path Final Chest"] = chest_check(0x0B, 0x0C)
checks["Gerudo Training Ground Lobby Left Chest"] = chest_check(0x0B, 0x13)
checks["Gerudo Training Ground Lobby Right Chest"] = chest_check(0x0B, 0x07)
checks["Gerudo Training Ground Stalfos Chest"] = chest_check(0x0B, 0x00)
checks["Gerudo Training Ground Before Heavy Block Chest"] = chest_check(0x0B, 0x11)
checks["Gerudo Training Ground Heavy Block First Chest"] = chest_check(0x0B, 0x0F)
checks["Gerudo Training Ground Heavy Block Second Chest"] = chest_check(0x0B, 0x0E)
checks["Gerudo Training Ground Heavy Block Third Chest"] = chest_check(0x0B, 0x14)
checks["Gerudo Training Ground Heavy Block Fourth Chest"] = chest_check(0x0B, 0x02)
checks["Gerudo Training Ground Eye Statue Chest"] = chest_check(0x0B, 0x03)
checks["Gerudo Training Ground Near Scarecrow Chest"] = chest_check(0x0B, 0x04)
checks["Gerudo Training Ground Hammer Room Clear Chest"] = chest_check(0x0B, 0x12)
checks["Gerudo Training Ground Hammer Room Switch Chest"] = chest_check(0x0B, 0x10)
checks["Gerudo Training Ground Freestanding Key"] = on_the_ground_check(0x0B, 0x1)
checks["Gerudo Training Ground Maze Right Central Chest"] = chest_check(0x0B, 0x05)
checks["Gerudo Training Ground Maze Right Side Chest"] = chest_check(0x0B, 0x08)
checks["Gerudo Training Ground Underwater Silver Rupee Chest"] = chest_check(0x0B, 0x0D)
checks["Gerudo Training Ground Beamos Chest"] = chest_check(0x0B, 0x01)
checks["Gerudo Training Ground Hidden Ceiling Chest"] = chest_check(0x0B, 0x0B)
checks["Gerudo Training Ground Maze Path First Chest"] = chest_check(0x0B, 0x06)
checks["Gerudo Training Ground Maze Path Second Chest"] = chest_check(0x0B, 0x0A)
checks["Gerudo Training Ground Maze Path Third Chest"] = chest_check(0x0B, 0x09)
checks["Gerudo Training Ground Maze Path Final Chest"] = chest_check(0x0B, 0x0C)
else
checks["Gerudo Training Grounds MQ Lobby Left Chest"] = chest_check(0xB, 0x13)
checks["Gerudo Training Grounds MQ Lobby Right Chest"] = chest_check(0xB, 0x7)
checks["Gerudo Training Grounds MQ First Iron Knuckle Chest"] = chest_check(0xB, 0x0)
checks["Gerudo Training Grounds MQ Before Heavy Block Chest"] = chest_check(0xB, 0x11)
checks["Gerudo Training Grounds MQ Heavy Block Chest"] = chest_check(0xB, 0x2)
checks["Gerudo Training Grounds MQ Eye Statue Chest"] = chest_check(0xB, 0x3)
checks["Gerudo Training Grounds MQ Ice Arrows Chest"] = chest_check(0xB, 0x4)
checks["Gerudo Training Grounds MQ Second Iron Knuckle Chest"] = chest_check(0xB, 0x12)
checks["Gerudo Training Grounds MQ Flame Circle Chest"] = chest_check(0xB, 0xE)
checks["Gerudo Training Grounds MQ Maze Right Central Chest"] = chest_check(0xB, 0x5)
checks["Gerudo Training Grounds MQ Maze Right Side Chest"] = chest_check(0xB, 0x8)
checks["Gerudo Training Grounds MQ Underwater Silver Rupee Chest"] = chest_check(0xB, 0xD)
checks["Gerudo Training Grounds MQ Dinolfos Chest"] = chest_check(0xB, 0x1)
checks["Gerudo Training Grounds MQ Hidden Ceiling Chest"] = chest_check(0xB, 0xB)
checks["Gerudo Training Grounds MQ Maze Path First Chest"] = chest_check(0xB, 0x6)
checks["Gerudo Training Grounds MQ Maze Path Third Chest"] = chest_check(0xB, 0x9)
checks["Gerudo Training Grounds MQ Maze Path Second Chest"] = chest_check(0xB, 0xA)
checks["Gerudo Training Ground MQ Lobby Left Chest"] = chest_check(0xB, 0x13)
checks["Gerudo Training Ground MQ Lobby Right Chest"] = chest_check(0xB, 0x7)
checks["Gerudo Training Ground MQ First Iron Knuckle Chest"] = chest_check(0xB, 0x0)
checks["Gerudo Training Ground MQ Before Heavy Block Chest"] = chest_check(0xB, 0x11)
checks["Gerudo Training Ground MQ Heavy Block Chest"] = chest_check(0xB, 0x2)
checks["Gerudo Training Ground MQ Eye Statue Chest"] = chest_check(0xB, 0x3)
checks["Gerudo Training Ground MQ Ice Arrows Chest"] = chest_check(0xB, 0x4)
checks["Gerudo Training Ground MQ Second Iron Knuckle Chest"] = chest_check(0xB, 0x12)
checks["Gerudo Training Ground MQ Flame Circle Chest"] = chest_check(0xB, 0xE)
checks["Gerudo Training Ground MQ Maze Right Central Chest"] = chest_check(0xB, 0x5)
checks["Gerudo Training Ground MQ Maze Right Side Chest"] = chest_check(0xB, 0x8)
checks["Gerudo Training Ground MQ Underwater Silver Rupee Chest"] = chest_check(0xB, 0xD)
checks["Gerudo Training Ground MQ Dinolfos Chest"] = chest_check(0xB, 0x1)
checks["Gerudo Training Ground MQ Hidden Ceiling Chest"] = chest_check(0xB, 0xB)
checks["Gerudo Training Ground MQ Maze Path First Chest"] = chest_check(0xB, 0x6)
checks["Gerudo Training Ground MQ Maze Path Third Chest"] = chest_check(0xB, 0x9)
checks["Gerudo Training Ground MQ Maze Path Second Chest"] = chest_check(0xB, 0xA)
end
return checks
end
@@ -1107,7 +1107,7 @@ local read_song_checks = function()
checks["Song from Impa"] = event_check(0x5, 0x9) -- Zelda's Lullaby
checks["Song from Malon"] = event_check(0x5, 0x8) -- Epona's Song
checks["Song from Saria"] = event_check(0x5, 0x7) -- Saria's Song
checks["Song from Composers Grave"] = event_check(0x5, 0xA) -- Sun's Song
checks["Song from Royal Familys Tomb"] = event_check(0x5, 0xA) -- Sun's Song
checks["Song from Ocarina of Time"] = event_check(0xA, 0x9) -- Song of Time
checks["Song from Windmill"] = event_check(0x5, 0xB) -- Song of Storms
checks["Sheik in Forest"] = event_check(0x5, 0x0) -- Minuet of Forest
@@ -1546,7 +1546,7 @@ local player_names_address = coop_context + 20
local player_name_length = 8 -- 8 bytes
local rom_name_location = player_names_address + 0x800
local master_quest_table_address = rando_context + 0xB220
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
local save_context_addr = 0x11A5D0
local internal_count_addr = save_context_addr + 0x90
@@ -1555,6 +1555,8 @@ local item_queue = {}
local first_connect = true
local game_complete = false
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
local bytes_to_string = function(bytes)
local string = ''
for i=0,#(bytes) do
@@ -1781,6 +1783,7 @@ function receive()
-- Determine message to send back
local retTable = {}
retTable["playerName"] = get_player_name()
retTable["scriptVersion"] = script_version
retTable["deathlinkActive"] = deathlink_enabled()
if InSafeState() then
retTable["locations"] = check_all_locations(master_quest_table_address)

View File

@@ -56,12 +56,22 @@ game.
### WebWorld Class
A `WebWorld` class contains specific attributes and methods that can be modified
for your world specifically on the webhost. At the moment this comprises of `settings_page`
which can be changed to a link instead of an AP generated settings page, and a `theme` to be used for your game specific AP pages. Available themes:
for your world specifically on the webhost.
`settings_page` which can be changed to a link instead of an AP generated settings page.
`theme` to be used for your game specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime |
|---|---|---|---|---|---|---|
| <img src="img/theme_dirt.JPG" width="100"> | <img src="img/theme_grass.JPG" width="100"> | <img src="img/theme_grassFlowers.JPG" width="100"> | <img src="img/theme_ice.JPG" width="100"> | <img src="img/theme_jungle.JPG" width="100"> | <img src="img/theme_ocean.JPG" width="100"> | <img src="img/theme_partyTime.JPG" width="100"> |
`bug_report_page` (optional) can be a link to a bug reporting page, most likely a GitHub issue page, that will be placed by the site to help direct users to report bugs.
`tutorials` list of `Tutorial` classes where each class represents a guide to be generated on the webhost.
`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be
prefixed with the same string as defined here. Default already has 'en'.
### MultiWorld Object
The `MultiWorld` object references the whole multiworld (all items and locations
@@ -336,7 +346,7 @@ from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from ..AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item
from BaseClasses import Region, Location, Entrance, Item, RegionType
from Utils import get_options, output_path
class MyGameItem(Item): # or from Items import MyGameItem
@@ -398,7 +408,7 @@ The world has to provide the following things for generation
`self.world.get_filled_locations(self.player)` will filter for this world.
`item.player` can be used to see if it's a local item.
In addition the following methods can be implemented
In addition, the following methods can be implemented and attributes can be set
* `def generate_early(self)`
called per player before any items or locations are created. You can set
@@ -418,16 +428,17 @@ In addition the following methods can be implemented
before, during and after the regular fill process, before `generate_output`.
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
will be used by the server to host the MultiWorld.
* `def get_required_client_version(self)`
can return a tuple of 3 ints to make sure the client is compatible to this
world (e.g. item IDs) when connecting.
Always use `return max((x,y,z), super().get_required_client_version())`
to catch updates in the lower layers that break compatibility.
* `required_client_version: Tuple(int, int, int)`
Client version as tuple of 3 ints to make sure the client is compatible to
this world (e.g. implements all required features) when connecting.
* `assert_generate(cls, world)` is a class method called at the start of
generation to check the existence of prerequisite files, usually a ROM for
games which require one.
#### generate_early
```python
def generate_early(self):
def generate_early(self) -> None:
# read player settings to world instance
self.final_boss_hp = self.world.final_boss_hp[self.player].value
```
@@ -453,7 +464,7 @@ def create_event(self, event: str):
#### create_items
```python
def create_items(self):
def create_items(self) -> None:
# Add items to the Multiworld.
# If there are two of the same item, the item has to be twice in the pool.
# Which items are added to the pool may depend on player settings,
@@ -480,23 +491,23 @@ def create_items(self):
#### create_regions
```python
def create_regions(self):
def create_regions(self) -> None:
# Add regions to the multiworld. "Menu" is the required starting point.
# Arguments to Region() are name, type, human_readable_name, player, world
r = Region("Menu", None, "Menu", self.player, self.world)
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
# Set Region.exits to a list of entrances that are reachable from region
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
# Append region to MultiWorld's regions
self.world.regions.append(r) # or use += [r...]
r = Region("Main Area", None, "Main Area", self.player, self.world)
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
# Add main area's locations to main area (all but final boss)
r.locations = [MyGameLocation(self.player, location.name,
self.location_name_to_id[location.name], r)]
r.exits = [Entrance(self.player, "Boss Door", r)]
self.world.regions.append(r)
r = Region("Boss Room", None, "Boss Room", self.player, self.world)
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
# add event to Boss Room
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
self.world.regions.append(r)
@@ -515,7 +526,7 @@ def create_regions(self):
#### generate_basic
```python
def generate_basic(self):
def generate_basic(self) -> None:
# place "Victory" at "Final Boss" and set collection as win condition
self.world.get_location("Final Boss", self.player)\
.place_locked_item(self.create_event("Victory"))
@@ -536,7 +547,7 @@ def generate_basic(self):
from ..generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type
def set_rules(self):
def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic
@@ -620,7 +631,7 @@ class MyGameWorld(World):
# ...
def set_rules(self):
set_rule(self.world.get_location("A Door", self.player),
lamda state: state._myworld_has_key(self.world, self.player))
lamda state: state._mygame_has_key(self.world, self.player))
```
### Generate Output

BIN
docs/network diagram.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

157
docs/network diagram.md Normal file
View File

@@ -0,0 +1,157 @@
```mermaid
flowchart LR
%% Diagram arranged specifically so output generates no terrible crossing lines.
%% AP Server
AS{Archipelago Server}
%% CommonClient.py
CC[CommonClient.py]
AS <-- WebSockets --> CC
%% ChecksFinder
subgraph ChecksFinder
CFC[ChecksFinderClient]
CF[ChecksFinder]
CFC <--> CF
end
CC <-- Integrated --> CFC
%% A Link to the Past
subgraph A Link to the Past
LTTP[SNES]
end
SNI <-- Various, depending on SNES device --> LTTP
%% Final Fantasy
subgraph Final Fantasy 1
FF1[FF1Client]
FFLUA[Lua Connector]
BZFF[BizHawk with Final Fantasy Loaded]
FF1 <-- LuaSockets --> FFLUA
FFLUA <--> BZFF
end
CC <-- Integrated --> FF1
%% Ocarina of Time
subgraph Ocarina of Time
OC[OoTClient]
LC[Lua Connector]
OCB[BizHawk with Ocarina of Time Loaded]
OC <-- LuaSockets --> LC
LC <--> OCB
end
CC <-- Integrated --> OC
%% SNI Connectors
SC[SNIClient]
SNI["Super Nintendo Interface (SNI)"]
CC <-- Integrated --> SC
SC <-- WebSockets --> SNI
%% Super Metroid
subgraph Super Metroid
SM[SNES]
end
SNI <-- Various, depending on SNES device --> SM
%% Super Metroid/A Link to the Past Combo Randomizer
subgraph "SMZ3"
SMZ[SNES]
end
SNI <-- Various, depending on SNES device --> SMZ
%% Native Clients or Games
%% Games or clients which compile to native or which the client is integrated in the game.
subgraph "Native"
APCLIENTPP[Game using apclientpp Client Library]
APCPP[Game using Apcpp Client Library]
subgraph Secret of Evermore
SOE[ap-soeclient]
end
SM64[Super Mario 64 Ex]
V6[VVVVVV]
MT[Meritous]
TW[The Witness]
APCLIENTPP <--> SOE
APCLIENTPP <--> MT
APCLIENTPP <-- The Witness Randomizer --> TW
APCPP <--> SM64
APCPP <--> V6
end
SOE <--> SNI <-- Various, depending on SNES device --> SOESNES
AS <-- WebSockets --> APCLIENTPP
AS <-- WebSockets --> APCPP
%% Java Based Games
subgraph Java
JM[Mod with Archipelago.MultiClient.Java]
STS[Slay the Spire]
JM <-- Mod the Spire --> STS
subgraph Minecraft
MCS[Minecraft Forge Server]
JMC[Any Java Minecraft Clients]
MCS <-- TCP --> JMC
end
JM <-- Forge Mod Loader --> MCS
end
AS <-- WebSockets --> JM
%% .NET Based Games
subgraph .NET
NM[Mod with Archipelago.MultiClient.Net]
subgraph FNA/XNA
TS[Timespinner]
RL[Rogue Legacy]
end
NM <-- TsRandomizer --> TS
NM <-- RogueLegacyRandomizer --> RL
subgraph Unity
ROR[Risk of Rain 2]
SN[Subnautica]
HK[Hollow Knight]
R[Raft]
end
NM <-- BepInEx --> ROR
NM <-- "QModLoader (BepInEx)" --> SN
NM <-- HK Modding API --> HK
NM <--> R
end
AS <-- WebSockets --> NM
%% Archipelago WebHost
subgraph "WebHost (archipelago.gg)"
WHNOTE(["Configurable (waitress, gunicorn, flask)"])
AH[AutoHoster]
PDB[(PonyORM DB)]
WH[WebHost]
FWC[Flask WebContent]
AG[AutoGenerator]
AH <-- SQL --> PDB
WH -- Subprocesses --> AH
FWC <-- SQL --> PDB
WH --> FWC
AG -- Deposit Generated Worlds --> PDB
PDB -- Provide Generation Instructions --> AG
WH -- Subprocesses --> AG
end
AH -- Subprocesses --> AS
%% Special subgraph for SoE for its SNES connection
subgraph Secret of Evermore
SOESNES[SNES]
end
%% Factorio
subgraph Factorio
FC[FactorioClient] <-- RCON --> FS[Factorio Server]
FS <-- UDP --> FG[Factorio Games]
FMOD[Factorio Mod Generated by AP]
FMAPI[Factorio Modding API]
FMAPI <--> FS
FMAPI <--> FG
FMOD <--> FMAPI
end
CC <-- Integrated --> FC
```

1
docs/network diagram.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -101,11 +101,10 @@ Sent to clients when the server refuses connection. This is sent during the init
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `SlotAlreadyTaken`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
InvalidSlot indicates that the sent 'name' field did not match any auth entry on the server.
InvalidGame indicates that a correctly named slot was found, but the game for it mismatched.
SlotAlreadyTaken indicates a connection with a different uuid is already established.
IncompatibleVersion indicates a version mismatch.
InvalidPassword indicates the wrong, or no password when it was required, was sent.
InvalidItemsHandling indicates a wrong value type or flag combination was sent.
@@ -121,7 +120,7 @@ Sent to clients when the connection handshake is successfully completed.
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
| slot_info | dict\[int, NetworkSlot\] | maps each slot to a NetworkSlot information |
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
### ReceivedItems
Sent to clients when they receive an item.
@@ -305,9 +304,9 @@ Basic chat command which sends text to the server to be distributed to other cli
Requests the data package from the server. Does not require client authentication.
#### Arguments
| Name | Type | Notes |
| ------ | ----- | ------ |
| exclusions | list\[str\] | Optional. If specified, will not send back the specified data. Such as, \["Factorio"\] -> Datapackage without Factorio data.|
| Name | Type | Notes |
|-------| ----- |---------------------------------------------------------------------------------------------------------------------------------|
| games | list\[str\] | Optional. If specified, will only send back the specified data. Such as, \["Factorio"\] -> Datapackage with only Factorio data. |
### Bounce
Send this message to the server, tell it which clients should receive the message and
@@ -569,7 +568,6 @@ Note:
| Name | Type | Notes |
| ------ | ----- | ------ |
| games | dict[str, GameData] | Mapping of all Games and their respective data |
| version | int | Sum of all per-game version numbers, for clients that don't bother with per-game caching/updating. |
#### GameData
GameData is a **dict** but contains these keys and values. It's broken out into another "type" for ease of documentation.
@@ -583,13 +581,13 @@ GameData is a **dict** but contains these keys and values. It's broken out into
### Tags
Tags are represented as a list of strings, the common Client tags follow:
| Name | Notes |
| ----- | ---- |
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| IgnoreGame | Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client is actually a Tracker, will refuse new locations from this client and send all items as if they were remote items. |
| TextOnly | Tells the server that this client will not send locations and does not want to receive items. |
| Name | Notes |
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| IgnoreGame | Deprecated. See Tracker and TextOnly. Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -105,7 +105,10 @@ factorio_options:
minecraft_options:
forge_directory: "Minecraft Forge server"
max_heap_size: "2G"
oot_options:
# release channel, currently "release", or "beta"
# any games played on the "beta" channel have a high likelihood of no longer working on the "release" channel.
release_channel: "release"
oot_options:
# File name of the OoT v1.0 ROM
rom_file: "The Legend of Zelda - Ocarina of Time.z64"
# Set this to false to never autostart a rom (such as after patching)

View File

@@ -5,7 +5,7 @@
#define MyAppExeName "ArchipelagoServer.exe"
#define MyAppIcon "data/icon.ico"
#dim VersionTuple[4]
#define MyAppVersion ParseVersion(source_path + '\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
#define MyAppVersion GetVersionComponents(source_path + '\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
#define MyAppVersionText Str(VersionTuple[0])+"."+Str(VersionTuple[1])+"."+Str(VersionTuple[2])
@@ -67,6 +67,7 @@ Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDi
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -92,6 +93,7 @@ Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: igno
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
[Icons]
@@ -104,6 +106,7 @@ Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinec
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
@@ -113,6 +116,7 @@ Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\Archipel
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
[Run]
@@ -141,12 +145,12 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{ap
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
@@ -163,6 +167,10 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text
[Code]
const

156
kvui.py
View File

@@ -36,9 +36,12 @@ from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.animation import Animation
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
if typing.TYPE_CHECKING:
import CommonClient
@@ -50,7 +53,7 @@ else:
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
"""from https://stackoverflow.com/a/605348110"""
"""originally from https://stackoverflow.com/a/605348110"""
hovered = BooleanProperty(False)
border_point = ObjectProperty(None)
@@ -61,11 +64,11 @@ class HoverBehavior(object):
Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs)
def on_mouse_pos(self, *args):
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # do proceed if I'm not displayed <=> If have no parent
pos = args[1]
# Next line to_widget allow to compensate for relative layout
return # Abort if not displayed
# to_widget translates window pos to within widget pos
inside = self.collide_point(*self.to_widget(*pos))
if self.hovered == inside:
return # We have already done what was needed
@@ -87,11 +90,19 @@ class HoverBehavior(object):
Factory.register('HoverBehavior', HoverBehavior)
class ServerToolTip(Label):
class ToolTip(Label):
pass
class ServerToolTip(ToolTip):
pass
class HovererableLabel(HoverBehavior, Label):
pass
class ServerLabel(HovererableLabel):
def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
@@ -101,6 +112,7 @@ class HovererableLabel(HoverBehavior, Label):
def on_enter(self):
self.popuplabel.text = self.get_text()
App.get_running_app().root.add_widget(self.layout)
fade_in_animation.start(self.layout)
def on_leave(self):
App.get_running_app().root.remove_widget(self.layout)
@@ -109,8 +121,6 @@ class HovererableLabel(HoverBehavior, Label):
def ctx(self) -> context_type:
return App.get_running_app().ctx
class ServerLabel(HovererableLabel):
def get_text(self):
if self.ctx.server:
ctx = self.ctx
@@ -158,10 +168,11 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
""" Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, Label):
class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
""" Add selection support to the Label """
index = None
selected = BooleanProperty(False)
tooltip = None
def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """
@@ -169,6 +180,56 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']')
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:
tx, ty = self.to_widget(*pos, relative=True)
# Why TF is Y flipped *within* the texture?
ty = self.texture_size[1] - ty
hit = False
for uid, zones in self.refs.items():
for zone in zones:
x, y, w, h = zone
if x <= tx <= w and y <= ty <= h:
self.create_tooltip(uid.split("|", 1)[1], *pos)
hit = True
break
if not hit:
self.remove_tooltip()
def on_enter(self):
pass
def on_leave(self):
self.remove_tooltip()
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
@@ -179,7 +240,7 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
else:
# Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]")))
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
cmdinput = App.get_running_app().textinput
if not cmdinput.text and " did you mean " in text:
for question in ("Didn't find something that closely matches, did you mean ",
@@ -192,7 +253,7 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
elif not cmdinput.text and text.startswith("Missing: "):
cmdinput.text = text.replace("Missing: ", "!hint_location ")
Clipboard.copy(text)
Clipboard.copy(text.replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']'))
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
@@ -369,15 +430,6 @@ class GameManager(App):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
class FactorioManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
base_title = "Archipelago Factorio Client"
class ChecksFinderManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
@@ -385,34 +437,6 @@ class ChecksFinderManager(GameManager):
base_title = "Archipelago ChecksFinder Client"
class SNIManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("SNES", "SNES"),
]
base_title = "Archipelago SNI Client"
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
class OoTManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Ocarina of Time Client"
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.INFO)
@@ -453,6 +477,34 @@ class E(ExceptionHandler):
class KivyJSONtoTextParser(JSONtoTextParser):
def __call__(self, *args, **kwargs):
self.ref_count = 0
return super(KivyJSONtoTextParser, self).__call__(*args, **kwargs)
def _handle_item_name(self, node: JSONMessagePart):
flags = node.get("flags", 0)
if flags & 0b001: # advancement
itemtype = "progression"
elif flags & 0b010: # never_exclude
itemtype = "useful"
elif flags & 0b100: # trap
itemtype = "trap"
else:
itemtype = "normal"
node.setdefault("refs", []).append("Item Class: " + itemtype)
return super(KivyJSONtoTextParser, self)._handle_item_name(node)
def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"])
slot_info = self.ctx.slot_info.get(player, None)
if slot_info:
text = f"Game: {slot_info.game}<br>" \
f"Type: {SlotType(slot_info.type).name}"
if slot_info.group_members:
text += f"<br>Members:<br> " + \
'<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members)
node.setdefault("refs", []).append(text)
return super(KivyJSONtoTextParser, self)._handle_player_id(node)
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
@@ -464,6 +516,12 @@ class KivyJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node)
return self._handle_text(node)
def _handle_text(self, node: JSONMessagePart):
for ref in node.get("refs", []):
node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]"
self.ref_count += 1
return super(KivyJSONtoTextParser, self)._handle_text(node)
ExceptionManager.add_handler(E())

View File

@@ -32,9 +32,11 @@ accessibility:
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
progression_balancing:
on: 50 # A system to reduce BK, as in times during which you can't do anything by moving your items into an earlier access sphere to make it likely you have stuff to do
off: 0 # Turn this off if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
progression_balancing: # A system to reduce BK, as in times during which you can't do anything, by moving your items into an earlier access sphere
0: 0 # Choose a lower number if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
25: 0
50: 50 # Make it likely you have stuff to do.
99: 0 # Get important items early, and stay at the front of the progression.
A Link to the Past:
### Logic Section ###
glitches_required: # Determine the logic required to complete the seed
@@ -444,6 +446,11 @@ A Link to the Past:
death_link:
false: 50
true: 0
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
# Off by default, because it currently crashes on real hardware.
false: 50
true: 0
linked_options:
- name: crosskeys

View File

@@ -1,8 +1,8 @@
colorama>=0.4.4
websockets>=10.2
websockets>=10.3
PyYAML>=6.0
thefuzz[speedup]>=0.19.0
jinja2>=3.1.1
jellyfish>=0.9.0
jinja2>=3.1.2
schema>=0.7.4
kivy>=2.1.0
bsdiff4>=1.2.2

View File

@@ -29,9 +29,9 @@ except pkg_resources.ResolutionError:
if os.path.exists("X:/pw.txt"):
print("Using signtool")
with open("X:/pw.txt") as f:
with open("X:/pw.txt", encoding="utf-8-sig") as f:
pw = f.read()
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p ' + pw + r' /fd sha256 /tr http://timestamp.digicert.com/ '
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + r'" /fd sha256 /tr http://timestamp.digicert.com/ '
else:
signtool = None
@@ -176,7 +176,8 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
shutil.copyfile(os.path.join("WebHostLib", "static", "generated", "configs", file_name),
self.buildfolder / "Players" / "Templates" / file_name)
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
# TODO: fix LttP options one day
shutil.copyfile("playerSettings.yaml", self.buildfolder / "Players" / "Templates" / "A Link to the Past.yaml")
try:
from maseya import z3pr
except ImportError:
@@ -347,7 +348,7 @@ cx_Freeze.setup(
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "kivy"],
"zip_exclude_packages": ["worlds", "kivy", "sc2"],
"include_files": [],
"include_msvcr": False,
"replace_paths": [("*", "")],

View File

@@ -584,7 +584,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
class TestBalanceMultiworldProgression(unittest.TestCase):
def assertRegionContains(self, region: Region, item: Item):
def assertRegionContains(self, region: Region, item: Item) -> bool:
for location in region.locations:
if location.item and location.item == item:
return True
@@ -592,7 +592,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
self.fail("Expected " + region.name + " to contain " + item.name +
"\n Contains" + str(list(map(lambda location: location.item, region.locations))))
def setUp(self):
def setUp(self) -> None:
multi_world = generate_multi_world(2)
self.multi_world = multi_world
player1 = generate_player_data(
@@ -628,10 +628,10 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
items = fillRegion(multi_world, region, [
player2.prog_items[1]] + items)
multi_world.progression_balancing[player1.id] = True
multi_world.progression_balancing[player2.id] = True
def test_balances_progression(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 50
self.multi_world.progression_balancing[self.player2.id].value = 50
def test_balances_progression(self):
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
@@ -640,7 +640,48 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
self.assertRegionContains(
self.player1.regions[1], self.player2.prog_items[0])
def test_ignores_priority_locations(self):
def test_balances_progression_light(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 1
self.multi_world.progression_balancing[self.player2.id].value = 1
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
balance_multiworld_progression(self.multi_world)
# TODO: arrange for a result that's different from the default
self.assertRegionContains(
self.player1.regions[1], self.player2.prog_items[0])
def test_balances_progression_heavy(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 99
self.multi_world.progression_balancing[self.player2.id].value = 99
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
balance_multiworld_progression(self.multi_world)
# TODO: arrange for a result that's different from the default
self.assertRegionContains(
self.player1.regions[1], self.player2.prog_items[0])
def test_skips_balancing_progression(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 0
self.multi_world.progression_balancing[self.player2.id].value = 0
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
balance_multiworld_progression(self.multi_world)
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
def test_ignores_priority_locations(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 50
self.multi_world.progression_balancing[self.player2.id].value = 50
self.player2.prog_items[0].location.progress_type = LocationProgressType.PRIORITY
balance_multiworld_progression(self.multi_world)

View File

@@ -0,0 +1,14 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from . import setup_default_world
class TestImplemented(unittest.TestCase):
def testCompletionCondition(self):
"""Ensure a completion condition is set that has requirements."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy"}:
with self.subTest(gamename):
world = setup_default_world(world_type)
self.assertFalse(world.completion_condition[1](world.state))

View File

@@ -22,6 +22,9 @@ class TestBase(unittest.TestCase):
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state))
with self.subTest("Completion Condition"):
self.assertTrue(world.can_beat_game(state))
def testEmptyStateCanReachSomething(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
# Final Fantasy logic is controlled by finalfantasyrandomizer.com

View File

@@ -1,12 +1,12 @@
from argparse import Namespace
from BaseClasses import MultiWorld, CollectionState
from BaseClasses import MultiWorld
from worlds.AutoWorld import call_all
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
def setup_default_world(world_type):
def setup_default_world(world_type) -> MultiWorld:
world = MultiWorld(1)
world.game[1] = world_type.game
world.player_name = {1: "Tester"}

View File

@@ -34,6 +34,9 @@ class TestGenerateMain(unittest.TestCase):
os.chdir(self.run_dir)
self.output_tempdir = TemporaryDirectory(prefix='AP_out_')
def tearDown(self):
self.output_tempdir.cleanup()
def test_generate_absolute(self):
sys.argv = [sys.argv[0], '--seed', '0',
'--player_files_path', str(self.abs_input_dir),

38
test/webhost/TestDocs.py Normal file
View File

@@ -0,0 +1,38 @@
import unittest
import Utils
import os
import WebHost
from worlds.AutoWorld import AutoWorldRegister
class TestDocs(unittest.TestCase):
def setUp(self) -> None:
self.tutorials_data = WebHost.create_ordered_tutorials_file()
def testHasTutorial(self):
games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data)
for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
with self.subTest(game_name):
try:
self.assertIn(game_name, games_with_tutorial)
except AssertionError:
# look for partial name in the tutorial name
for game in games_with_tutorial:
if game_name in game:
break
else:
self.fail(f"{game_name} has no setup tutorial. "
f"Games with Tutorial: {games_with_tutorial}")
def testHasGameInfo(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game_name)
for game_info_lang in world_type.web.game_info_languages:
with self.subTest(game_name):
self.assertTrue(
os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{game_name}.md')),
f'{game_name} missing game info file for "{game_info_lang}" language.'
)

0
test/webhost/__init__.py Normal file
View File

View File

@@ -1,16 +1,16 @@
from __future__ import annotations
import logging
from typing import Dict, Set, Tuple, List, Optional, TextIO, Any, Callable, Union
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple
from BaseClasses import MultiWorld, Item, CollectionState, Location
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
from Options import Option
class AutoWorldRegister(type):
world_types: Dict[str, World] = {}
world_types: Dict[str, type(World)] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
if "web" in dct:
assert isinstance(dct["web"], WebWorld), "WebWorld has to be instantiated."
# filter out any events
@@ -27,17 +27,27 @@ class AutoWorldRegister(type):
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
# move away from get_required_client_version function
if "game" in dct:
assert "get_required_client_version" not in dct, f"{name}: required_client_version is an attribute now"
# set minimum required_client_version from bases
if "required_client_version" in dct and bases:
for base in bases:
if "required_client_version" in base.__dict__:
dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["required_client_version"])
# construct class
new_class = super().__new__(cls, name, bases, dct)
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
AutoWorldRegister.world_types[dct["game"]] = new_class
return new_class
class AutoLogicRegister(type):
def __new__(cls, name, bases, dct):
def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister:
new_class = super().__new__(cls, name, bases, dct)
function: Callable
function: Callable[..., Any]
for item_name, function in dct.items():
if item_name == "copy_mixin":
CollectionState.additional_copy_functions.append(function)
@@ -50,13 +60,13 @@ class AutoLogicRegister(type):
return new_class
def call_single(world: MultiWorld, method_name: str, player: int, *args):
def call_single(world: MultiWorld, method_name: str, player: int, *args: Any) -> Any:
method = getattr(world.worlds[player], method_name)
return method(*args)
def call_all(world: MultiWorld, method_name: str, *args):
world_types = set()
def call_all(world: MultiWorld, method_name: str, *args: Any) -> None:
world_types: Set[AutoWorldRegister] = set()
for player in world.player_ids:
world_types.add(world.worlds[player].__class__)
call_single(world, method_name, player, *args)
@@ -67,7 +77,7 @@ def call_all(world: MultiWorld, method_name: str, *args):
stage_callable(world, *args)
def call_stage(world: MultiWorld, method_name: str, *args):
def call_stage(world: MultiWorld, method_name: str, *args: Any) -> None:
world_types = {world.worlds[player].__class__ for player in world.player_ids}
for world_type in world_types:
stage_callable = getattr(world_type, f"stage_{method_name}", None)
@@ -80,19 +90,31 @@ class WebWorld:
# display a settings page. Can be a link to an out-of-ap settings tool too.
settings_page: Union[bool, str] = True
# docs folder will be scanned for game info pages using this list in the format '{language}_{game_name}.md'
game_info_languages: List[str] = ['en']
# docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial
# class is to be used for one guide.
tutorials: List[Tutorial]
# Choose a theme for your /game/* pages
# Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime
theme = "grass"
# display a link to a bug report page, most likely a link to a GitHub issue page.
bug_report_page: Optional[str]
class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
A Game should have its own subclass of World in which it defines the required data structures."""
options: Dict[str, type(Option)] = {} # link your Options mapping
options: Dict[str, Option[Any]] = {} # link your Options mapping
game: str # name the game
topology_present: bool = False # indicate if world type has any meaningful layout/pathing
all_item_and_group_names: Set[str] = frozenset() # gets automatically populated with all item and item group names
# gets automatically populated with all item and item group names
all_item_and_group_names: FrozenSet[str] = frozenset()
# map names to their IDs
item_name_to_id: Dict[str, int] = {}
@@ -106,7 +128,15 @@ class World(metaclass=AutoWorldRegister):
# retrieved by clients on every connection.
data_version: int = 1
hint_blacklist: Set[str] = frozenset() # any names that should not be hintable
# override this if changes to a world break forward-compatibility of the client
# The base version of (0, 1, 6) is provided for backwards compatibility and does *not* need to be updated in the
# future. Protocol level compatibility check moved to MultiServer.min_client_version.
required_client_version: Tuple[int, int, int] = (0, 1, 6)
# update this if the resulting multidata breaks forward-compatibility of the server
required_server_version: Tuple[int, int, int] = (0, 2, 4)
hint_blacklist: FrozenSet[str] = frozenset() # any names that should not be hintable
# NOTE: remote_items and remote_start_inventory are now available in the network protocol for the client to set.
# These values will be removed.
@@ -148,64 +178,71 @@ class World(metaclass=AutoWorldRegister):
# can also be implemented as a classmethod and called "stage_<original_name>",
# in that case the MultiWorld object is passed as an argument and it gets called once for the entire multiworld.
# An example of this can be found in alttp as stage_pre_fill
def generate_early(self):
@classmethod
def assert_generate(cls) -> None:
"""Checks that a game is capable of generating, usually checks for some base file like a ROM.
Not run for unittests since they don't produce output"""
pass
def create_regions(self):
def generate_early(self) -> None:
pass
def create_items(self):
def create_regions(self) -> None:
pass
def set_rules(self):
def create_items(self) -> None:
pass
def generate_basic(self):
def set_rules(self) -> None:
pass
def pre_fill(self):
def generate_basic(self) -> None:
pass
def pre_fill(self) -> None:
"""Optional method that is supposed to be used for special fill stages. This is run *after* plando."""
pass
@classmethod
def fill_hook(cls, progitempool: List[Item], nonexcludeditempool: List[Item],
localrestitempool: Dict[int, List[Item]], nonlocalrestitempool: Dict[int, List[Item]],
restitempool: List[Item], fill_locations: List[Location]):
def fill_hook(cls,
progitempool: List[Item],
nonexcludeditempool: List[Item],
localrestitempool: Dict[int, List[Item]],
nonlocalrestitempool: Dict[int, List[Item]],
restitempool: List[Item],
fill_locations: List[Location]) -> None:
"""Special method that gets called as part of distribute_items_restrictive (main fill).
This gets called once per present world type."""
pass
def post_fill(self):
def post_fill(self) -> None:
"""Optional Method that is called after regular fill. Can be used to do adjustments before output generation."""
def generate_output(self, output_directory: str):
def generate_output(self, output_directory: str) -> None:
"""This method gets called from a threadpool, do not use world.random here.
If you need any last-second randomization, use MultiWorld.slot_seeds[slot] instead."""
pass
def fill_slot_data(self) -> dict:
def fill_slot_data(self) -> Dict[str, Any]: # json of WebHostLib.models.Slot
"""Fill in the slot_data field in the Connected network package."""
return {}
def modify_multidata(self, multidata: dict):
def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata?
"""For deeper modification of server multidata."""
pass
def get_required_client_version(self) -> Tuple[int, int, int]:
return 0, 1, 6
# Spoiler writing is optional, these may not get called.
def write_spoiler_header(self, spoiler_handle: TextIO):
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
"""Write to the spoiler header. If individual it's right at the end of that player's options,
if as stage it's right under the common header before per-player options."""
pass
def write_spoiler(self, spoiler_handle: TextIO):
def write_spoiler(self, spoiler_handle: TextIO) -> None:
"""Write to the spoiler "middle", this is after the per-player options and before locations,
meant for useful or interesting info."""
pass
def write_spoiler_end(self, spoiler_handle: TextIO):
def write_spoiler_end(self, spoiler_handle: TextIO) -> None:
"""Write to the end of the spoiler"""
pass
@@ -219,7 +256,7 @@ class World(metaclass=AutoWorldRegister):
def get_filler_item_name(self) -> str:
"""Called when the item pool needs to be filled with additional items to match location count."""
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.world.random.choice(self.item_name_to_id)
return self.world.random.choice(tuple(self.item_name_to_id.keys()))
# decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: CollectionState, item: Item, remove: bool = False) -> Optional[str]:
@@ -230,6 +267,7 @@ class World(metaclass=AutoWorldRegister):
:param remove: indicate if this is meant to remove from state instead of adding."""
if item.advancement:
return item.name
return None
# called to create all_state, return Items that are created during pre_fill
def get_pre_fill_items(self) -> List[Item]:
@@ -260,4 +298,3 @@ class World(metaclass=AutoWorldRegister):
# please use a prefix as all of them get clobbered together
class LogicMixin(metaclass=AutoLogicRegister):
pass

View File

@@ -13,7 +13,8 @@ for file in os.scandir(os.path.dirname(__file__)):
world_folders.append(file.name)
world_folders.sort()
for world in world_folders:
importlib.import_module(f".{world}", "worlds")
if not world.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders
importlib.import_module(f".{world}", "worlds")
from .AutoWorld import AutoWorldRegister
lookup_any_item_id_to_name = {}

View File

@@ -244,7 +244,7 @@ def generate_itempool(world):
world.push_item(world.get_location('Ganon', player), ItemFactory('Triforce', player), False)
if world.goal[player] == 'icerodhunt':
world.progression_balancing[player].value = False
world.progression_balancing[player].value = 0
loc = world.get_location('Turtle Rock - Boss', player)
world.push_item(loc, ItemFactory('Triforce Piece', player), False)
world.treasure_hunt_count[player] = 1

View File

@@ -96,12 +96,14 @@ class ShopItemSlots(Range):
range_start = 0
range_end = 30
class ShopPriceModifier(Range):
"""Percentage modifier for shuffled item prices in shops"""
range_start = 0
default = 100
range_end = 400
class WorldState(Choice):
option_standard = 1
option_open = 0
@@ -299,6 +301,12 @@ class BeemizerTrapChance(BeemizerRange):
display_name = "Beemizer Trap Chance"
class AllowCollect(Toggle):
"""Allows for !collect / co-op to auto-open chests containing items for other players.
Off by default, because it currently crashes on real hardware."""
display_name = "Allow Collection of checks for other players"
alttp_options: typing.Dict[str, type(Option)] = {
"crystals_needed_for_gt": CrystalsTower,
"crystals_needed_for_ganon": CrystalsGanon,
@@ -334,6 +342,6 @@ alttp_options: typing.Dict[str, type(Option)] = {
"glitch_boots": DefaultOnToggle,
"beemizer_total_chance": BeemizerTotalChance,
"beemizer_trap_chance": BeemizerTrapChance,
"death_link": DeathLink
"death_link": DeathLink,
"allow_collect": AllowCollect
}

View File

@@ -1723,7 +1723,7 @@ def write_custom_shops(rom, world, player):
price_data = get_price_data(item['price'], item["price_type"])
replacement_price_data = get_price_data(item['replacement_price'], item['replacement_price_type'])
slot = 0 if shop.type == ShopType.TakeAny else index
if not item['item'] in item_table: # item not native to ALTTP
if item['player'] and world.game[item['player']] != "A Link to the Past": # item not native to ALTTP
item_code = get_nonnative_item_sprite(item['item'])
else:
item_code = ItemFactory(item['item'], player).code
@@ -1765,7 +1765,7 @@ def hud_format_text(text):
def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, sprite: str, palettes_options,
world=None, player=1, allow_random_on_event=False, reduceflashing=False,
triforcehud: str = None, deathlink: bool = False):
triforcehud: str = None, deathlink: bool = False, allowcollect: bool = False):
local_random = random if not world else world.slot_seeds[player]
disable_music: bool = not music
# enable instant item menu
@@ -1899,7 +1899,9 @@ def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, spri
elif palettes_options['dungeon'] == 'random':
randomize_uw_palettes(rom, local_random)
rom.write_byte(0x18008D, int(deathlink))
rom.write_byte(0x18008D, (0b00000001 if deathlink else 0) |
# 0b00000010 is already used for death_link_allow_survive in super metroid.
(0b00000100 if allowcollect else 0))
apply_random_sprite_on_event(rom, sprite, local_random, allow_random_on_event,
world.sprite_pool[player] if world else [])

View File

@@ -30,7 +30,7 @@ def set_rules(world):
# Set access rules according to max glitches for multiworld progression.
# Set accessibility to none, and shuffle assuming the no logic players can always win
world.accessibility[player] = world.accessibility[player].from_text("minimal")
world.progression_balancing[player].value = False
world.progression_balancing[player].value = 0
else:
world.completion_condition[player] = lambda state: state.has('Triforce', player)

View File

@@ -267,7 +267,7 @@ def ShopSlotFill(world):
min(int(price * world.shop_price_modifier[location.player] / 100) * 5, 9999), 1,
location.item.player if location.item.player != location.player else 0)
if 'P' in world.shop_shuffle[location.player]:
price_to_funny_price(shop.inventory[location.shop_slot], world, location.player)
price_to_funny_price(world, shop.inventory[location.shop_slot], location.player)
def create_shops(world, player: int):
@@ -499,7 +499,7 @@ def shuffle_shops(world, items, player: int):
if 'P' in option:
for item in total_inventory:
price_to_funny_price(item, world, player)
price_to_funny_price(world, item, player)
# Don't apply to upgrade shops
# Upgrade shop is only one place, and will generally be too easy to
# replenish hearts and bombs
@@ -529,14 +529,14 @@ price_blacklist = {
price_chart = {
ShopPriceType.Rupees: lambda p: p,
ShopPriceType.Hearts: lambda p: min(5, p // 5) * 8, # Each heart is 0x8 in memory, Max of 5 hearts (20 total??)
ShopPriceType.Magic: lambda p: min(15, p // 5) * 8, # Each pip is 0x8 in memory, Max of 15 pips (16 total...)
ShopPriceType.Bombs: lambda p: max(1, min(10, p // 5)), # 10 Bombs max
ShopPriceType.Arrows: lambda p: max(1, min(30, p // 5)), # 30 Arrows Max
ShopPriceType.Hearts: lambda p: min(5, p // 5) * 8, # Each heart is 0x8 in memory, Max of 5 hearts (20 total??)
ShopPriceType.Magic: lambda p: min(15, p // 5) * 8, # Each pip is 0x8 in memory, Max of 15 pips (16 total...)
ShopPriceType.Bombs: lambda p: max(1, min(10, p // 5)), # 10 Bombs max
ShopPriceType.Arrows: lambda p: max(1, min(30, p // 5)), # 30 Arrows Max
ShopPriceType.HeartContainer: lambda p: 0x8,
ShopPriceType.BombUpgrade: lambda p: 0x1,
ShopPriceType.ArrowUpgrade: lambda p: 0x1,
ShopPriceType.Keys: lambda p: min(3, (p // 100) + 1), # Max of 3 keys for a price
ShopPriceType.Keys: lambda p: min(3, (p // 100) + 1), # Max of 3 keys for a price
ShopPriceType.Potion: lambda p: (p // 5) % 5,
}
@@ -564,27 +564,27 @@ simple_price_types = [
]
def price_to_funny_price(item: dict, world, player: int):
def price_to_funny_price(world, item: dict, player: int):
"""
Converts a raw Rupee price into a special price type
"""
if item:
my_price_types = simple_price_types.copy()
world.random.shuffle(my_price_types)
for p_type in my_price_types:
# Ignore rupee prices, logic-based prices or Keys (if we're not on universal keys)
if p_type in [ShopPriceType.Rupees, ShopPriceType.BombUpgrade, ShopPriceType.ArrowUpgrade]:
price_types = [
ShopPriceType.Rupees, # included as a chance to not change price type
ShopPriceType.Hearts,
ShopPriceType.Bombs,
]
# don't pay in universal keys to get access to universal keys
if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal \
and not "Small Key (Universal)" == item['replacement']:
price_types.append(ShopPriceType.Keys)
if not world.retro[player]:
price_types.append(ShopPriceType.Arrows)
world.random.shuffle(price_types)
for p_type in price_types:
# Ignore rupee prices
if p_type == ShopPriceType.Rupees:
return
# If we're using keys...
# Check if we're in universal, check if our replacement isn't a Small Key
# Check if price isn't super small... (this will ideally be handled in a future table)
if p_type in [ShopPriceType.Keys]:
if world.smallkey_shuffle[player] != smallkey_shuffle.option_universal:
continue
elif item['replacement'] and 'Small Key' in item['replacement']:
continue
if item['price'] < 50:
continue
if any(x in item['item'] for x in price_blacklist[p_type]):
continue
else:

View File

@@ -4,11 +4,11 @@ import os
import threading
import typing
from BaseClasses import Item, CollectionState
from BaseClasses import Item, CollectionState, Tutorial
from .SubClasses import ALttPItem
from ..AutoWorld import World, LogicMixin
from ..AutoWorld import World, WebWorld, LogicMixin
from .Options import alttp_options, smallkey_shuffle
from .Items import as_dict_item_table, item_name_groups, item_table
from .Items import as_dict_item_table, item_name_groups, item_table, GetBeemizerItem
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions
from .Rules import set_rules
from .ItemPool import generate_itempool, difficulties
@@ -17,6 +17,7 @@ from .Dungeons import create_dungeons
from .Rom import LocalRom, patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, get_hash_string, \
get_base_rom_path, LttPDeltaPatch
import Patch
from itertools import chain
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
@@ -24,6 +25,82 @@ from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_con
lttp_logger = logging.getLogger("A Link to the Past")
class ALTTPWeb(WebWorld):
setup_en = Tutorial(
"Multiworld Setup Tutorial",
"A guide to setting up the Archipelago ALttP Software on your computer. This guide covers single-player, multiworld, and related software.",
"English",
"multiworld_en.md",
"multiworld/en",
["Farrak Kilhn"]
)
setup_de = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Deutsch",
"multiworld_de.md",
"multiworld/de",
["Fischfilet"]
)
setup_es = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Español",
"multiworld_es.md",
"multiworld/es",
["Edos"]
)
setup_fr = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Français",
"multiworld_fr.md",
"multiworld/fr",
["Coxla"]
)
msu = Tutorial(
"MSU-1 Setup Tutorial",
"A guide to setting up MSU-1, which allows for custom in-game music.",
"English",
"msu1_en.md",
"msu1/en",
["Farrak Kilhn"]
)
msu_es = Tutorial(
msu.tutorial_name,
msu.description,
"Español",
"msu1_es.md",
"msu1/en",
["Edos"]
)
msu_fr = Tutorial(
msu.tutorial_name,
msu.description,
"Français",
"msu1_fr.md",
"msu1/fr",
["Coxla"]
)
plando = Tutorial(
"Plando Tutorial",
"A guide to creating Multiworld Plandos with LTTP",
"English",
"plando_en.md",
"plando/en",
["Berserker"]
)
tutorials = [setup_en, setup_de, setup_es, setup_fr, msu, msu_es, msu_fr, plando]
class ALTTPWorld(World):
"""
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of
@@ -43,6 +120,8 @@ class ALTTPWorld(World):
data_version = 8
remote_items: bool = False
remote_start_inventory: bool = False
required_client_version = (0, 3, 2)
web = ALTTPWeb()
set_rules = set_rules
@@ -55,6 +134,12 @@ class ALTTPWorld(World):
self.has_progressive_bows = False
super(ALTTPWorld, self).__init__(*args, **kwargs)
@classmethod
def stage_assert_generate(cls, world):
rom_file = get_base_rom_path()
if not os.path.exists(rom_file):
raise FileNotFoundError(rom_file)
def generate_early(self):
player = self.player
world = self.world
@@ -295,7 +380,8 @@ class ALTTPWorld(World):
palettes_options, world, player, True,
reduceflashing=world.reduceflashing[player] or world.is_race,
triforcehud=world.triforcehud[player].current_key,
deathlink=world.death_link[player])
deathlink=world.death_link[player],
allowcollect=world.allow_collect[player])
outfilepname = f'_P{player}'
outfilepname += f"_{world.get_file_safe_player_name(player).replace(' ', '_')}" \
@@ -323,9 +409,6 @@ class ALTTPWorld(World):
new_name = base64.b64encode(bytes(self.rom_name)).decode()
multidata["connect_names"][new_name] = multidata["connect_names"][self.world.player_name[self.player]]
def get_required_client_version(self) -> tuple:
return max((0, 3, 1), super(ALTTPWorld, self).get_required_client_version())
def create_item(self, name: str) -> Item:
return ALttPItem(name, self.player, **as_dict_item_table[name])
@@ -397,7 +480,11 @@ class ALTTPWorld(World):
trash_count -= 1
def get_filler_item_name(self) -> str:
return "Rupees (5)" # temporary
if self.world.goal[self.player] == "icerodhunt":
item = "Nothing"
else:
item = self.world.random.choice(chain(difficulties[self.world.difficulty[self.player]].extras[0:5]))
return GetBeemizerItem(self.world, self.player, item)
def get_pre_fill_items(self):
res = []

View File

@@ -111,7 +111,8 @@ You only have to do these steps once.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355.
![Screenshot of Network Commands setting](/static/assets/tutorial/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)".

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,11 +1,20 @@
from BaseClasses import Item, MultiWorld, Region, Location, Entrance
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial
from .Items import item_table
from .Rules import set_rules
from ..AutoWorld import World, WebWorld
from datetime import datetime
class ArchipIDLEWebWorld(WebWorld):
theme = 'partyTime'
tutorials = [Tutorial(
"Setup Guide",
"A guide to playing ArchipIDLE",
"English",
"guide_en.md",
"guide/en",
["Farrak Kilhn"]
)]
class ArchipIDLEWorld(World):
@@ -15,6 +24,7 @@ class ArchipIDLEWorld(World):
game = "ArchipIDLE"
topology_present = False
data_version = 3
hidden = (datetime.now().month != 4) # ArchipIDLE is only visible during April
web = ArchipIDLEWebWorld()
item_name_to_id = {}
@@ -62,6 +72,8 @@ class ArchipIDLEWorld(World):
self.world.get_entrance('Entrance to IDLE Zone', self.player)\
.connect(self.world.get_region('IDLE Zone', self.player))
def get_filler_item_name(self) -> str:
return self.world.random.choice(item_table)
def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None):
region = Region(name, None, name, player)

View File

@@ -9,12 +9,25 @@ from .Regions import checksfinder_regions, link_checksfinder_structures
from .Rules import set_rules, set_completion_rules
from worlds.generic.Rules import exclusion_rules
from BaseClasses import Region, Entrance, Item
from BaseClasses import Region, Entrance, Item, Tutorial
from .Options import checksfinder_options
from ..AutoWorld import World
from ..AutoWorld import World, WebWorld
client_version = 7
class ChecksFinderWeb(WebWorld):
tutorials = [Tutorial(
"Multiworld Setup Tutorial",
"A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers "
"single-player, multiworld, and related software.",
"English",
"checksfinder_en.md",
"checksfinder/en",
["Mewlif"]
)]
class ChecksFinderWorld(World):
"""
ChecksFinder is a game where you avoid mines and find checks inside the board
@@ -23,6 +36,7 @@ class ChecksFinderWorld(World):
game: str = "ChecksFinder"
options = checksfinder_options
topology_present = True
web = ChecksFinderWeb()
item_name_to_id = {name: data.code for name, data in item_table.items()}
location_name_to_id = {name: data.id for name, data in advancement_table.items()}

View File

@@ -12,7 +12,7 @@
### What is a YAML file and why do I need one?
See the guide on setting up a basic YAML at the Archipelago setup
guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en)
guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en)
### Where do I get a YAML file?

View File

@@ -262,6 +262,8 @@ class FactorioWorldGen(OptionDict):
}
},
Optional("seed"): Or(None, And(int, lambda n: n >= 0)),
Optional("width"): And(int, lambda n: n >= 0),
Optional("height"): And(int, lambda n: n >= 0),
Optional("starting_area"): FloatRange(0.166, 6),
Optional("peaceful_mode"): LuaBool,
Optional("cliff_settings"): {
@@ -270,11 +272,12 @@ class FactorioWorldGen(OptionDict):
"richness": FloatRange(0, 6)
},
Optional("property_expression_names"): Schema({
"control-setting:moisture:bias": FloatRange(-0.5, 0.5),
"control-setting:moisture:frequency:multiplier": FloatRange(0.166, 6),
"control-setting:aux:bias": FloatRange(-0.5, 0.5),
"control-setting:aux:frequency:multiplier": FloatRange(0.166, 6)
}, ignore_extra_keys=True)
Optional("control-setting:moisture:bias"): FloatRange(-0.5, 0.5),
Optional("control-setting:moisture:frequency:multiplier"): FloatRange(0.166, 6),
Optional("control-setting:aux:bias"): FloatRange(-0.5, 0.5),
Optional("control-setting:aux:frequency:multiplier"): FloatRange(0.166, 6),
Optional(str): object # allow overriding all properties
}),
},
"advanced": {
Optional("pollution"): {

View File

@@ -193,8 +193,18 @@ del (raw)
recipes = {}
all_product_sources: Dict[str, Set[Recipe]] = {"character": set()}
# add uranium mining to logic graph. TODO: add to automatic extractor for mod support
raw_recipes["uranium-ore"] = {"ingredients": {"sulfuric-acid": 1}, "products": {"uranium-ore": 1}, "category": "mining",
"energy": 2}
raw_recipes["uranium-ore"] = {
"ingredients": {"sulfuric-acid": 1},
"products": {"uranium-ore": 1},
"category": "mining",
"energy": 2
}
raw_recipes["crude-oil"] = {
"ingredients": {},
"products": {"crude-oil": 1},
"category": "basic-fluid",
"energy": 1
}
# raw_recipes["iron-ore"] = {"ingredients": {}, "products": {"iron-ore": 1}, "category": "mining", "energy": 2}
# raw_recipes["copper-ore"] = {"ingredients": {}, "products": {"copper-ore": 1}, "category": "mining", "energy": 2}
@@ -225,6 +235,7 @@ for name, categories in raw_machines.items():
# add electric mining drill as a crafting machine to resolve uranium-ore
machines["electric-mining-drill"] = Machine("electric-mining-drill", {"mining"})
machines["pumpjack"] = Machine("pumpjack", {"basic-fluid"})
machines["assembling-machine-1"].categories.add("crafting-with-fluid") # mod enables this
machines["character"].categories.add("basic-crafting") # somehow this is implied and not exported
del (raw_machines)
@@ -301,6 +312,7 @@ for category_name, machine_name in machine_per_category.items():
required_technologies: Dict[str, FrozenSet[Technology]] = Utils.KeyedDefaultDict(lambda ingredient_name: frozenset(
recursively_get_unlocking_technologies(ingredient_name, unlock_func=unlock)))
def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe, satellite_recipe: Recipe) -> Set[str]:
techs = set()
if silo_recipe:

View File

@@ -1,9 +1,9 @@
import collections
import typing
from ..AutoWorld import World
from ..AutoWorld import World, WebWorld
from BaseClasses import Region, Entrance, Location, Item, RegionType
from BaseClasses import Region, Entrance, Location, Item, RegionType, Tutorial
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, rocket_recipes, \
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
@@ -16,6 +16,17 @@ from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTree
import logging
class FactorioWeb(WebWorld):
tutorials = [Tutorial(
"Multiworld Setup Tutorial",
"A guide to setting up the Archipelago Factorio software on your computer.",
"English",
"setup_en.md",
"setup/en",
["Berserker, Farrak Kilhn"]
)]
class FactorioItem(Item):
game = "Factorio"
@@ -36,12 +47,15 @@ class Factorio(World):
custom_recipes: typing.Dict[str, Recipe]
advancement_technologies: typing.Set[str]
web = FactorioWeb()
item_name_to_id = all_items
location_name_to_id = base_tech_table
item_name_groups = {
"Progressive": set(progressive_tech_table.values()),
}
data_version = 5
required_client_version = (0, 3, 0)
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
@@ -179,9 +193,6 @@ class Factorio(World):
return super(Factorio, self).collect_item(state, item, remove)
def get_required_client_version(self) -> tuple:
return max((0, 2, 6), super(Factorio, self).get_required_client_version())
options = factorio_options
@classmethod
@@ -234,14 +245,16 @@ class Factorio(World):
ingredient = pool.pop()
if liquids_used == allow_liquids and ingredient in liquids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
ingredient_raw = 0
if ingredient in all_product_sources:
ingredient_recipe = min(all_product_sources[ingredient], key=lambda recipe: recipe.rel_cost)
ingredient_raw = sum((count for ingredient, count in ingredient_recipe.base_cost.items()))
ingredient_energy = ingredient_recipe.total_energy
else:
# assume simple ore TODO: remove if tree when mining data is harvested from Factorio
ingredient_raw = 1
ingredient_energy = 2
if not ingredient_raw:
ingredient_raw = 1
if remaining_num_ingredients == 1:
max_raw = 1.1 * remaining_raw
min_raw = 0.9 * remaining_raw

View File

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

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

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