Compare commits

..

164 Commits
0.1.0 ... 0.1.3

Author SHA1 Message Date
Fabian Dill
6c1d164330 LttP: set non-native items to Power Star 2021-06-22 06:25:19 +02:00
Fabian Dill
937fee9019 Factorio: fix locale file formatting 2021-06-22 02:00:35 +02:00
Fabian Dill
023a798ac1 Factorio: refactor visibility option into tech_tree_information
set vanilla technologies to be hidden instead of disabled
          fix advancement icon still showing when no information in tech was supposed to be given
2021-06-21 22:25:49 +02:00
Fabian Dill
07d61f6d47 fix playerSettings.yaml post-merge 2021-06-21 02:51:54 +02:00
Fabian Dill
304f63aedf Merge branch 'espeon' into Archipelago_Main
# Conflicts:
#	playerSettings.yaml
2021-06-21 02:49:06 +02:00
Fabian Dill
30190f373a send /received output to self.output 2021-06-21 02:14:25 +02:00
espeon65536
b51b094cc1 Added HMG to playerSettings 2021-06-18 23:45:03 -05:00
Fabian Dill
f4a2f344a7 format MultiServer.py 2021-06-19 03:03:06 +02:00
Fabian Dill
1e7214a86b fix required plando options triggering on empty string 2021-06-19 01:00:41 +02:00
Fabian Dill
f8fd8b3585 Factorio: add toggle to disable imported blueprints 2021-06-19 01:00:21 +02:00
CaitSith2
644d62c915 Ignore Factorio AP savegame file. 2021-06-18 14:23:55 -07:00
Fabian Dill
741ec36ee1 all requires to be modified by trigggers and linked options 2021-06-18 23:17:12 +02:00
Fabian Dill
a08d7bb1b2 Settings: add requires 2021-06-18 22:15:54 +02:00
Fabian Dill
a5bf3a8407 Factorio: remove option to turn off random_tech_ingredients 2021-06-16 23:41:43 +02:00
Chris Wilson
3ee88fd8fe Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into main 2021-06-15 21:18:24 -04:00
Chris Wilson
bc9c93b180 Improvements to the WebHost
- Improved routing structure
- Improved style imports across site
- Added placeholder player-settings pages for Factorio and Minecraft
2021-06-15 21:18:14 -04:00
Fabian Dill
167958c002 fix legacy weapons trigger 2021-06-15 23:23:39 +02:00
Fabian Dill
8b16ffb629 fix LttP rom options 2021-06-15 21:15:57 +02:00
Fabian Dill
b5193162bf update playerSettings.yaml 2021-06-15 20:26:31 +02:00
Fabian Dill
bc34c237b6 move minecraft plando connections into minecraft 2021-06-15 16:34:36 +02:00
Fabian Dill
d9824d26d2 make Factorio rocket silo a static (and therefore local) node 2021-06-15 15:32:40 +02:00
Fabian Dill
8d08b55e69 move item referencing options into their game's category 2021-06-15 15:10:31 +02:00
Fabian Dill
503c844971 categorize game options 2021-06-15 14:11:46 +02:00
espeon65536
deff356910 Added HMG check to all checks for OWG and NL 2021-06-14 22:10:26 -05:00
Chris Wilson
883ebbf267 Updating WebHost structure 2021-06-14 22:27:43 -04:00
Fabian Dill
cd45116dce dynamify games listing 2021-06-15 02:35:40 +02:00
Chris Wilson
d80362c4b8 Fix 404 pages 2021-06-14 20:20:23 -04:00
Chris Wilson
384e06d6fe Subdirectory pages currently 404. I'll look into this later 2021-06-14 20:18:40 -04:00
Fabian Dill
e6f44a70d0 use flask convention for template fetching 2021-06-15 01:51:40 +02:00
Chris Wilson
0ca90ee7e8 Add subdirectory handling for zelda3, factorio, and minecraft. Add generic 404 page. 2021-06-14 19:35:02 -04:00
Fabian Dill
59a56c803a Log which player's plando has caused a placement failure 2021-06-14 23:42:13 +02:00
Fabian Dill
1e0b44bdc5 set Triforce Piece Defaults 2021-06-14 23:41:47 +02:00
Fabian Dill
2f3296bada remove _ and - from pedestal hint texts 2021-06-14 02:23:41 +02:00
Fabian Dill
434d8e0977 remove _ and - from item hint texts 2021-06-14 02:20:13 +02:00
Fabian Dill
0a89eaaf62 update trigger result key before trigger 2021-06-14 02:14:02 +02:00
Fabian Dill
cea2f81b86 remove IRH special rule now that it's a 1/1 triforce piece hunt 2021-06-13 07:57:34 +02:00
Fabian Dill
86b612f3b5 implement random-middle 2021-06-12 21:05:45 +02:00
espeon65536
d425e5eb6a disable GT junk fill in hybrid 2021-06-12 13:11:14 -05:00
Fabian Dill
183fd33f3f MultiMystery linux compatibility 2021-06-12 16:10:56 +02:00
Chris Wilson
8c82d3e747 Added a page to describe the games currently supported by AP 2021-06-12 02:49:36 -04:00
Chris Wilson
7b495f3d81 Website landing page preliminary update 2021-06-11 20:22:47 -04:00
Fabian Dill
3ea7f1cb03 Factorio Funnels: only sort current funnel, not all funnels 2021-06-11 20:18:28 +02:00
Fabian Dill
2a13fe05c6 fix import error for Hollow Knight 2021-06-11 18:05:49 +02:00
Fabian Dill
2c4c899179 move more Factorio stuff around 2021-06-11 18:02:48 +02:00
Fabian Dill
760fb32016 fix Factorio Recipe Time randomization not being deterministic 2021-06-11 14:47:13 +02:00
Fabian Dill
278f40471b fix open_pyramid default 2021-06-11 14:26:12 +02:00
Fabian Dill
20ca09c730 remove test modules 2021-06-11 14:23:59 +02:00
Fabian Dill
568a71cdbe Start implementing object oriented scaffold for world types
(There's still a lot of work ahead, such as:
registering locations and items to the World, as well as methods to create_item_from_name()
many more method names for various stages
embedding Options into the world type
and many more...)
2021-06-11 14:22:44 +02:00
Fabian Dill
753a5f7cb2 Merge branch 'split' into Archipelago_Main
# Conflicts:
#	Main.py
2021-06-11 13:27:28 +02:00
espeon65536
96e13786cd Fixed broken mirrorless swamp rules 2021-06-10 18:10:25 -05:00
espeon65536
5d6592f296 Merge branch 'main' of https://github.com/espeon65536/Archipelago into main 2021-06-09 11:00:33 -05:00
Fabian Dill
534dd331b9 document item locality options properly 2021-06-09 10:13:18 +02:00
espeon65536
b3b56fcafd removed unnecessary import 2021-06-08 19:32:27 -05:00
espeon65536
671fd50cfb Moved the add_rule for mirrorless swamp to speed it up on invalid entrance shuffle type 2021-06-08 19:19:11 -05:00
espeon65536
eaf19643a9 Cleaned up code for assigning dungeon reentry rules 2021-06-08 19:18:28 -05:00
espeon65536
a582a3781b Moved the addition of HMG-specific connections to fix crossed ER 2021-06-08 18:32:22 -05:00
espeon65536
e0d90e0b21 Properly accounting for agatower not freely opening for dungeon reentry 2021-06-08 18:17:21 -05:00
espeon65536
a73189338c Fixed full ER HMG not ignoring pearl requirements on entrances 2021-06-08 18:15:47 -05:00
Fabian Dill
1e414dd370 fix tests 2021-06-08 22:14:56 +02:00
Fabian Dill
5ea03c71c0 start moving some alttp options over to the new system 2021-06-08 21:58:11 +02:00
Fabian Dill
622f8f8158 always check legal range for Range 2021-06-08 15:39:34 +02:00
Fabian Dill
821b0f0f92 document random-high and random-low 2021-06-08 14:56:41 +02:00
Fabian Dill
471b217e99 add random-high and random-low to Range Options 2021-06-08 14:48:00 +02:00
Fabian Dill
adda0eff4a implement Range option type 2021-06-08 14:15:23 +02:00
espeon65536
2001ca6566 Fixed the check on dungeon reentry not working properly 2021-06-08 01:22:16 -05:00
espeon65536
b9a783d7d7 Fixed open connections breaking non-HMG seed generation 2021-06-08 00:50:28 -05:00
espeon65536
eb9ee9f41e Hybrid Major Glitches connections and logic 2021-06-07 20:19:03 -05:00
espeon65536
fae14ad283 Mystery.py correctly recognizes HMG as an option 2021-06-07 19:34:00 -05:00
Fabian Dill
4b5ac3f926 Update VC Redist 2021-06-07 11:53:33 +02:00
Fabian Dill
72e5acfb86 Factorio recipe time: adjust triangular mode 2021-06-07 11:32:39 +02:00
espeon65536
16c6e17a49 Initial handling of hybrid glitch logic outside of UnderworldGlitchRules 2021-06-07 01:19:27 -05:00
espeon65536
ac31671914 initial hybridmg logic file commit 2021-06-07 00:38:30 -05:00
Fabian Dill
4b283242fe FactorioClient: remove duplicate log 2021-06-06 23:59:15 +02:00
Fabian Dill
353ea0fbbe encode correct color 2021-06-06 23:44:04 +02:00
Fabian Dill
fc941f55ef FactorioClientGUI.py: disable multitouch emulation on mouse 2021-06-06 23:23:06 +02:00
Fabian Dill
12600a8cbd FactorioClientGUI.py: fix frozen logging 2021-06-06 23:13:19 +02:00
Fabian Dill
33fa9542e0 move FactorioJSONtoTextParser 2021-06-06 22:49:37 +02:00
Fabian Dill
d872ea32af Update various links 2021-06-06 22:14:13 +02:00
Fabian Dill
46bb2d1367 Factorio: add chaos recipe time and use random.triangular distribution 2021-06-06 21:38:53 +02:00
Fabian Dill
403ddd603f Factorio: implement random recipe times 2021-06-06 21:11:58 +02:00
Fabian Dill
7907838c24 Factorio: Revamp Tech Tree Layouts 2021-06-06 20:26:40 +02:00
Fabian Dill
15bd79186a remove player_name feature in MultiMystery
MultiMystery is slated to be integrated into Mystery and the auto-launch feature is not maintainable for a growing games list
2021-06-06 18:12:19 +02:00
Fabian Dill
4555b77204 FactorioClient.py formatting 2021-06-06 17:50:48 +02:00
Fabian Dill
dd3c612dec Factorio: Colored ingame text relay for AP texts 2021-06-06 17:41:06 +02:00
Fabian Dill
09b6698de8 revamp some spoiler log conditions 2021-06-06 17:13:34 +02:00
Fabian Dill
27ee156706 tiny cleanup 2021-06-06 17:10:49 +02:00
espeon65536
48c3d1fa4a Added campfire for Sticky Situation, by popular demand 2021-06-06 15:10:45 +00:00
espeon65536
286254c5cd require end crystals for Free the End, since it's possible to kill the dragon with beds and not receive the advancement 2021-06-06 15:10:45 +00:00
espeon65536
82cd51f5f4 structure plando for Minecraft 2021-06-06 15:10:45 +00:00
espeon65536
08bf993146 only write Medallions section to spoiler log if there is an ALttP world 2021-06-06 15:10:45 +00:00
espeon65536
a55bcae3ec Minecraft logic improvements
- Very Very Frightening now properly accounts for getting a villager into the overworld by curing a zombie villager
- Hot Tourist Destinations no longer requires striders, since no one was using them anyway
- Saddles are now also obtainable from raids by killing a ravager (100% drop rate)
2021-06-06 15:10:45 +00:00
Fabian Dill
607a14e921 FactorioClient: log kivy exceptions 2021-06-06 16:09:00 +02:00
Fabian Dill
c71387ad00 Factorio: fix single-player static node placement 2021-06-06 16:08:17 +02:00
Fabian Dill
c095c28618 Split requirements into world types, automatically discover and resolve them. 2021-06-06 15:30:20 +02:00
Fabian Dill
cae1188ff8 Allow ModuleUpdate to use multiple requirements files, no longer need to care about naming, and use conventional requirement parsing. Also add WebHost to it. 2021-06-06 15:11:17 +02:00
CaitSith2
7e599c51f8 Make defaults for missing options in host.yaml consistent. 2021-06-05 21:15:54 -07:00
CaitSith2
6ccb9d2dc2 Fix adjuster reference 2021-06-05 13:58:59 -07:00
Fabian Dill
1d00ed463e fix updated name aliases for tracker 2021-06-05 03:54:16 +02:00
Fabian Dill
c99054e479 add /build_factorio to gitignore 2021-06-04 01:00:03 +02:00
Fabian Dill
85a9e0d0bc write Factorio options to spoiler 2021-06-04 00:29:59 +02:00
Fabian Dill
8b4ea3c80c fix max progressive item icon in per-player tracker 2021-06-03 01:02:31 +02:00
Fabian Dill
30dec34b72 update websockets 2021-06-02 04:40:43 +02:00
Fabian Dill
a3d2df7c45 Merge branch 'factorio_gui_client' into Archipelago_Main 2021-06-02 04:31:39 +02:00
Fabian Dill
034f338f45 set default hint cost to 10 2021-06-01 04:28:15 +02:00
Fabian Dill
1d84346705 Factorio: Don't trigger bridge file on receiving a technology from server 2021-05-29 20:02:36 +02:00
Fabian Dill
6e916ebd45 bake correct minimum version for Factorio into multidata 2021-05-29 06:23:35 +02:00
Fabian Dill
a993bed8dc move factorio_client_setup.py into setup.py 2021-05-27 12:26:08 +02:00
Fabian Dill
aa6f65ee1f Prevent logical lockout from Pedestal/Pyramid Fairy in ice rod hunt 2021-05-27 12:14:20 +02:00
Fabian Dill
573931930c remove debugging helper 2021-05-25 01:06:15 +02:00
Fabian Dill
252bb69808 FactorioClient: Read Bridge file after a server log indicates that the file was written 2021-05-25 01:03:04 +02:00
Fabian Dill
0175c8ab8a move FactorioClient log to logs folder 2021-05-24 16:09:10 +02:00
Fabian Dill
f78bb2078d make sure Factorio subprocess is terminated properly 2021-05-24 13:51:27 +02:00
Fabian Dill
bc028a63cd first version of a Factorio Graphical Client 2021-05-24 12:49:01 +02:00
Fabian Dill
4b04f2b918 update icons 2021-05-24 12:48:18 +02:00
Fabian Dill
887a3b0922 update flask and jinja 2021-05-24 05:03:45 +02:00
Fabian Dill
3df78fa387 Factorio add ap_unimportant.png 2021-05-23 20:13:19 +02:00
Fabian Dill
c36ac5baba consider the ability to craft a rocket-silo for factorio completion 2021-05-22 21:13:53 +02:00
Fabian Dill
d8e33fe596 Factorio: Differentiate advancement items. 2021-05-22 10:46:27 +02:00
Fabian Dill
80b7e2e188 Factorio: Build logic for rocket launch, allow beatable only to work correctly
Convert Science requirements to Event of "automate <pack>"
2021-05-22 10:06:21 +02:00
Fabian Dill
14b430a168 Factorio: simplify resulting data-final-fixes.lua after templating a bit. 2021-05-22 08:08:37 +02:00
Fabian Dill
22aa4cbb9f Factorio: Fix Rocket Launch event getting encoded into mod 2021-05-22 07:54:12 +02:00
Fabian Dill
71bb5b850e set correct player ID for Factorio Victory 2021-05-22 07:06:09 +02:00
Fabian Dill
066c830a43 Fix LttP progressive starting Items not writing to ROM 2021-05-22 06:27:22 +02:00
Fabian Dill
760107becf remove no longer needed imports 2021-05-22 03:00:24 +02:00
Fabian Dill
8dad49e385 assign generic tracker's checked locations to correct player 2021-05-20 01:22:18 +02:00
Fabian Dill
518e5db55b use item_name filter for generic tracker 2021-05-19 21:57:10 +02:00
Fabian Dill
31a3c1cf33 Add a generic fallback tracker for all games 2021-05-19 21:55:18 +02:00
Fabian Dill
e1b4975a11 Add Crafting Machine awareness to Factorio logic
(should have no effect on vanilla, mostly for modded gameplay)
2021-05-19 06:52:53 +02:00
Fabian Dill
f8a5e8bfc7 add Factorio Victory Event 2021-05-19 05:33:44 +02:00
Fabian Dill
a656ad5cd2 potential fix for rcon timing issue 2021-05-18 20:45:56 +02:00
Fabian Dill
b43e4fae86 update websockets 2021-05-16 23:10:45 +02:00
Fabian Dill
1f17aa394e allow uploading of Factorio mods 2021-05-16 22:59:45 +02:00
Fabian Dill
a1d7bc558c preconfigure and sign qusb2snes 2021-05-16 18:30:13 +02:00
Fabian Dill
de31fc320c allow webhost handling of APMC files 2021-05-16 01:16:51 +02:00
espeon65536
685de847c4 Minecraft updates (#13)
* Minecraft locations, items, and generation without logic

* added id lookup for minecraft

* typing import fix in minecraft/Items.py

* fix 2

* implementing Minecraft options and hard/postgame advancement exclusion

* first logic pass (75/80)

* logic pass 2 and proper completion conditions

* added insane difficulty pool, modified method of excluding item pools for easier extension

* bump network_data_package version

* minecraft testing framework

* switch Ancient Debris to Netherite Scrap to avoid advancement triggering on receiving that item

* Testing now functions, split tests up by advancement pane, added some story tests

* Newer testing framework: every advancement gets its own function, for ease of testing

* fixed logic for The End... Again...

* changed option names to "include_hard_advancements" etc.

* village/pillager-related advancements now require can_adventure: weapon + food

* a few minecraft tests

* rename "Flint & Steel" to "Flint and Steel" for parity with in-game name

* additional MC tests

* more tests, mostly nether-related tests

* more tests, removed anvil path for Two Birds One Arrow

* include Minecraft slot data, and a world seed for each Minecraft player slot

* Added new items: ender pearls, lapis, porkchops

* All remaining Minecraft tests

* formatting of Minecraft tests and logic for better readability

* require Wither kill for Monsters Hunted

* properly removed 8 Emeralds item from item pool

* enchanting required for wither; fishing rod required for water breathing; water breathing required for elder guardian kill

* Added 12 new advancements (ported from old achievement system)

* renamed "On a Rail" for consistency with modern advancements

* tests for the new advancements

* moved slot_data generation for minecraft into worlds/minecraft/__init__.py, added logic_version to slot_data

* output minecraft options in the spoiler log

* modified advancement goal values for new advancements

* make non-native Minecraft items appear as Shovel in ALttP, and unknown-game items as Power Stars

* fixed glowstone block logic for Not Quite Nine Lives

* setup for shuffling MC structures: building ER world and shuffling regions/entrances

* ensured Nether Fortresses can't be placed in the End

* finished logic for structure randomization

* fixed nonnative items always showing up as Hammers in ALttP shops

* output minecraft structure info in the spoiler

* generate .apmc file for communication with MC client

* fixed structure rando always using the same seed

* move stuff to worlds/minecraft/Regions.py

* make output apmc file have consistent name with other files

* added minecraft bottle macro; fixed tests imports

* generalizing MC region generation

* restructured structure shuffling in preparation for structure plando

* only output structure rando info in spoiler if they are shuffled

* Force structure rando to always be off, for the stable release

* added Minecraft options to player settings

* formally added combat_difficulty as an option

* Added Ender Dragon into playthrough, cleaned up goal map

* Added new difficulties: Easy, Normal, Hard combat

* moved .apmc generation time to prevent outputs on failed generation

* updated tests for new combat logic

* Fixed bug causing generation to fail; removed Nether Fortress event since it should no longer be needed with the fix

* moved all MC-specific functions into gen_minecraft

* renamed "logic_version" to "client_version"

* bug fixes
properly flagged event locations/items with id None
moved generation back to Main.py to fix mysterious generation failures

* moved link_minecraft_regions into minecraft init, left create_regions in Main for caching

* added seed_name, player_name, client_version to apmc file

* reenabled structure shuffle

* added entrance tests for minecraft

* Minecraft logic updates
Wither kill now considers nether fortresses as a valid source of soul sand
A Furious Cocktail now requires beacons for resistance and village access for carrots
Uneasy Alliance now requires fishing rod to pull the ghast through the portal
On a Rail now requires iron pickaxe to make powered rails
Overkill now may require strength II without stone axe, which needs nether access

* embed all apmc info into slot_data

* updated MC tests for logic changes

* put apmc into zipfile

Co-authored-by: achuang <alexander.w.chuang@gmail.com>
2021-05-16 00:49:58 +02:00
Kono Tyran
40751f267b removed reference to playersettings yaml as full descriptions are now in the provided example. 2021-05-15 22:46:21 +00:00
Fabian Dill
3e1941a561 allow Factorio Client to recognize if it's trying to connect to the wrong multiworld. 2021-05-16 00:21:00 +02:00
Fabian Dill
8e27ad3547 include full websockets module due to dynamic imports not being identifiable by cx_freeze 2021-05-15 23:01:52 +02:00
Fabian Dill
c4f5db9c84 pass through sys args to factorio server 2021-05-15 22:11:20 +02:00
Fabian Dill
19896e1fae prepare webhost for multi-game per-slot downloads 2021-05-14 15:25:57 +02:00
Fabian Dill
23678b814d specify get_id as being alttp only 2021-05-14 14:38:23 +02:00
Fabian Dill
13fe1f2ea2 /api/generate send back error message 2021-05-14 14:12:21 +02:00
Chris Wilson
c24d6a0785 Add error message to player-settings and weighted-settings pages if the call to /api/generate returns a non-2xx response code. 2021-05-13 21:33:56 -04:00
Fabian Dill
b2f3fd56f4 bunch of fixes after testing round 2021-05-14 01:25:41 +02:00
Fabian Dill
b82d6cec31 regain basic WebHost functionality 2021-05-13 21:57:11 +02:00
Fabian Dill
c5ff962ea1 document start_hints 2021-05-13 02:53:59 +02:00
Fabian Dill
4aa56c1a7f don't default to active start_hints 2021-05-13 02:39:20 +02:00
Fabian Dill
681279cb2b Implement "start_hints" option 2021-05-13 02:35:50 +02:00
Fabian Dill
c4ea879651 "precollect" visible Factorio tech tree as hints, so points are never spent on what was visible. 2021-05-13 02:10:37 +02:00
Fabian Dill
8cdf9d2ddc faster .apsave loading and saving 2021-05-13 01:58:53 +02:00
Fabian Dill
daa959e353 remove suppress rom argument 2021-05-13 01:40:36 +02:00
Fabian Dill
d5cdff5ec9 filter hints to whom they concern 2021-05-13 01:37:50 +02:00
Fabian Dill
109eb5b9dc start of split 2021-05-13 01:34:59 +02:00
Fabian Dill
fb192b989d update jinja templates to use base static files 2021-05-13 00:41:49 +02:00
Fabian Dill
d35adc5868 Update Flask and Jinja
Flask Autoversion is now integrated
Flask config.from_file is now integrated
2021-05-13 00:28:53 +02:00
Fabian Dill
c0bf4f58ad extend gitignore 2021-05-11 23:57:42 +02:00
Kono Tyran
f24a81fdaf fix !remaining command to look beyond ALTTP 2021-05-11 21:38:44 +00:00
Kono Tyran
40ff0e867c fixed abbreviation ap to full name. 2021-05-11 21:38:34 +00:00
Fabian Dill
a231850911 Make hint costs relative 2021-05-11 23:08:50 +02:00
Fabian Dill
1b2283b173 Factorio: correctly cache control_template to allow multiple Factorio worlds 2021-05-11 13:28:58 +02:00
Fabian Dill
729088fd85 Fix generation failure if aga tower door was placed on HC ledge in inverted dungeonsfull 2021-05-11 01:26:59 +02:00
Fabian Dill
88d75a41ae Factorio setup tutorial 2021-05-10 22:42:11 +02:00
Fabian Dill
237b44ca66 Update Documentation to match compatibility variable 2021-05-10 22:04:19 +02:00
Fabian Dill
6fef30d9b3 remove german tutorial video 2021-05-10 13:06:51 +02:00
182 changed files with 3823 additions and 17859 deletions

114
.gitignore vendored
View File

@@ -12,14 +12,16 @@
*.db3
*multidata
*multisave
*.archipelago
*.apsave
build
/build_factorio/
bundle/components.wxs
dist
README.html
.vs/
EnemizerCLI/
.mypy_cache/
RaceRom.py
weights/
/MultiMystery/
@@ -35,4 +37,112 @@ mystery_result_*.yaml
success.txt
output/
Output Logs/
/factorio/
/factorio/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
Archipelago.zip

View File

@@ -23,6 +23,7 @@ class MultiWorld():
plando_items: List[PlandoItem]
plando_connections: List[PlandoConnection]
er_seeds: Dict[int, str]
worlds: Dict[int, "AutoWorld.World"]
class AttributeProxy():
def __init__(self, rule):
@@ -32,8 +33,6 @@ class MultiWorld():
return self.rule(player)
def __init__(self, players: int):
# TODO: move per-player settings into new classes per game-type instead of clumping it all together here
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
self.players = players
self.teams = 1
@@ -44,6 +43,7 @@ class MultiWorld():
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = []
self.state = CollectionState(self)
self._cached_entrances = None
@@ -112,8 +112,6 @@ class MultiWorld():
set_player_attr('bush_shuffle', False)
set_player_attr('beemizer', 0)
set_player_attr('escape_assist', [])
set_player_attr('crystals_needed_for_ganon', 7)
set_player_attr('crystals_needed_for_gt', 7)
set_player_attr('open_pyramid', False)
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0)
@@ -130,7 +128,6 @@ class MultiWorld():
set_player_attr('triforce_pieces_available', 30)
set_player_attr('triforce_pieces_required', 20)
set_player_attr('shop_shuffle', 'off')
set_player_attr('shop_shuffle_slots', 0)
set_player_attr('shuffle_prizes', "g")
set_player_attr('sprite_pool', [])
set_player_attr('dark_room_logic', "lamp")
@@ -140,15 +137,19 @@ class MultiWorld():
set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
import Options
for hk_option in Options.hollow_knight_options:
set_player_attr(hk_option, False)
self.custom_data = {}
for player in range(1, players+1):
self.worlds = {}
def set_options(self, args):
import Options
from worlds import AutoWorld
for option_set in Options.option_sets:
for option in option_set:
setattr(self, option, getattr(args, option, {}))
for player in self.player_ids:
self.custom_data[player] = {}
# self.worlds = []
# for i in range(players):
# self.worlds.append(worlds.alttp.ALTTPWorld({}, i))
self.worlds[player] = AutoWorld.AutoWorldRegister.world_types[self.game[player]](self, player)
def secure(self):
self.random = secrets.SystemRandom()
@@ -812,6 +813,9 @@ class CollectionState(object):
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_bomb_clip(self, region: Region, player: int) -> bool:
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
# Minecraft logic functions
def has_iron_ingots(self, player: int):
return self.has('Progressive Tools', player) and self.has('Ingot Crafting', player)
@@ -879,13 +883,12 @@ class CollectionState(object):
self.has('Progressive Armor', player) and self.has('Shield', player)
def can_kill_wither(self, player: int):
build_wither = self.fortress_loot(player) and (self.can_reach('The Nether', 'Region', player) or self.can_piglin_trade(player))
normal_kill = self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self.can_brew_potions(player) and self.can_enchant(player)
if self.combat_difficulty(player) == 'easy':
return build_wither and normal_kill and self.has('Archery', player)
return self.fortress_loot(player) and normal_kill and self.has('Archery', player)
elif self.combat_difficulty(player) == 'hard': # cheese kill using bedrock ceilings
return build_wither and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
return build_wither and normal_kill
return self.fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
return self.fortress_loot(player) and normal_kill
def can_kill_ender_dragon(self, player: int):
if self.combat_difficulty(player) == 'easy':
@@ -1193,6 +1196,14 @@ class Location():
return True
return False
def place_locked_item(self, item: Item):
if self.item:
raise Exception(f"Location {self} already filled.")
self.item = item
self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True
def __repr__(self):
return self.__str__()
@@ -1221,7 +1232,7 @@ class Item():
zora_credit_text = None
fluteboy_credit_text = None
def __init__(self, name: str, advancement: bool, code: int, player: int):
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
self.name = name
self.advancement = advancement
self.player = player
@@ -1229,11 +1240,11 @@ class Item():
@property
def hint_text(self):
return getattr(self, "_hint_text", self.name)
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
def pedestal_hint_text(self):
return getattr(self, "_pedestal_hint_text", self.name)
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
def __eq__(self, other):
return self.name == other.name and self.player == other.player
@@ -1440,7 +1451,7 @@ class Spoiler(object):
'triforce_pieces_available': self.world.triforce_pieces_available,
'triforce_pieces_required': self.world.triforce_pieces_required,
'shop_shuffle': self.world.shop_shuffle,
'shop_shuffle_slots': self.world.shop_shuffle_slots,
'shop_item_slots': self.world.shop_item_slots,
'shuffle_prizes': self.world.shuffle_prizes,
'sprite_pool': self.world.sprite_pool,
'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss,
@@ -1467,6 +1478,7 @@ class Spoiler(object):
return json.dumps(out)
def to_file(self, filename):
import Options
self.parse_data()
def bool_to_text(variable: Union[bool, str]) -> str:
@@ -1490,16 +1502,21 @@ class Spoiler(object):
'Yes' if self.metadata['progression_balancing'][player] else 'No'))
outfile.write('Accessibility: %s\n' % self.metadata['accessibility'][player])
if player in self.world.hk_player_ids:
import Options
for hk_option in Options.hollow_knight_options:
res = getattr(self.world, hk_option)[player]
outfile.write(f'{hk_option+":":33}{res}\n')
if player in self.world.minecraft_player_ids:
import Options
elif player in self.world.factorio_player_ids:
for f_option in Options.factorio_options:
res = getattr(self.world, f_option)[player]
outfile.write(f'{f_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n')
elif player in self.world.minecraft_player_ids:
for mc_option in Options.minecraft_options:
res = getattr(self.world, mc_option)[player]
outfile.write(f'{mc_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n')
if player in self.world.alttp_player_ids:
elif player in self.world.alttp_player_ids:
for team in range(self.world.teams):
outfile.write('%s%s\n' % (
f"Hash - {self.world.player_names[player][team]} (Team {team + 1}): " if
@@ -1551,8 +1568,8 @@ class Spoiler(object):
"f" in self.metadata["shop_shuffle"][player]))
outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.metadata["shop_shuffle"][player]))
outfile.write('Shop Slots: %s\n' %
self.metadata["shop_shuffle_slots"][player])
outfile.write('Shop Item Slots: %s\n' %
self.metadata["shop_item_slots"][player])
outfile.write('Boss shuffle: %s\n' % self.metadata['boss_shuffle'][player])
outfile.write(
'Enemy shuffle: %s\n' % bool_to_text(self.metadata['enemy_shuffle'][player]))
@@ -1575,17 +1592,24 @@ class Spoiler(object):
'<=>' if entry['direction'] == 'both' else
'<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()]))
outfile.write('\n\nMedallions:\n')
for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}')
if self.medallions:
outfile.write('\n\nMedallions:\n')
for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}')
if self.startinventory:
outfile.write('\n\nStarting Inventory:\n\n')
outfile.write('\n'.join(self.startinventory))
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(['%s: %s' % (location, item) for grouping in self.locations.values() for (location, item) in grouping.items()]))
outfile.write('\n\nShops:\n\n')
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item)) for shop in self.shops))
for player in range(1, self.world.players + 1):
if self.shops:
outfile.write('\n\nShops:\n\n')
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item)) for shop in self.shops))
for player in self.world.alttp_player_ids:
if self.world.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
outfile.write(f'\n\nBosses{(f" ({self.world.get_player_names(player)})" if self.world.players > 1 else "")}:\n')
@@ -1595,19 +1619,20 @@ class Spoiler(object):
if self.unreachables:
outfile.write('\n\nUnreachable Items:\n\n')
outfile.write('\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
outfile.write('\n\nPaths:\n\n')
path_listings = []
for location, path in sorted(self.paths.items()):
path_lines = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))
else:
path_lines.append(region)
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
if self.paths:
outfile.write('\n\nPaths:\n\n')
path_listings = []
for location, path in sorted(self.paths.items()):
path_lines = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))
else:
path_lines.append(region)
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings))
outfile.write('\n'.join(path_listings))
from worlds.alttp.Items import item_name_groups
from worlds.generic import PlandoItem, PlandoConnection

View File

@@ -47,16 +47,9 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info('Received items:')
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], self.ctx.item_name_getter(item.item),
self.ctx.location_name_getter(item.location), index,
len(self.ctx.items_received),
self.ctx.item_name_getter(item.item) in Items.progression_items)
logging.info('%s from %s (%s) (%d/%d in list)' % (
color(self.ctx.item_name_getter(item.item), 'red', 'bold'),
color(self.ctx.player_names[item.player], 'yellow'),
self.ctx.location_name_getter(item.location), index, len(self.ctx.items_received)))
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
@@ -116,7 +109,7 @@ class CommonContext():
self.team = None
self.slot = None
self.auth = None
self.ui_node = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set()
self.locations_scouted: typing.Set[int] = set()
@@ -129,7 +122,7 @@ class CommonContext():
self.input_requests = 0
# game state
self.player_names: typing.Dict[int: str] = {0: "Server"}
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
@@ -194,7 +187,7 @@ class CommonContext():
def consume_players_package(self, package: typing.List[tuple]):
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
self.player_names[0] = "Server"
self.player_names[0] = "Archipelago"
def event_invalid_slot(self):
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
@@ -223,9 +216,6 @@ class CommonContext():
async def server_loop(ctx: CommonContext, address=None):
ui_node = getattr(ctx, "ui_node", None)
if ui_node:
ui_node.send_connection_status(ctx)
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
@@ -237,8 +227,6 @@ async def server_loop(ctx: CommonContext, address=None):
# Wait for the user to provide a multiworld server address
if not address:
logger.info('Please connect to an Archipelago server.')
if ui_node:
ui_node.poll_for_server_ip()
return
address = f"ws://{address}" if "://" not in address else address
@@ -250,8 +238,6 @@ async def server_loop(ctx: CommonContext, address=None):
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
if ui_node:
ui_node.send_connection_status(ctx)
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
async for data in ctx.server.socket:
for msg in decode(data):
@@ -273,8 +259,6 @@ async def server_loop(ctx: CommonContext, address=None):
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
if ui_node:
ui_node.send_connection_status(ctx)
asyncio.create_task(server_autoreconnect(ctx))
ctx.current_reconnect_delay *= 2
@@ -292,41 +276,42 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']} points and you get {args['location_check_points']}"
f" for each location checked.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
if ctx.ui_node:
ctx.ui_node.send_game_info(ctx)
if len(args['players']) < 1:
logger.info('No player connected')
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']}% of checks points and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
if len(args['players']) < 1:
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")

View File

@@ -1,19 +1,21 @@
from __future__ import annotations
import os
import logging
import json
import string
import copy
import sys
from concurrent.futures import ThreadPoolExecutor
import colorama
import asyncio
from queue import Queue, Empty
from queue import Queue
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
from MultiServer import mark_raw
import Utils
import random
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from worlds.factorio.Technologies import lookup_id_to_name
@@ -21,8 +23,6 @@ rcon_port = 24242
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
save_name = "Archipelago"
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password)
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
options = Utils.get_options()
executable = options["factorio_options"]["executable"]
@@ -35,10 +35,14 @@ if not os.path.exists(executable):
else:
raise FileNotFoundError(executable)
threadpool = ThreadPoolExecutor(10)
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:])
thread_pool = ThreadPoolExecutor(10)
class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext
@mark_raw
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
@@ -63,14 +67,16 @@ class FactorioContext(CommonContext):
super(FactorioContext, self).__init__(*args, **kwargs)
self.send_index = 0
self.rcon_client = None
self.awaiting_bridge = False
self.raw_json_text_parser = RawJSONtoTextParser(self)
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
async def server_auth(self, password_requested):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils._version_tuple,
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': ['AP'],
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
}])
@@ -79,42 +85,50 @@ class FactorioContext(CommonContext):
logger.info(args["text"])
if self.rcon_client:
cleaned_text = args['text'].replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{cleaned_text}\")")
def on_print_json(self, args: dict):
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
pass # don't want info on other player's local pickups.
copy_data = copy.deepcopy(args["data"]) # jsontotextparser is destructive currently
logger.info(self.jsontotextparser(args["data"]))
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
logger.info(text)
if self.rcon_client:
cleaned_text = self.raw_json_text_parser(copy_data).replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
text = self.factorio_json_text_parser(args["data"])
cleaned_text = text.replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{cleaned_text}\")")
async def game_watcher(ctx: FactorioContext, bridge_file: str):
bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
bridge_counter = 0
try:
while 1:
while not ctx.exit_event.is_set():
if os.path.exists(bridge_file):
bridge_logger.info("Found Factorio Bridge file.")
while 1:
with open(bridge_file) as f:
data = json.load(f)
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
ctx.auth = data["slot_name"]
while not ctx.exit_event.is_set():
if ctx.awaiting_bridge:
ctx.awaiting_bridge = False
with open(bridge_file) as f:
data = json.load(f)
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
ctx.auth = data["slot_name"]
ctx.seed_name = data["seed_name"]
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if ctx.locations_checked != research_data:
bridge_logger.info(f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
if ctx.locations_checked != research_data:
bridge_logger.info(
f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
await asyncio.sleep(1)
else:
bridge_counter += 1
@@ -157,12 +171,13 @@ async def factorio_server_watcher(ctx: FactorioContext):
stream_factorio_output(factorio_process.stdout, factorio_queue)
stream_factorio_output(factorio_process.stderr, factorio_queue)
script_folder = None
progression_watcher = None
try:
while 1:
while not ctx.exit_event.is_set():
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if not ctx.rcon_client and "Hosting game at IP ADDR:" in msg:
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
# trigger lua interface confirmation
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
@@ -174,7 +189,10 @@ async def factorio_server_watcher(ctx: FactorioContext):
if os.path.exists(bridge_file):
os.remove(bridge_file)
logging.info(f"Bridge File Path: {bridge_file}")
asyncio.create_task(game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher")
progression_watcher = asyncio.create_task(
game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher")
if not ctx.awaiting_bridge and "Archipelago Bridge File written for game tick " in msg:
ctx.awaiting_bridge = True
if ctx.rcon_client:
while ctx.send_index < len(ctx.items_received):
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
@@ -193,6 +211,11 @@ async def factorio_server_watcher(ctx: FactorioContext):
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
finally:
factorio_process.terminate()
if progression_watcher:
await progression_watcher
async def main():
ctx = FactorioContext(None, None, True)
@@ -223,6 +246,20 @@ async def main():
await input_task
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
for color in colors:
if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray",
"brown", "cyan", "acid"}:
node["text"] = f"[color={color}]{node['text']}[/color]"
return self._handle_text(node)
elif color == "magenta":
node["text"] = f"[color=pink]{node['text']}[/color]"
return self._handle_text(node)
return self._handle_text(node)
if __name__ == '__main__':
colorama.init()
loop = asyncio.get_event_loop()

167
FactorioClientGUI.py Normal file
View File

@@ -0,0 +1,167 @@
import os
import logging
import sys
os.makedirs("logs", exist_ok=True)
if getattr(sys, "frozen", False):
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
else:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
import asyncio
from CommonClient import server_loop, logger
from FactorioClient import FactorioContext, factorio_server_watcher
async def main():
ctx = FactorioContext(None, None, True)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
ui_app = FactorioManager(ctx)
ui_task = asyncio.create_task(ui_app.async_run(), name="UI")
await ctx.exit_event.wait() # wait for signal to exit application
ui_app.stop()
ctx.server_address = None
ctx.snes_reconnect_address = None
# allow tasks to quit
await ui_task
await factorio_server_task
await ctx.server_task
if ctx.server is not None and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task is not None:
await ctx.server_task
while ctx.input_requests > 0: # clear queue for shutdown
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
from kivy.app import App
from kivy.uix.label import Label
from kivy.base import ExceptionHandler, ExceptionManager, Config
from kivy.uix.gridlayout import GridLayout
from kivy.uix.textinput import TextInput
from kivy.uix.recycleview import RecycleView
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
from kivy.lang import Builder
class FactorioManager(App):
def __init__(self, ctx):
super(FactorioManager, self).__init__()
self.ctx = ctx
self.commandprocessor = ctx.command_processor(ctx)
self.icon = "data/icon.png"
def build(self):
self.grid = GridLayout()
self.grid.cols = 1
self.tabs = TabbedPanel()
self.tabs.default_tab_text = "All"
self.title = "Archipelago Factorio Client"
pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge File Log"),
]
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
for logger_name, display_name in pairs:
bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name)
panel.content = UILog(bridge_logger)
self.tabs.add_widget(panel)
self.grid.add_widget(self.tabs)
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
textinput.bind(on_text_validate=self.on_message)
self.grid.add_widget(textinput)
self.commandprocessor("/help")
return self.grid
def on_stop(self):
self.ctx.exit_event.set()
def on_message(self, textinput: TextInput):
try:
input_text = textinput.text.strip()
textinput.text = ""
if self.ctx.input_requests > 0:
self.ctx.input_requests -= 1
self.ctx.input_queue.put_nowait(input_text)
elif input_text:
self.commandprocessor(input_text)
except Exception as e:
logger.exception(e)
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.DEBUG)
self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None:
self.on_log(record)
class UILog(RecycleView):
cols = 1
def __init__(self, *loggers_to_handle, **kwargs):
super(UILog, self).__init__(**kwargs)
self.data = []
for logger in loggers_to_handle:
logger.addHandler(LogtoUI(self.on_log))
def on_log(self, record: logging.LogRecord) -> None:
self.data.append({"text": record.getMessage()})
class E(ExceptionHandler):
def handle_exception(self, inst):
logger.exception(inst)
return ExceptionManager.RAISE
ExceptionManager.add_handler(E())
Config.set("input", "mouse", "mouse,disable_multitouch")
Builder.load_string('''
<TabbedPanel>
tab_width: 200
<Row@Label>:
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
Rectangle:
size: self.size
pos: self.pos
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
<UILog>:
viewclass: 'Row'
scroll_y: 0
effect_cls: "ScrollEffect"
RecycleBoxLayout:
default_size: None, dp(20)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
''')
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

143
Fill.py
View File

@@ -3,7 +3,7 @@ import typing
import collections
import itertools
from BaseClasses import CollectionState, PlandoItem, Location
from BaseClasses import CollectionState, PlandoItem, Location, MultiWorld
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import key_drop_data
@@ -12,7 +12,7 @@ class FillError(RuntimeError):
pass
def fill_restrictive(world, base_state: CollectionState, locations, itempool, single_player_placement=False,
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool, single_player_placement=False,
lock=False):
def sweep_from_pool():
new_state = base_state.copy()
@@ -68,7 +68,7 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
itempool.extend(unplaced_items)
def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None):
def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_locations=None):
# If not passed in, then get a shuffled list of locations to fill in
if not fill_locations:
fill_locations = world.get_unfilled_locations()
@@ -93,7 +93,7 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
# fill in gtower locations with trash first
for player in world.alttp_player_ids:
if not gftower_trash or not world.ganonstower_vanilla[player] or \
world.logic[player] in {'owglitches', "nologic"}:
world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}:
gtower_trash_count = 0
elif 'triforcehunt' in world.goal[player] and ('local' in world.goal[player] or world.players == 1):
gtower_trash_count = world.random.randint(world.crystals_needed_for_gt[player] * 2,
@@ -167,14 +167,14 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
logging.warning(f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
def fast_fill(world, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world):
def flood_items(world: MultiWorld):
# get items to distribute
world.random.shuffle(world.itempool)
itempool = world.itempool
@@ -234,7 +234,7 @@ def flood_items(world):
break
def balance_multiworld_progression(world):
def balance_multiworld_progression(world: MultiWorld):
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
@@ -363,73 +363,76 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(world):
def distribute_planned(world: MultiWorld):
world_name_lookup = world.world_name_lookup
for player in world.player_ids:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
item = ItemFactory(placement.item, player)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
try:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
item = ItemFactory(placement.item, player)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
continue
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
except Exception as e:
raise Exception(f"Error running plando for player {player} ({world.player_names[player]})") from e

6
Gui.py
View File

@@ -428,7 +428,6 @@ def guiMain(args=None):
guiargs.fastmenu = rom_vars.fastMenuVar.get()
guiargs.create_spoiler = bool(createSpoilerVar.get())
guiargs.skip_playthrough = not bool(createSpoilerVar.get())
guiargs.suppress_rom = bool(suppressRomVar.get())
guiargs.open_pyramid = openpyramidVar.get()
guiargs.mapshuffle = bool(mapshuffleVar.get())
guiargs.compassshuffle = bool(compassshuffleVar.get())
@@ -469,7 +468,7 @@ def guiMain(args=None):
if shopWitchShuffleVar.get():
guiargs.shop_shuffle += "w"
if shopPoolShuffleVar.get():
guiargs.shop_shuffle_slots = 30
guiargs.shop_item_slots = 30
guiargs.shuffle_prizes = {"none": "",
"bonk": "b",
"general": "g",
@@ -513,7 +512,7 @@ def guiMain(args=None):
elif type(v) is dict: # use same settings for every player
setattr(guiargs, k, {player: getattr(guiargs, k) for player in range(1, guiargs.multi + 1)})
try:
if not guiargs.suppress_rom and not os.path.exists(guiargs.rom):
if not os.path.exists(guiargs.rom):
raise FileNotFoundError(f"Could not find specified rom file {guiargs.rom}")
if guiargs.count is not None:
seed = guiargs.seed
@@ -1204,7 +1203,6 @@ def guiMain(args=None):
setattr(args, k, v[1]) # only get values for player 1 for now
# load values from commandline args
createSpoilerVar.set(int(args.create_spoiler))
suppressRomVar.set(int(args.suppress_rom))
mapshuffleVar.set(args.mapshuffle)
compassshuffleVar.set(args.compassshuffle)
keyshuffleVar.set(args.keyshuffle)

View File

@@ -5,10 +5,8 @@ import tkinter as tk
from Utils import local_path
def set_icon(window):
er16 = tk.PhotoImage(file=local_path('data', 'ER16.gif'))
er32 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
er48 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
window.tk.call('wm', 'iconphoto', window._w, er16, er32, er48) # pylint: disable=protected-access
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
window.tk.call('wm', 'iconphoto', window._w, logo)
# Although tkinter is intended to be thread safe, there are many reports of issues
# some which may be platform specific, or depend on if the TCL library was compiled without

View File

@@ -18,7 +18,7 @@ class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.rom_seeds = {1: random}
self.slot_seeds = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -153,8 +153,8 @@ def adjust(args):
def adjustGUI():
from tkinter import Checkbutton, OptionMenu, Toplevel, LabelFrame, PhotoImage, Tk, LEFT, RIGHT, BOTTOM, TOP, \
StringVar, IntVar, Frame, Label, W, E, X, BOTH, Entry, Spinbox, Button, filedialog, messagebox, ttk
from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from Gui import get_rom_options_frame, get_rom_frame
from GuiUtils import set_icon
from argparse import Namespace

View File

@@ -1,18 +1,13 @@
import argparse
import atexit
import time
import functools
import webbrowser
import multiprocessing
import socket
import os
import subprocess
import base64
import shutil
from json import loads, dumps
from random import randrange
from Utils import get_item_name_from_id
exit_func = atexit.register(input, "Press enter to close.")
@@ -24,7 +19,6 @@ ModuleUpdate.update()
import colorama
from NetUtils import *
import WebUI
from worlds.alttp import Regions, Shops
from worlds.alttp import Items
@@ -45,12 +39,6 @@ class LttPCommandProcessor(ClientCommandProcessor):
self.output(f"Setting slow mode to {self.ctx.slow_mode}")
def _cmd_web(self):
if self.ctx.webui_socket_port:
webbrowser.open(f'http://localhost:5050?port={self.ctx.webui_socket_port}')
else:
self.output("Web UI was never started.")
@mark_raw
def _cmd_snes(self, snes_address: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices"""
@@ -69,21 +57,9 @@ class LttPCommandProcessor(ClientCommandProcessor):
class Context(CommonContext):
command_processor = LttPCommandProcessor
def __init__(self, snes_address, server_address, password, found_items, port: int):
def __init__(self, snes_address, server_address, password, found_items):
super(Context, self).__init__(server_address, password, found_items)
# WebUI Stuff
self.ui_node = WebUI.WebUiClient()
logger.addHandler(self.ui_node)
self.webui_socket_port: typing.Optional[int] = port
self.hint_cost = 0
self.check_points = 0
self.forfeit_mode = ''
self.remaining_mode = ''
self.hint_points = 0
# End of WebUI Stuff
# snes stuff
self.snes_address = snes_address
self.snes_socket = None
@@ -121,7 +97,7 @@ class Context(CommonContext):
self.auth = self.rom
auth = base64.b64encode(self.rom).decode()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': auth, 'version': Utils._version_tuple,
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': get_tags(self),
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
}])
@@ -162,8 +138,6 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
location_shop_order = [name for name, info in
Shops.shop_table.items()] # probably don't leave this here. This relies on python 3.6+ dictionary keys having defined order
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
@@ -495,7 +469,7 @@ async def get_snes_devices(ctx: Context):
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
ctx.ui_node.send_device_list(devices)
await socket.close()
return devices
@@ -517,8 +491,6 @@ async def snes_connect(ctx: Context, address):
if len(devices) == 1:
device = devices[0]
elif ctx.ui_node.manual_snes and ctx.ui_node.manual_snes in devices:
device = ctx.ui_node.manual_snes
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
@@ -538,7 +510,6 @@ async def snes_connect(ctx: Context, address):
await ctx.snes_socket.send(dumps(Attach_Request))
ctx.snes_state = SNESState.SNES_ATTACHED
ctx.snes_attached_device = (devices.index(device), device)
ctx.ui_node.send_connection_status(ctx)
if 'sd2snes' in device.lower() or 'COM' in device:
logger.info("SD2SNES/FXPAK Detected")
@@ -607,7 +578,6 @@ async def snes_recv_loop(ctx: Context):
ctx.snes_state = SNESState.SNES_DISCONNECTED
ctx.snes_recv_queue = asyncio.Queue()
ctx.hud_message_queue = []
ctx.ui_node.send_connection_status(ctx)
ctx.rom = None
@@ -732,9 +702,6 @@ def get_tags(ctx: Context):
return tags
async def track_locations(ctx: Context, roomid, roomdata):
new_locations = []
@@ -743,12 +710,10 @@ async def track_locations(ctx: Context, roomid, roomdata):
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
ctx.ui_node.send_location_check(ctx, location)
try:
if roomid in location_shop_ids:
misc_data = await snes_read(ctx, SHOP_ADDR, (len(location_shop_order) * 3) + 5)
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
for cnt, b in enumerate(misc_data):
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
new_check(Shops.SHOP_ID_START + cnt)
@@ -887,10 +852,6 @@ async def game_watcher(ctx: Context):
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
ctx.ui_node.notify_item_received(ctx.player_names[item.player], ctx.item_name_getter(item.item),
ctx.location_name_getter(item.location), recv_index + 1,
len(ctx.items_received),
ctx.item_name_getter(item.item) in Items.progression_items)
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index + 1, len(ctx.items_received)))
@@ -920,57 +881,6 @@ async def run_game(romfile):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def websocket_server(websocket: websockets.WebSocketServerProtocol, path, ctx: Context):
endpoint = Endpoint(websocket)
ctx.ui_node.endpoints.append(endpoint)
process_command = LttPCommandProcessor(ctx)
try:
async for incoming_data in websocket:
data = loads(incoming_data)
logging.debug(f"WebUIData:{data}")
if ('type' not in data) or ('content' not in data):
raise Exception('Invalid data received in websocket')
elif data['type'] == 'webStatus':
if data['content'] == 'connections':
ctx.ui_node.send_connection_status(ctx)
elif data['content'] == 'devices':
await get_snes_devices(ctx)
elif data['content'] == 'gameInfo':
ctx.ui_node.send_game_info(ctx)
elif data['content'] == 'checkData':
ctx.ui_node.send_location_check(ctx, 'Waiting for check...')
elif data['type'] == 'webConfig':
if 'serverAddress' in data['content']:
ctx.server_address = data['content']['serverAddress']
await ctx.connect(data['content']['serverAddress'])
elif 'deviceId' in data['content']:
# Allow a SNES disconnect via UI sending -1 as new device
if data['content']['deviceId'] == "-1":
ctx.ui_node.manual_snes = None
ctx.snes_reconnect_address = None
await snes_disconnect(ctx)
else:
await snes_disconnect(ctx)
ctx.ui_node.manual_snes = data['content']['deviceId']
await snes_connect(ctx, ctx.snes_address)
elif data['type'] == 'webControl':
if 'disconnect' in data['content']:
await ctx.disconnect()
elif data['type'] == 'webCommand':
process_command(data['content'])
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
await ctx.ui_node.disconnect(endpoint)
async def main():
multiprocessing.freeze_support()
parser = argparse.ArgumentParser()
@@ -982,8 +892,6 @@ async def main():
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
parser.add_argument('--web_ui', default=False, action='store_true',
help="Emit a webserver for the webbrowser based user interface.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if args.diff_file:
@@ -1002,23 +910,9 @@ async def main():
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
port = None
if args.web_ui:
# Find an available port on the host system to use for hosting the websocket server
while True:
port = randrange(49152, 65535)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if not sock.connect_ex(('localhost', port)) == 0:
break
import threading
WebUI.start_server(
port, on_start=threading.Timer(1, webbrowser.open, (f'http://localhost:5050?port={port}',)).start)
ctx = Context(args.snes, args.connect, args.password, args.founditems, port)
ctx = Context(args.snes, args.connect, args.password, args.founditems)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
if args.web_ui:
ui_socket = websockets.serve(functools.partial(websocket_server, ctx=ctx),
'localhost', port, ping_timeout=None, ping_interval=None)
await ui_socket
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")

431
Main.py
View File

@@ -1,4 +1,3 @@
import copy
from itertools import zip_longest
import logging
import os
@@ -7,7 +6,7 @@ import time
import zlib
import concurrent.futures
import pickle
from typing import Dict
from typing import Dict, Tuple
from BaseClasses import MultiWorld, CollectionState, Region, Item
from worlds.alttp.Items import ItemFactory, item_name_groups
@@ -21,15 +20,13 @@ from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__, _version_tuple
from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
from worlds.hk import gen_hollow
from worlds.hk import create_regions as hk_create_regions
from worlds.factorio import gen_factorio, factorio_create_regions
from worlds.factorio.Mod import generate_mod
from worlds.minecraft import gen_minecraft, fill_minecraft_slot_data, generate_mc_data
from worlds.minecraft.Regions import minecraft_create_regions
from worlds.generic.Rules import locality_rules
from worlds import Games, lookup_any_item_name_to_id
from worlds import Games, lookup_any_item_name_to_id, AutoWorld
import Patch
seeddigits = 20
@@ -67,6 +64,7 @@ def main(args, seed=None):
world.secure()
else:
world.random.seed(world.seed)
world.seed_name = str(args.outputname if args.outputname else world.seed)
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
@@ -78,7 +76,7 @@ def main(args, seed=None):
world.progressive = args.progressive.copy()
world.goal = args.goal.copy()
world.local_items = args.local_items.copy()
if hasattr(args, "algorithm"): # current GUI options
if hasattr(args, "algorithm"): # current GUI options
world.algorithm = args.algorithm
world.shuffleganon = args.shuffleganon
world.custom = args.custom
@@ -93,12 +91,6 @@ def main(args, seed=None):
world.compassshuffle = args.compassshuffle.copy()
world.keyshuffle = args.keyshuffle.copy()
world.bigkeyshuffle = args.bigkeyshuffle.copy()
world.crystals_needed_for_ganon = {
player: world.random.randint(0, 7) if args.crystals_ganon[player] == 'random' else int(
args.crystals_ganon[player]) for player in range(1, world.players + 1)}
world.crystals_needed_for_gt = {
player: world.random.randint(0, 7) if args.crystals_gt[player] == 'random' else int(args.crystals_gt[player])
for player in range(1, world.players + 1)}
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_shuffle = args.enemy_shuffle.copy()
@@ -120,7 +112,6 @@ def main(args, seed=None):
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.copy()
world.shop_shuffle_slots = args.shop_shuffle_slots.copy()
world.progression_balancing = args.progression_balancing.copy()
world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy()
@@ -132,18 +123,13 @@ def main(args, seed=None):
world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
import Options
for hk_option in Options.hollow_knight_options:
setattr(world, hk_option, getattr(args, hk_option, {}))
for factorio_option in Options.factorio_options:
setattr(world, factorio_option, getattr(args, factorio_option, {}))
for minecraft_option in Options.minecraft_options:
setattr(world, minecraft_option, getattr(args, minecraft_option, {}))
world.set_options(args)
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.rom_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in range(1, world.players + 1)}
world.slot_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in
range(1, world.players + 1)}
for player in range(1, world.players+1):
for player in range(1, world.players + 1):
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
if "-" in world.shuffle[player]:
@@ -153,7 +139,8 @@ def main(args, seed=None):
world.er_seeds[player] = "vanilla"
elif seed.startswith("group-") or args.race:
# renamed from team to group to not confuse with existing team name use
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
world.er_seeds[player] = get_same_seed(world, (
shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
else: # not a race or group seed, use set seed as is.
world.er_seeds[player] = seed
elif world.shuffle[player] == "vanilla":
@@ -161,6 +148,10 @@ def main(args, seed=None):
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info("Found World Types:")
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
logger.info(f" {name:30} {cls}")
parsed_names = parse_player_names(args.names, world.players, args.teams)
world.teams = len(parsed_names)
for i, team in enumerate(parsed_names, 1):
@@ -170,14 +161,15 @@ def main(args, seed=None):
world.player_names[player].append(name)
logger.info('')
for player in world.alttp_player_ids:
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
for player in world.player_ids:
for item_name in args.startinventory[player]:
item = Item(item_name, True, lookup_any_item_name_to_id[item_name], player)
item.game = world.game[player]
world.push_precollected(item)
for player in world.alttp_player_ids:
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
for player in world.player_ids:
# enforce pre-defined local items.
@@ -207,23 +199,26 @@ def main(args, seed=None):
for player in world.hk_player_ids:
hk_create_regions(world, player)
for player in world.factorio_player_ids:
factorio_create_regions(world, player)
AutoWorld.call_all(world, "create_regions")
for player in world.minecraft_player_ids:
minecraft_create_regions(world, player)
for player in world.alttp_player_ids:
if world.open_pyramid[player] == 'goal':
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'}
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
'localganontriforcehunt', 'ganonpedestal'}
elif world.open_pyramid[player] == 'auto':
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'} and \
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed'} or not world.shuffle_ganon)
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
'localganontriforcehunt', 'ganonpedestal'} and \
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'} or not world.shuffle_ganon)
else:
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(world.open_pyramid[player], world.open_pyramid[player])
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(
world.open_pyramid[player], 'auto')
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_required[player])
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player],
world.triforce_pieces_required[player])
if world.mode[player] != 'inverted':
create_regions(world, player)
@@ -263,14 +258,15 @@ def main(args, seed=None):
for player in world.player_ids:
locality_rules(world, player)
AutoWorld.call_all(world, "set_rules")
for player in world.alttp_player_ids:
set_rules(world, player)
for player in world.hk_player_ids:
gen_hollow(world, player)
for player in world.factorio_player_ids:
gen_factorio(world, player)
AutoWorld.call_all(world, "generate_basic")
for player in world.minecraft_player_ids:
gen_minecraft(world, player)
@@ -314,7 +310,7 @@ def main(args, seed=None):
balance_multiworld_progression(world)
logger.info('Generating output files.')
outfilebase = 'AP_%s' % (args.outputname if args.outputname else world.seed)
outfilebase = 'AP_' + world.seed_name
rom_names = []
def _gen_rom(team: int, player: int):
@@ -335,13 +331,13 @@ def main(args, seed=None):
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
palettes_options={}
palettes_options['dungeon']=args.uw_palettes[player]
palettes_options['overworld']=args.ow_palettes[player]
palettes_options['hud']=args.hud_palettes[player]
palettes_options['sword']=args.sword_palettes[player]
palettes_options['shield']=args.shield_palettes[player]
palettes_options['link']=args.link_palettes[player]
palettes_options = {}
palettes_options['dungeon'] = args.uw_palettes[player]
palettes_options['overworld'] = args.ow_palettes[player]
palettes_options['hud'] = args.hud_palettes[player]
palettes_options['sword'] = args.sword_palettes[player]
palettes_options['shield'] = args.shield_palettes[player]
palettes_options['link'] = args.link_palettes[player]
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
args.fastmenu[player], args.disablemusic[player], args.sprite[player],
@@ -357,8 +353,8 @@ def main(args, seed=None):
world.bigkeyshuffle[player]].count(True) == 1:
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \
'-compassshuffle' if world.compassshuffle[player] else \
'-universal_keys' if world.keyshuffle[player] == "universal" else \
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
'-universal_keys' if world.keyshuffle[player] == "universal" else \
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-%s%s%s%sshuffle' % (
@@ -366,189 +362,214 @@ def main(args, seed=None):
'U' if world.keyshuffle[player] == "universal" else 'S' if world.keyshuffle[player] else '',
'B' if world.bigkeyshuffle[player] else '')
outfilepname = f'_T{team + 1}' if world.teams > 1 else ''
outfilepname += f'_P{player}'
outfilepname = f'_P{player}'
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
if world.player_names[player][team] != 'Player%d' % player else ''
outfilestuffs = {
"logic": world.logic[player], # 0
"difficulty": world.difficulty[player], # 1
"item_functionality": world.item_functionality[player], # 2
"mode": world.mode[player], # 3
"goal": world.goal[player], # 4
"timer": str(world.timer[player]), # 5
"shuffle": world.shuffle[player], # 6
"algorithm": world.algorithm, # 7
"mscb": mcsb_name, # 8
"retro": world.retro[player], # 9
"progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B
"logic": world.logic[player], # 0
"difficulty": world.difficulty[player], # 1
"item_functionality": world.item_functionality[player], # 2
"mode": world.mode[player], # 3
"goal": world.goal[player], # 4
"timer": str(world.timer[player]), # 5
"shuffle": world.shuffle[player], # 6
"algorithm": world.algorithm, # 7
"mscb": mcsb_name, # 8
"retro": world.retro[player], # 9
"progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B
}
# 0 1 2 3 4 5 6 7 8 9 A B
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (
# 0 1 2 3 4 5 6 7 8 9 A B C
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
outfilestuffs["logic"], # 0
# 0 1 2 3 4 5 6 7 8 9 A B C
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
outfilestuffs["logic"], # 0
outfilestuffs["difficulty"], # 1
outfilestuffs["item_functionality"], # 2
outfilestuffs["mode"], # 3
outfilestuffs["goal"], # 4
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
outfilestuffs["difficulty"], # 1
outfilestuffs["item_functionality"], # 2
outfilestuffs["mode"], # 3
outfilestuffs["goal"], # 4
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
outfilestuffs["shuffle"], # 6
outfilestuffs["algorithm"], # 7
outfilestuffs["mscb"], # 8
outfilestuffs["shuffle"], # 6
outfilestuffs["algorithm"], # 7
outfilestuffs["mscb"], # 8
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
) if not args.outputname else ''
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
) if not args.outputname else ''
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath, hide_enemizer=True)
if args.create_diff:
Patch.create_patch_file(rompath)
Patch.create_patch_file(rompath, player=player, player_name=world.player_names[player][team])
return player, team, bytes(rom.name)
pool = concurrent.futures.ThreadPoolExecutor()
multidata_task = None
check_accessibility_task = pool.submit(world.fulfills_accessibility)
if not args.suppress_rom:
rom_futures = []
mod_futures = []
for team in range(world.teams):
for player in world.alttp_player_ids:
rom_futures.append(pool.submit(_gen_rom, team, player))
for player in world.factorio_player_ids:
mod_futures.append(pool.submit(generate_mod, world, player,
str(args.outputname if args.outputname else world.seed)))
rom_futures = []
output_file_futures = []
for team in range(world.teams):
for player in world.alttp_player_ids:
rom_futures.append(pool.submit(_gen_rom, team, player))
for player in world.player_ids:
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
# collect ER hint info
er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]}
from worlds.alttp.Regions import RegionType
for region in world.regions:
if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
# collect ER hint info
er_hint_data = {player: {} for player in range(1, world.players + 1) if
world.shuffle[player] != "vanilla" or world.retro[player]}
from worlds.alttp.Regions import RegionType
for region in world.regions:
if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != Games.LTTP:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'}\
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != Games.LTTP:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
world.retro[player]]:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
precollected_items = {player: [] for player in range(1, world.players+1)}
FillDisabledShopSlots(world)
def write_multidata(roms, outputs):
import base64
import NetUtils
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
games = {}
for slot in world.player_ids:
if world.game[slot] == "Factorio":
client_versions[slot] = (0, 1, 2)
else:
client_versions[slot] = (0, 0, 3)
games[slot] = world.game[slot]
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
slot, team, rom_name in rom_names}
precollected_items = {player: [] for player in range(1, world.players + 1)}
for item in world.precollected_items:
precollected_items[item.player].append(item.code)
precollected_hints = {player: set() for player in range(1, world.players + 1)}
# for now special case Factorio tech_tree_information
sending_visible_players = set()
for player in world.factorio_player_ids:
if world.tech_tree_information[player].value == 2:
sending_visible_players.add(player)
FillDisabledShopSlots(world)
def write_multidata(roms, mods):
import base64
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 0, 4), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = (0, 0, 3)
games[slot] = world.game[slot]
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
slot, team, rom_name in rom_names}
for i, team in enumerate(parsed_names):
for player, name in enumerate(team, 1):
if player not in world.alttp_player_ids:
connect_names[name] = (i, player)
for i, team in enumerate(parsed_names):
for player, name in enumerate(team, 1):
if player not in world.alttp_player_ids:
connect_names[name] = (i, player)
if world.hk_player_ids:
import Options
for slot in world.hk_player_ids:
slots_data = slot_data[slot] = {}
for option_name in Options.hollow_knight_options:
option = getattr(world, option_name)[slot]
slots_data[option_name] = int(option.value)
for slot in world.minecraft_player_ids:
slot_data[slot] = fill_minecraft_slot_data(world, slot)
multidata = zlib.compress(pickle.dumps({
"slot_data" : slot_data,
"games": games,
"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player]},
"locations": {
(location.address, location.player):
(location.item.code, location.item.player)
for location in world.get_filled_locations() if
type(location.address) is int},
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": str(args.outputname if args.outputname else world.seed)
}), 9)
for slot in world.minecraft_player_ids:
slot_data[slot] = fill_minecraft_slot_data(world, slot)
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for future in mods:
future.result() # collect errors if they occured
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
locations_data[location.player][location.address] = (location.item.code, location.item.player)
if location.player in sending_visible_players and location.item.player != location.player:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False)
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
elif location.item.name in args.start_hints[location.item.player]:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False,
er_hint_data.get(location.player, {}).get(location.address, ""))
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
multidata_task = pool.submit(write_multidata, rom_futures, mod_futures)
multidata = zlib.compress(pickle.dumps({
"slot_data": slot_data,
"games": games,
"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player]},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name
}), 9)
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for future in outputs:
future.result() # collect errors if they occured
multidata_task = pool.submit(write_multidata, rom_futures, output_file_futures)
if not check_accessibility_task.result():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
@@ -557,8 +578,8 @@ def main(args, seed=None):
if multidata_task:
multidata_task.result() # retrieve exception if one exists
pool.shutdown() # wait for all queued tasks to complete
for player in world.minecraft_player_ids: # Doing this after shutdown prevents the .apmc from being generated if there's an error
generate_mc_data(world, player, str(args.outputname if args.outputname else world.seed))
for player in world.minecraft_player_ids: # Doing this after shutdown prevents the .apmc from being generated if there's an error
generate_mc_data(world, player)
if not args.skip_playthrough:
logger.info('Calculating playthrough.')
create_playthrough(world)
@@ -612,7 +633,8 @@ def create_playthrough(world):
to_delete = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, location.item.player)
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
old_item = location.item
location.item = None
if world.can_beat_game(state_cache[num]):
@@ -657,7 +679,8 @@ def create_playthrough(world):
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere), len(required_locations))
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -676,14 +699,22 @@ def create_playthrough(world):
world.spoiler.paths = dict()
for player in range(1, world.players + 1):
world.spoiler.paths.update({ str(location) : get_path(state, location.parent_region) for sphere in collection_spheres for location in sphere if location.player == player})
world.spoiler.paths.update(
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
sphere if location.player == player})
if player in world.alttp_player_ids:
for path in dict(world.spoiler.paths).values():
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
if world.mode[player] != 'inverted':
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state, world.get_region('Big Bomb Shop', player))
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state,
world.get_region(
'Big Bomb Shop',
player))
else:
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state, world.get_region('Inverted Big Bomb Shop', player))
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state,
world.get_region(
'Inverted Big Bomb Shop',
player))
# we can finally output our playthrough
world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])}

View File

@@ -1,55 +1,48 @@
import os
import sys
import subprocess
import importlib
import pkg_resources
requirements_files = {'requirements.txt'}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
update_ran = hasattr(sys, "frozen") and getattr(sys, "frozen") # don't run update if environment is frozen/compiled
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
if not update_ran:
for entry in os.scandir("worlds"):
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command():
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt', '--upgrade'])
naming_specialties = {"PyYAML": "yaml", # PyYAML is imported as the name yaml
"maseya-z3pr": "maseya",
"factorio-rcon-py": "factorio_rcon"}
for file in requirements_files:
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
def update():
global update_ran
if not update_ran:
update_ran = True
path = os.path.join(os.path.dirname(sys.argv[0]), 'requirements.txt')
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), 'requirements.txt')
with open(path) as requirementsfile:
for line in requirementsfile.readlines():
module, remote_version = line.split(">=")
module = naming_specialties.get(module, module)
try:
module = importlib.import_module(module)
except:
import traceback
traceback.print_exc()
input(f'Required python module {module} not found, press enter to install it')
update_command()
return
else:
if hasattr(module, "__version__"):
module_version = module.__version__
module = module.__name__ # also unloads the module to make it writable
if type(module_version) == str:
module_version = tuple(int(part.strip()) for part in module_version.split("."))
remote_version = tuple(int(part.strip()) for part in remote_version.split("."))
if module_version < remote_version:
input(f'Required python module {module} is outdated ({module_version}<{remote_version}),'
' press enter to upgrade it')
update_command()
return
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
requirements = pkg_resources.parse_requirements(requirementsfile)
for requirement in requirements:
requirement = str(requirement)
try:
pkg_resources.require(requirement)
except pkg_resources.ResolutionError:
import traceback
traceback.print_exc()
input(f'Requirement {requirement} is not satisfied, press enter to install it')
update_command()
return
if __name__ == "__main__":

View File

@@ -26,7 +26,6 @@ if __name__ == "__main__":
from Utils import get_public_ipv4, get_options
from Mystery import get_seed_name
from Patch import create_patch_file
options = get_options()
@@ -41,11 +40,11 @@ if __name__ == "__main__":
create_spoiler = multi_mystery_options["create_spoiler"]
zip_roms = multi_mystery_options["zip_roms"]
zip_diffs = multi_mystery_options["zip_diffs"]
zip_apmcs = multi_mystery_options["zip_apmcs"]
zip_spoiler = multi_mystery_options["zip_spoiler"]
zip_multidata = multi_mystery_options["zip_multidata"]
zip_format = multi_mystery_options["zip_format"]
# zip_password = multi_mystery_options["zip_password"] not at this time
player_name = multi_mystery_options["player_name"]
meta_file_path = multi_mystery_options["meta_file_path"]
weights_file_path = multi_mystery_options["weights_file_path"]
pre_roll = multi_mystery_options["pre_roll"]
@@ -76,7 +75,7 @@ if __name__ == "__main__":
if os.path.exists("ArchipelagoMystery.exe"):
basemysterycommand = "ArchipelagoMystery.exe" # compiled windows
elif os.path.exists("ArchipelagoMystery"):
basemysterycommand = "ArchipelagoMystery" # compiled linux
basemysterycommand = "./ArchipelagoMystery" # compiled linux
else:
basemysterycommand = f"py -{py_version} Mystery.py" # source
@@ -124,16 +123,7 @@ if __name__ == "__main__":
spoilername = f"AP_{seed_name}_Spoiler.txt"
romfilename = ""
if player_name:
for file in os.listdir(output_path):
if player_name in file:
import MultiClient
import asyncio
asyncio.run(MultiClient.run_game(os.path.join(output_path, file)))
break
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs)):
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs, zip_apmcs)):
import zipfile
compression = {1: zipfile.ZIP_DEFLATED,
@@ -167,7 +157,7 @@ if __name__ == "__main__":
def _handle_sfc_file(file: str):
if zip_roms:
pack_file(file)
if zip_roms == 2 and player_name.lower() not in file.lower():
if zip_roms == 2:
remove_zipped_file(file)
@@ -178,15 +168,28 @@ if __name__ == "__main__":
remove_zipped_file(file)
def _handle_apmc_file(file: str):
if zip_apmcs:
pack_file(file)
if zip_apmcs == 2:
remove_zipped_file(file)
with concurrent.futures.ThreadPoolExecutor() as pool:
futures = []
files = os.listdir(output_path)
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
for file in os.listdir(output_path):
for file in files:
if seed_name in file:
if file.endswith(".sfc"):
futures.append(pool.submit(_handle_sfc_file, file))
elif file.endswith(".apbp"):
futures.append(pool.submit(_handle_diff_file, file))
elif file.endswith(".apmc"):
futures.append(pool.submit(_handle_apmc_file, file))
# just handle like a diff file for now
elif file.endswith(".zip"):
futures.append(pool.submit(_handle_diff_file, file))
if zip_multidata and os.path.exists(os.path.join(output_path, multidataname)):
pack_file(multidataname)
@@ -204,14 +207,13 @@ if __name__ == "__main__":
if not args.disable_autohost:
if os.path.exists(os.path.join(output_path, multidataname)):
if os.path.exists("ArchipelagoServer.exe"):
baseservercommand = "ArchipelagoServer.exe" # compiled windows
baseservercommand = ["ArchipelagoServer.exe"] # compiled windows
elif os.path.exists("ArchipelagoServer"):
baseservercommand = "ArchipelagoServer" # compiled linux
baseservercommand = ["./ArchipelagoServer"] # compiled linux
else:
baseservercommand = f"py -{py_version} MultiServer.py" # source
baseservercommand = ["py", f"-{py_version}", "MultiServer.py"] # source
# don't have a mac to test that. If you try to run compiled on mac, good luck.
subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}")
subprocess.call(baseservercommand + ["--multidata", os.path.join(output_path, multidataname)])
except:
import traceback

View File

@@ -30,7 +30,7 @@ from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_
lookup_any_location_id_to_name, lookup_any_location_name_to_id
import Utils
from Utils import get_item_name_from_id, get_location_name_from_id, \
_version_tuple, restricted_loads, Version
version_tuple, restricted_loads, Version
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
colorama.init()
@@ -39,11 +39,12 @@ all_items = frozenset(lookup_any_item_name_to_id)
all_locations = frozenset(lookup_any_location_name_to_id)
all_console_names = frozenset(all_items | all_locations)
class Client(Endpoint):
version = Version(0, 0, 0)
tags: typing.List[str] = []
def __init__(self, socket: websockets.server.WebSocketServerProtocol, ctx: Context):
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
super().__init__(socket)
self.auth = False
self.name = None
@@ -75,10 +76,10 @@ class Context(Node):
self.save_filename = None
self.saving = False
self.player_names = {}
self.connect_names = {} # names of slots clients can connect to
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {}
self.remote_items = set()
self.locations = {}
self.locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
self.host = host
self.port = port
self.server_password = server_password
@@ -114,6 +115,11 @@ class Context(Node):
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
data = f.read()
@@ -131,9 +137,9 @@ class Context(Node):
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils._version_tuple:
raise RuntimeError(f"Supplied Multidata requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils._version_tuple}")
if mdata_ver > Utils.version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils.version_tuple}")
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for player, version in clients_ver.items():
@@ -155,12 +161,12 @@ class Context(Node):
for slot, item_codes in decoded_obj["precollected_items"].items():
if slot in self.remote_items:
self.received_items[team, slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes]
for slot, hints in decoded_obj["precollected_hints"].items():
self.hints[team, slot].update(hints)
if use_embedded_server_options:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@@ -168,7 +174,7 @@ class Context(Node):
for key, value in server_options.items():
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
if value not in {False, True, None}: # some can be boolean OR text, such as password
try:
value = data_type(value)
except Exception as e:
@@ -194,7 +200,7 @@ class Context(Node):
return False
def _save(self, exit_save:bool=False) -> bool:
def _save(self, exit_save: bool = False) -> bool:
try:
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
@@ -240,42 +246,40 @@ class Context(Node):
def get_save(self) -> dict:
d = {
"rom_names": list(self.connect_names.items()),
"received_items": tuple((k, v) for k, v in self.received_items.items()),
"hints_used": tuple((key, value) for key, value in self.hints_used.items()),
"hints": tuple(
(key, list(hint.re_check(self, key[0]) for hint in value)) for key, value in self.hints.items()),
"location_checks": tuple((key, tuple(value)) for key, value in self.location_checks.items()),
"name_aliases": tuple((key, value) for key, value in self.name_aliases.items()),
"client_game_state": tuple((key, value) for key, value in self.client_game_state.items()),
"connect_names": self.connect_names,
"received_items": self.received_items,
"hints_used": dict(self.hints_used),
"hints": dict(self.hints),
"location_checks": dict(self.location_checks),
"name_aliases": self.name_aliases,
"client_game_state": dict(self.client_game_state),
"client_activity_timers": tuple(
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
}
return d
def set_save(self, savedata: dict):
if self.connect_names != savedata["connect_names"]:
raise Exception("This savegame does not appear to match the loaded multiworld.")
self.received_items = savedata["received_items"]
self.hints_used.update(savedata["hints_used"])
self.hints.update(savedata["hints"])
received_items = {tuple(k): [NetworkItem(*i) for i in v] for k, v in savedata["received_items"]}
self.received_items = received_items
self.hints_used.update({tuple(key): value for key, value in savedata["hints_used"]})
self.hints.update(
{tuple(key): set(NetUtils.Hint(*hint) for hint in value) for key, value in savedata["hints"]})
self.name_aliases.update({tuple(key): value for key, value in savedata["name_aliases"]})
self.client_game_state.update({tuple(key): value for key, value in savedata["client_game_state"]})
self.name_aliases.update(savedata["name_aliases"])
self.client_game_state.update(savedata["client_game_state"])
self.client_connection_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_connection_timers"]})
self.client_activity_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_activity_timers"]})
self.location_checks.update({tuple(key): set(value) for key, value in savedata["location_checks"]})
self.location_checks.update(savedata["location_checks"])
logging.info(f'Loaded save file with {sum([len(p) for p in received_items.values()])} received items '
f'for {len(received_items)} players')
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
f'for {len(self.received_items)} players')
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
@@ -316,16 +320,20 @@ class Context(Node):
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
cmd = ctx.dumper([{"cmd": "Hint", "hints" : hints}])
commands = ctx.dumper([hint.as_network_message() for hint in hints])
concerns = collections.defaultdict(list)
for hint in hints:
net_msg = hint.as_network_message()
concerns[hint.receiving_player].append(net_msg)
if not hint.local:
concerns[hint.finding_player].append(net_msg)
for text in (format_hint(ctx, team, hint) for hint in hints):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
for client in ctx.endpoints:
if client.auth and client.team == team:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
asyncio.create_task(ctx.send_encoded_msgs(client, commands))
client_hints = concerns[client.slot]
if client_hints:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int, client: typing.Optional[Client] = None):
@@ -358,7 +366,8 @@ async def server(websocket, path, ctx: Context):
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
logging.info("Disconnected")
if ctx.log_network:
logging.info("Disconnected")
await ctx.disconnect(client)
@@ -366,12 +375,14 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': ctx.password is not None,
'players': [NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name), client.name) for client
in ctx.endpoints if client.auth],
'players': [
NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name),
client.name) for client
in ctx.endpoints if client.auth],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
'version': Utils._version_tuple,
'version': Utils.version_tuple,
'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode,
'hint_cost': ctx.hint_cost,
@@ -395,9 +406,11 @@ async def on_client_joined(ctx: Context, client: Client):
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def on_client_left(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.notify_all(
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -437,34 +450,30 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw
return ctx.received_items.setdefault((team, player), [])
def tuplize_received_items(items):
return [NetworkItem(item.item, item.location, item.player) for item in items]
def send_new_items(ctx: Context):
for client in ctx.endpoints:
if client.auth: # can't send to disconnected client
if client.auth: # can't send to disconnected client
items = get_received_items(ctx, client.team, client.slot)
if len(items) > client.send_index:
asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems",
"index": client.send_index,
"items": tuplize_received_items(items)[client.send_index:]}]))
"items": items[client.send_index:]}]))
client.send_index = len(items)
def forfeit_player(ctx: Context, team: int, slot: int):
# register any locations that are in the multidata
all_locations = {location_id for location_id, location_slot in ctx.locations if location_slot == slot}
all_locations = set(ctx.locations[slot])
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
register_location_checks(ctx, team, slot, all_locations)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
items = []
for (location, location_slot) in ctx.locations:
if location_slot == slot and location not in ctx.location_checks[team, slot]:
items.append(ctx.locations[location, slot][0]) # item ID
for location_id in ctx.locations[slot]:
if location_id not in ctx.location_checks[team, slot]:
items.append(ctx.locations[slot][location_id][0]) # item ID
return sorted(items)
@@ -473,8 +482,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if (location, slot) in ctx.locations:
item_id, target_player = ctx.locations[(location, slot)]
if location in ctx.locations[slot]:
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
@@ -500,33 +509,28 @@ def notify_team(ctx: Context, team: int, text: str):
ctx.broadcast_team(team, [['Print', {"text": text}]])
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_item_id = lookup_any_item_name_to_id[item]
for check, result in ctx.locations.items():
item_id, receiving_player = result
if receiving_player == slot and item_id == seeked_item_id:
location_id, finding_player = check
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
item_id, receiving_player = result
if receiving_player == slot and item_id == seeked_item_id:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
return hints
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_location = Regions.lookup_name_to_id[location]
for check, result in ctx.locations.items():
location_id, finding_player = check
if finding_player == slot and location_id == seeked_location:
item_id, receiving_player = result
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
break # each location has 1 item
return hints
seeked_location: int = Regions.lookup_name_to_id[location]
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
if item_id:
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance)]
return []
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
@@ -539,6 +543,7 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".")
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
parts = []
NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id)
@@ -558,7 +563,9 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
return {"cmd": "PrintJSON", "data": parts, "type": "ItemSend",
"receiving": receiving_player, "sending": net_item.player}
def get_intended_text(input_text: str, possible_answers: typing.Iterable[str]= all_console_names) -> typing.Tuple[str, bool, str]:
def get_intended_text(input_text: str, possible_answers: typing.Iterable[str] = all_console_names) -> typing.Tuple[
str, bool, str]:
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
@@ -683,11 +690,12 @@ class CommonCommandProcessor(CommandProcessor):
"""List all current options. Warning: lists password."""
self.output("Current options:")
for option in self.ctx.simple_options:
if option == "server_password" and self.marker == "!": #Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4,16))}")
if option == "server_password" and self.marker == "!": # Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4, 16))}")
else:
self.output(f"Option {option} is set to {getattr(self.ctx, option)}")
class ClientMessageProcessor(CommonCommandProcessor):
marker = "!"
@@ -714,11 +722,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Allow remote administration of the multiworld server"""
output = f"!admin {command}"
if output.lower().startswith("!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
if output.lower().startswith(
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
output = f"!admin login {('*' * random.randint(4, 16))}"
elif output.lower().startswith("!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
elif output.lower().startswith(
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled")
@@ -726,7 +737,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not command:
if self.is_authenticated():
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
self.output(
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
else:
self.output("Usage: !admin login [password]")
return True
@@ -786,7 +798,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -799,7 +811,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -809,7 +821,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
@@ -849,7 +860,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
if usable:
new_item = NetworkItem(Items.item_table[item_name][2], -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, self.client.slot))
self.ctx.notify_all(
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
self.client.slot))
send_new_items(self.ctx)
return True
else:
@@ -864,7 +877,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Use !hint {item_name/location_name}, for example !hint Lamp or !hint Link's House. """
points_available = get_client_points(self.ctx, self.client)
if not item_or_location:
self.output(f"A hint costs {self.ctx.hint_cost} points. "
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -885,7 +898,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
else: # location name
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
cost = self.ctx.get_hint_cost(self.client.slot)
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
@@ -899,8 +912,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif self.ctx.hint_cost:
can_pay = points_available // self.ctx.hint_cost
elif cost:
can_pay = points_available // cost
else:
can_pay = 1000
@@ -926,7 +939,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.hint_cost}")
f"{self.ctx.get_hint_cost(self.client.slot)}")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
@@ -941,26 +954,24 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_checked_checks(ctx: Context, client: Client) -> typing.List[int]:
return [location_id for
location_id, slot in ctx.locations if
slot == client.slot and
location_id in ctx.locations[client.slot] if
location_id in ctx.location_checks[client.team, client.slot]]
def get_missing_checks(ctx: Context, client: Client) -> typing.List[int]:
return [location_id for
location_id, slot in ctx.locations if
slot == client.slot and
location_id in ctx.locations[client.slot] if
location_id not in ctx.location_checks[client.team, client.slot]]
def get_client_points(ctx: Context, client: Client) -> int:
return (ctx.location_check_points * len(ctx.location_checks[client.team, client.slot]) -
ctx.hint_cost * ctx.hints_used[client.team, client.slot])
ctx.get_hint_cost(client.slot) * ctx.hints_used[client.team, client.slot])
async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd:str = args["cmd"]
cmd: str = args["cmd"]
except:
logging.exception(f"Could not get command from {args}")
raise
@@ -1012,7 +1023,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('IncompatibleVersion')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != _version_tuple:
if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion')
if errors:
logging.info(f"A client connection was refused due to: {errors}")
@@ -1032,7 +1043,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
}]
items = get_received_items(ctx, client.team, client.slot)
if items:
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": tuplize_received_items(items)})
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": items})
client.send_index = len(items)
await ctx.send_msgs(client, reply)
@@ -1046,8 +1057,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
items = get_received_items(ctx, client.team, client.slot)
if items:
client.send_index = len(items)
await ctx.send_msgs(client, [{"cmd": "ReceivedItems","index": 0,
"items": tuplize_received_items(items)}])
await ctx.send_msgs(client, [{"cmd": "ReceivedItems", "index": 0,
"items": items}])
elif cmd == 'LocationChecks':
register_location_checks(ctx, client.team, client.slot, args["locations"])
@@ -1058,7 +1069,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if type(location) is not int or location not in lookup_any_location_id_to_name:
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'LocationScouts'}])
return
target_item, target_player = ctx.locations[location, client.slot]
target_item, target_player = ctx.locations[client.slot][location]
locs.append(NetworkItem(target_item, location, target_player))
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
@@ -1068,11 +1079,12 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text" : 'Say'}])
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'Say'}])
return
client.messageprocessor(args["text"])
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
current = ctx.client_game_state[client.team, client.slot]
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
@@ -1084,6 +1096,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.client_game_state[client.team, client.slot] = new_status
class ServerCommandProcessor(CommonCommandProcessor):
def __init__(self, ctx: Context):
self.ctx = ctx
@@ -1191,7 +1204,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
self.output(
f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
return True
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
@@ -1206,7 +1220,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
for client in self.ctx.endpoints:
if client.name == seeked_player:
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, client.slot)
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, 0)
get_received_items(self.ctx, client.team, client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(client.team, client.slot))
@@ -1271,6 +1285,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
f"{', '.join(known)}")
return False
async def console(ctx: Context):
session = prompt_toolkit.PromptSession()
while ctx.running:
@@ -1357,7 +1372,7 @@ async def auto_shutdown(ctx, to_cancel=None):
async def main(args: argparse.Namespace):
logging.basicConfig(force = True,
logging.basicConfig(force=True,
format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,

View File

@@ -13,7 +13,7 @@ from worlds.generic import PlandoItem, PlandoConnection
ModuleUpdate.update()
from Utils import parse_yaml
from Utils import parse_yaml, version_tuple, __version__, tuplize_version
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from Main import get_seed, seeddigits
@@ -23,7 +23,9 @@ from worlds.alttp.Items import item_name_groups, item_table
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable
from worlds.alttp.Regions import location_table, key_drop_data
from worlds.AutoWorld import AutoWorldRegister
categories = set(AutoWorldRegister.world_types)
def mystery_argparse():
parser = argparse.ArgumentParser(add_help=False)
@@ -61,9 +63,11 @@ def mystery_argparse():
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args
def get_seed_name(random):
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain):
if not args:
args = mystery_argparse()
@@ -79,14 +83,14 @@ def main(args=None, callback=ERmain):
weights_cache = {}
if args.weights:
try:
weights_cache[args.weights] = get_weights(args.weights)
weights_cache[args.weights] = read_weights_yaml(args.weights)
except Exception as e:
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights} >> "
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}")
if args.meta:
try:
weights_cache[args.meta] = get_weights(args.meta)
weights_cache[args.meta] = read_weights_yaml(args.meta)
except Exception as e:
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta]
@@ -99,7 +103,7 @@ def main(args=None, callback=ERmain):
if path:
try:
if path not in weights_cache:
weights_cache[path] = get_weights(path)
weights_cache[path] = read_weights_yaml(path)
print(f"P{player} Weights: {path} >> "
f"{get_choice('description', weights_cache[path], 'No description specified')}")
@@ -254,7 +258,7 @@ def main(args=None, callback=ERmain):
callback(erargs, seed)
def get_weights(path):
def read_weights_yaml(path):
try:
if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
@@ -303,7 +307,10 @@ def handle_name(name: str, player: int, name_counter: Counter):
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
return new_name.strip().replace(' ', '_')[:16]
new_name = new_name.strip().replace(' ', '_')[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
@@ -339,19 +346,6 @@ goals = {
'ice_rod_hunt': 'icerodhunt',
}
# remove sometime before 1.0.0, warn before
legacy_boss_shuffle_options = {
# legacy, will go away:
'simple': 'basic',
'random': 'full',
'normal': 'full'
}
legacy_goals = {
'dungeons': 'bosses',
'fast_ganon': 'crystals',
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
"""Roll a percentage chance.
@@ -379,13 +373,12 @@ def roll_linked_options(weights: dict) -> dict:
try:
if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
if "options" in option_set:
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom",
option_set["name"])
weights["rom"] = rom_weights
new_options = option_set["options"]
for category_name, category_options in new_options.items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
else:
logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e:
@@ -399,35 +392,32 @@ def roll_triggers(weights: dict) -> dict:
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
for i, option_set in enumerate(weights["triggers"]):
try:
currently_targeted_weights = weights
category = option_set.get("option_category", None)
if category:
currently_targeted_weights = currently_targeted_weights[category]
key = get_choice("option_name", option_set)
if key not in weights:
if key not in currently_targeted_weights:
logging.warning(f'Specified option name {option_set["option_name"]} did not '
f'match with a root option. '
f'This is probably in error.')
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, weights)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if "options" in option_set:
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"])
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom",
option_set["option_name"])
weights["rom"] = rom_weights
weights[key] = result
except Exception as e:
raise ValueError(f"Your trigger number {i+1} is destroyed. "
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
f"Please fix your triggers.") from e
return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
if boss_shuffle in legacy_boss_shuffle_options:
new_boss_shuffle = legacy_boss_shuffle_options[boss_shuffle]
logging.warning(f"Boss shuffle {boss_shuffle} is deprecated, "
f"please use {new_boss_shuffle} instead")
return new_boss_shuffle
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
@@ -435,10 +425,6 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in legacy_boss_shuffle_options:
remainder_shuffle = legacy_boss_shuffle_options[boss_shuffle]
logging.warning(f"Boss shuffle {boss} is deprecated, "
f"please use {remainder_shuffle} instead")
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
@@ -504,14 +490,34 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
if "triggers" in weights:
weights = roll_triggers(weights)
requirements = weights.get("requires", {})
if requirements:
version = requirements.get("version", __version__)
if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}")
required_plando_options = requirements.get("plando", "")
if required_plando_options:
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
required_plando_options -= plando_options
if required_plando_options:
if len(required_plando_options) == 1:
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
f"which is not enabled.")
else:
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
f"which are not enabled.")
ret = argparse.Namespace()
ret.name = get_choice('name', weights)
ret.accessibility = get_choice('accessibility', weights)
ret.progression_balancing = get_choice('progression_balancing', weights, True)
ret.game = get_choice("game", weights, "A Link to the Past")
ret.game = get_choice("game", weights)
if ret.game not in weights:
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
game_weights = weights[ret.game]
ret.local_items = set()
for item_name in weights.get('local_items', []):
for item_name in game_weights.get('local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item in items:
if item in lookup_any_item_name_to_id:
@@ -520,7 +526,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
ret.non_local_items = set()
for item_name in weights.get('non_local_items', []):
for item_name in game_weights.get('non_local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item in items:
if item in lookup_any_item_name_to_id:
@@ -528,7 +534,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
else:
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
inventoryweights = weights.get('startinventory', {})
inventoryweights = game_weights.get('start_inventory', {})
startitems = []
for item in inventoryweights.keys():
itemvalue = get_choice(item, inventoryweights)
@@ -538,39 +544,57 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
elif itemvalue:
startitems.append(item)
ret.startinventory = startitems
ret.start_hints = set(game_weights.get('start_hints', []))
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, weights, plando_options)
roll_alttp_settings(ret, game_weights, plando_options)
elif ret.game == "Hollow Knight":
for option_name, option in Options.hollow_knight_options.items():
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True)))
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights, True)))
elif ret.game == "Factorio":
for option_name, option in Options.factorio_options.items():
if option_name in weights:
if option_name in game_weights:
if issubclass(option, Options.OptionDict): # get_choice should probably land in the Option class
setattr(ret, option_name, option.from_any(weights[option_name]))
setattr(ret, option_name, option.from_any(game_weights[option_name]))
else:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
else:
setattr(ret, option_name, option(option.default))
elif ret.game == "Minecraft":
for option_name, option in Options.minecraft_options.items():
if option_name in weights:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
if option_name in game_weights:
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
else:
setattr(ret, option_name, option(option.default))
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if "connections" in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
for option_name, option in Options.alttp_options.items():
if option_name in weights:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
else:
setattr(ret, option_name, option(option.default))
glitches_required = get_choice('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG and No Logic supported")
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches'}[
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required]
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
@@ -607,23 +631,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
goal = get_choice('goals', weights, 'ganon')
if goal in legacy_goals:
logging.warning(f"Goal {goal} is depcrecated, please use {legacy_goals[goal]} instead.")
goal = legacy_goals[goal]
ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
ret.open_pyramid = get_choice('open_pyramid', weights, 'goal')
ret.crystals_gt = prefer_int(get_choice('tower_open', weights))
ret.crystals_ganon = prefer_int(get_choice('ganon_open', weights))
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = int(get_choice('triforce_pieces_required', weights, 20))
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 90)
ret.triforce_pieces_required = Options.TriforcePieces.from_any(get_choice('triforce_pieces_required', weights, 20))
# sum a percentage to required
if extra_pieces == 'percentage':
@@ -631,7 +647,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are)
elif extra_pieces == 'available':
ret.triforce_pieces_available = int(get_choice('triforce_pieces_available', weights, 30))
ret.triforce_pieces_available = Options.TriforcePieces.from_any(
get_choice('triforce_pieces_available', weights, 30))
# required pieces + fixed extra
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10)))
@@ -639,11 +656,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
shuffle_slots = get_choice('shop_shuffle_slots', weights, '0')
if str(shuffle_slots).lower() == "random":
ret.shop_shuffle_slots = random.randint(0, 30)
else:
ret.shop_shuffle_slots = int(shuffle_slots)
ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
if not ret.shop_shuffle:
@@ -665,7 +677,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False))
ret.killable_thieves = get_choice('killable_thieves', weights, False)
ret.tile_shuffle = get_choice('tile_shuffle', weights, False)
ret.bush_shuffle = get_choice('bush_shuffle', weights, False)
@@ -777,49 +788,43 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
get_choice("direction", placement, "both")
))
if 'rom' in weights:
romweights = weights['rom']
ret.sprite_pool = romweights['sprite_pool'] if 'sprite_pool' in romweights else []
ret.sprite = get_choice('sprite', romweights, "Link")
if 'random_sprite_on_event' in romweights:
randomoneventweights = romweights['random_sprite_on_event']
if get_choice('enabled', randomoneventweights, False):
ret.sprite = 'randomon'
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else ''
ret.sprite += '-enter' if get_choice('on_enter', randomoneventweights, False) else ''
ret.sprite += '-exit' if get_choice('on_exit', randomoneventweights, False) else ''
ret.sprite += '-slash' if get_choice('on_slash', randomoneventweights, False) else ''
ret.sprite += '-item' if get_choice('on_item', randomoneventweights, False) else ''
ret.sprite += '-bonk' if get_choice('on_bonk', randomoneventweights, False) else ''
ret.sprite = 'randomonall' if get_choice('on_everything', randomoneventweights, False) else ret.sprite
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
randomoneventweights = weights['random_sprite_on_event']
if get_choice('enabled', randomoneventweights, False):
ret.sprite = 'randomon'
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else ''
ret.sprite += '-enter' if get_choice('on_enter', randomoneventweights, False) else ''
ret.sprite += '-exit' if get_choice('on_exit', randomoneventweights, False) else ''
ret.sprite += '-slash' if get_choice('on_slash', randomoneventweights, False) else ''
ret.sprite += '-item' if get_choice('on_item', randomoneventweights, False) else ''
ret.sprite += '-bonk' if get_choice('on_bonk', randomoneventweights, False) else ''
ret.sprite = 'randomonall' if get_choice('on_everything', randomoneventweights, False) else ret.sprite
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
and 'sprite' in romweights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
for key, value in romweights['sprite'].items():
if key.startswith('random'):
ret.sprite_pool += ['random'] * int(value)
else:
ret.sprite_pool += [key] * int(value)
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
for key, value in weights['sprite'].items():
if key.startswith('random'):
ret.sprite_pool += ['random'] * int(value)
else:
ret.sprite_pool += [key] * int(value)
ret.disablemusic = get_choice('disablemusic', romweights, False)
ret.triforcehud = get_choice('triforcehud', romweights, 'hide_goal')
ret.quickswap = get_choice('quickswap', romweights, True)
ret.fastmenu = get_choice('menuspeed', romweights, "normal")
ret.reduceflashing = get_choice('reduceflashing', romweights, False)
ret.heartcolor = get_choice('heartcolor', romweights, "red")
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', romweights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', romweights, "default")
ret.uw_palettes = get_choice('uw_palettes', romweights, "default")
ret.hud_palettes = get_choice('hud_palettes', romweights, "default")
ret.sword_palettes = get_choice('sword_palettes', romweights, "default")
ret.shield_palettes = get_choice('shield_palettes', romweights, "default")
ret.link_palettes = get_choice('link_palettes', romweights, "default")
else:
ret.quickswap = True
ret.sprite = "Link"
ret.disablemusic = get_choice('disablemusic', weights, False)
ret.triforcehud = get_choice('triforcehud', weights, 'hide_goal')
ret.quickswap = get_choice('quickswap', weights, True)
ret.fastmenu = get_choice('menuspeed', weights, "normal")
ret.reduceflashing = get_choice('reduceflashing', weights, False)
ret.heartcolor = get_choice('heartcolor', weights, "red")
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', weights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', weights, "default")
ret.uw_palettes = get_choice('uw_palettes', weights, "default")
ret.hud_palettes = get_choice('hud_palettes', weights, "default")
ret.sword_palettes = get_choice('sword_palettes', weights, "default")
ret.shield_palettes = get_choice('shield_palettes', weights, "default")
ret.link_palettes = get_choice('link_palettes', weights, "default")
if __name__ == '__main__':

View File

@@ -308,3 +308,7 @@ class Hint(typing.NamedTuple):
add_json_text(parts, ".")
return {"cmd": "PrintJSON", "data": parts, "type": "hint"}
@property
def local(self):
return self.receiving_player == self.finding_player

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import typing
import random
class AssembleOptions(type):
@@ -7,8 +8,9 @@ class AssembleOptions(type):
options = attrs["options"] = {}
name_lookup = attrs["name_lookup"] = {}
for base in bases:
options.update(base.options)
name_lookup.update(name_lookup)
if hasattr(base, "options"):
options.update(base.options)
name_lookup.update(name_lookup)
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("option_")}
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
@@ -20,6 +22,37 @@ class AssembleOptions(type):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
class AssembleCategoryPath(type):
def __new__(mcs, name, bases, attrs):
path = []
for base in bases:
if hasattr(base, "segment"):
path += base.segment
path += attrs["segment"]
attrs["path"] = path
return super(AssembleCategoryPath, mcs).__new__(mcs, name, bases, attrs)
class RootCategory(metaclass=AssembleCategoryPath):
segment = []
class LttPCategory(RootCategory):
segment = ["A Link to the Past"]
class LttPRomCategory(LttPCategory):
segment = ["rom"]
class FactorioCategory(RootCategory):
segment = ["Factorio"]
class MinecraftCategory(RootCategory):
segment = ["Minecraft"]
class Option(metaclass=AssembleOptions):
value: int
name_lookup: typing.Dict[int, str]
@@ -88,6 +121,8 @@ class Toggle(Option):
def get_option_name(self):
return bool(self.value)
class DefaultOnToggle(Toggle):
default = 1
class Choice(Option):
def __init__(self, value: int):
@@ -109,6 +144,41 @@ class Choice(Option):
return cls.from_text(str(data))
class Range(Option, int):
range_start = 0
range_end = 1
def __init__(self, value: int):
if value < self.range_start:
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
elif value > self.range_end:
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}")
self.value = value
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
if text.startswith("random"):
if text == "random-low":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
elif text == "random-high":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
elif text == "random-middle":
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
else:
return cls(random.randint(cls.range_start, cls.range_end))
return cls(int(text))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
if type(data) == int:
return cls(data)
return cls.from_text(str(data))
def __str__(self):
return str(self.value)
class OptionNameSet(Option):
default = frozenset()
@@ -139,13 +209,18 @@ class OptionDict(Option):
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
def get_option_name(self):
return str(self.value)
class Logic(Choice):
option_no_glitches = 0
option_minor_glitches = 1
option_overworld_glitches = 2
option_hybrid_major_glitches = 3
option_no_logic = 4
alias_owg = 2
alias_hmg = 3
class Objective(Choice):
@@ -171,17 +246,28 @@ class Accessibility(Choice):
option_beatable = 2
class Crystals(Choice):
# can't use IntEnum since there's also random
option_0 = 0
option_1 = 1
option_2 = 2
option_3 = 3
option_4 = 4
option_5 = 5
option_6 = 6
option_7 = 7
option_random = -1
class Crystals(Range):
range_start = 0
range_end = 7
class CrystalsTower(Crystals):
default = 7
class CrystalsGanon(Crystals):
default = 7
class TriforcePieces(Range):
default = 30
range_start = 1
range_end = 90
class ShopItemSlots(Range):
range_start = 0
range_end = 30
class WorldState(Choice):
@@ -204,56 +290,36 @@ class Enemies(Choice):
option_chaos = 2
mapshuffle = Toggle
compassshuffle = Toggle
keyshuffle = Toggle
bigkeyshuffle = Toggle
hints = Toggle
alttp_options: typing.Dict[str, type(Option)] = {
"crystals_needed_for_gt": CrystalsTower,
"crystals_needed_for_ganon": CrystalsGanon,
"shop_item_slots": ShopItemSlots,
}
RandomizeDreamers = Toggle
RandomizeSkills = Toggle
RandomizeCharms = Toggle
RandomizeKeys = Toggle
RandomizeGeoChests = Toggle
RandomizeMaskShards = Toggle
RandomizeVesselFragments = Toggle
RandomizeCharmNotches = Toggle
RandomizePaleOre = Toggle
RandomizeRancidEggs = Toggle
RandomizeRelics = Toggle
RandomizeMaps = Toggle
RandomizeStags = Toggle
RandomizeGrubs = Toggle
RandomizeWhisperingRoots = Toggle
RandomizeRocks = Toggle
RandomizeSoulTotems = Toggle
RandomizePalaceTotems = Toggle
RandomizeLoreTablets = Toggle
RandomizeLifebloodCocoons = Toggle
RandomizeFlames = Toggle
hollow_knight_randomize_options: typing.Dict[str, Option] = {
"RandomizeDreamers": RandomizeDreamers,
"RandomizeSkills": RandomizeSkills,
"RandomizeCharms": RandomizeCharms,
"RandomizeKeys": RandomizeKeys,
"RandomizeGeoChests": RandomizeGeoChests,
"RandomizeMaskShards": RandomizeMaskShards,
"RandomizeVesselFragments": RandomizeVesselFragments,
"RandomizeCharmNotches": RandomizeCharmNotches,
"RandomizePaleOre": RandomizePaleOre,
"RandomizeRancidEggs": RandomizeRancidEggs,
"RandomizeRelics": RandomizeRelics,
"RandomizeMaps": RandomizeMaps,
"RandomizeStags": RandomizeStags,
"RandomizeGrubs": RandomizeGrubs,
"RandomizeWhisperingRoots": RandomizeWhisperingRoots,
"RandomizeRocks": RandomizeRocks,
"RandomizeSoulTotems": RandomizeSoulTotems,
"RandomizePalaceTotems": RandomizePalaceTotems,
"RandomizeLoreTablets": RandomizeLoreTablets,
"RandomizeLifebloodCocoons": RandomizeLifebloodCocoons,
"RandomizeFlames": RandomizeFlames
hollow_knight_randomize_options: typing.Dict[str, type(Option)] = {
"RandomizeDreamers": DefaultOnToggle,
"RandomizeSkills": DefaultOnToggle,
"RandomizeCharms": DefaultOnToggle,
"RandomizeKeys": DefaultOnToggle,
"RandomizeGeoChests": Toggle,
"RandomizeMaskShards": DefaultOnToggle,
"RandomizeVesselFragments": DefaultOnToggle,
"RandomizeCharmNotches": Toggle,
"RandomizePaleOre": DefaultOnToggle,
"RandomizeRancidEggs": Toggle,
"RandomizeRelics": DefaultOnToggle,
"RandomizeMaps": Toggle,
"RandomizeStags": Toggle,
"RandomizeGrubs": Toggle,
"RandomizeWhisperingRoots": Toggle,
"RandomizeRocks": Toggle,
"RandomizeSoulTotems": Toggle,
"RandomizePalaceTotems": Toggle,
"RandomizeLoreTablets": Toggle,
"RandomizeLifebloodCocoons": Toggle,
"RandomizeFlames": Toggle
}
hollow_knight_skip_options: typing.Dict[str, type(Option)] = {
@@ -282,8 +348,8 @@ class MaxSciencePack(Choice):
default = 6
def get_allowed_packs(self):
return {option.replace("_", "-") for option, value in self.options.items()
if value <= self.value}
return {option.replace("_", "-") for option, value in self.options.items() if value <= self.value} - \
{"space-science-pack"} # with rocket launch being the goal, post-launch techs don't make sense
class TechCost(Choice):
@@ -309,28 +375,48 @@ class TechTreeLayout(Choice):
option_single = 0
option_small_diamonds = 1
option_medium_diamonds = 2
option_pyramid = 3
option_funnel = 4
option_large_diamonds = 3
option_small_pyramids = 4
option_medium_pyramids = 5
option_large_pyramids = 6
option_small_funnels = 7
option_medium_funnels = 8
option_large_funnels = 9
option_funnels = 4
alias_pyramid = 6
alias_funnel = 9
default = 0
class Visibility(Choice):
class TechTreeInformation(Choice):
option_none = 0
option_sending = 1
default = 1
option_advancement = 1
option_full = 2
default = 2
class RecipeTime(Choice):
option_vanilla = 0
option_fast = 1
option_normal = 2
option_slow = 4
option_chaos = 5
class FactorioStartItems(OptionDict):
default = {"burner-mining-drill": 19, "stone-furnace": 19}
factorio_options: typing.Dict[str, type(Option)] = {"max_science_pack": MaxSciencePack,
"tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost,
"free_samples": FreeSamples,
"visibility": Visibility,
"random_tech_ingredients": Toggle,
"starting_items": FactorioStartItems}
factorio_options: typing.Dict[str, type(Option)] = {
"max_science_pack": MaxSciencePack,
"tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost,
"free_samples": FreeSamples,
"tech_tree_information": TechTreeInformation,
"starting_items": FactorioStartItems,
"recipe_time": RecipeTime,
"imported_blueprints": DefaultOnToggle,
}
class AdvancementGoal(Choice):
@@ -356,9 +442,20 @@ minecraft_options: typing.Dict[str, type(Option)] = {
"shuffle_structures": Toggle
}
option_sets = (
minecraft_options,
factorio_options,
alttp_options,
hollow_knight_options
)
if __name__ == "__main__":
import argparse
mapshuffle = Toggle
compassshuffle = Toggle
keyshuffle = Toggle
bigkeyshuffle = Toggle
hints = Toggle
test = argparse.Namespace()
test.logic = Logic.from_text("no_logic")
test.mapshuffle = mapshuffle.from_text("ON")

View File

@@ -12,7 +12,7 @@ from typing import Tuple, Optional
import Utils
from worlds.alttp.Rom import JAP10HASH
current_patch_version = 1
current_patch_version = 2
def get_base_rom_path(file_name: str = "") -> str:
@@ -43,9 +43,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": "alttp",
"compatible_version": 1,
"game": "A Link to the Past",
# minimum version of patch system expected for patching to be successful
"compatible_version": 1,
"version": current_patch_version,
"base_checksum": JAP10HASH})
return patch.encode(encoding="utf-8-sig")
@@ -58,10 +58,13 @@ def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
return generate_yaml(patch, metadata)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None) -> str:
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "") -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
{
"server": server}) # allow immediate connection to server in multiworld. Empty string otherwise
meta)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
write_lzma(bytes, target)
return target

View File

@@ -12,8 +12,8 @@ class Version(typing.NamedTuple):
minor: int
build: int
__version__ = "0.1.0"
_version_tuple = tuplize_version(__version__)
__version__ = "0.1.3"
version_tuple = tuplize_version(__version__)
import builtins
import os
@@ -188,7 +188,7 @@ def get_default_options() -> dict:
"server_password": None,
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 1000,
"hint_cost": 10,
"forfeit_mode": "goal",
"remaining_mode": "goal",
"auto_shutdown": 0,
@@ -203,10 +203,10 @@ def get_default_options() -> dict:
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"pre_roll": False,
"player_name": "",
"create_spoiler": 1,
"zip_roms": 0,
"zip_diffs": 2,
"zip_apmcs": 1,
"zip_spoiler": 0,
"zip_multidata": 1,
"zip_format": 1,
@@ -345,12 +345,12 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
f"Enter yes, no or never: ")
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from Adjuster import AdjusterWorld
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import Adjuster
_, romfile = Adjuster.adjust(adjuster_settings)
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
@@ -392,6 +392,11 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
import NetUtils
return getattr(NetUtils, name)
if module == "Options":
import Options
obj = getattr(Options, name)
if issubclass(obj, Options.Option):
return obj
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))

View File

@@ -2,22 +2,24 @@ import os
import multiprocessing
import logging
import ModuleUpdate
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
ModuleUpdate.update()
from WebHostLib import app as raw_app
from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost
configpath = "config.yaml"
configpath = os.path.abspath("config.yaml")
def get_app():
app = raw_app
if os.path.exists(configpath):
import yaml
with open(configpath) as c:
app.config.update(yaml.safe_load(c))
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)

View File

@@ -3,10 +3,11 @@ import uuid
import base64
import socket
import jinja2.exceptions
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask import Blueprint
from flask_caching import Cache
from flaskext.autoversion import Autoversion
from flask_compress import Compress
from .models import *
@@ -48,9 +49,6 @@ app.config["CACHE_TYPE"] = "simple"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
app.autoversion = True
av = Autoversion(app)
cache = Cache(app)
Compress(app)
@@ -78,6 +76,51 @@ def register_session():
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
games_list = {
"zelda3": ("The Legend of Zelda: A Link to the Past",
"""
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of Link,
a boy who is destined to save the land of Hyrule. Delve through three palaces and nine dungeons on
your quest to rescue the descendents of the seven wise men and defeat the evil Ganon!"""),
"factorio": ("Factorio",
"""
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
research new technologies, and become more efficient in your quest to build a rocket and return home.
"""),
"minecraft": ("Minecraft",
"""
Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine,
craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient
structures, and materials to create a portal to another world. Defeat the Ender Dragon, and claim
victory!""")
}
# Game sub-pages
@app.route('/games/<string:game>/<string:page>')
def game_pages(game, page):
return render_template(f"/games/{game}/{page}.html")
# Game landing pages
@app.route('/games/<game>')
def game_page(game):
return render_template(f"/games/{game}/{game}.html")
# List of supported games
@app.route('/games')
def games():
return render_template("games/games.html", games_list=games_list)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang)
@@ -88,13 +131,8 @@ def tutorial_landing():
return render_template("tutorialLanding.html")
@app.route('/player-settings')
def player_settings_simple():
return render_template("playerSettings.html")
@app.route('/weighted-settings')
def player_settings():
def weighted_settings():
return render_template("weightedSettings.html")

View File

@@ -1,4 +1,6 @@
import json
import pickle
from uuid import UUID
from . import api_endpoints
@@ -46,7 +48,7 @@ def generate_api():
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",
@@ -58,6 +60,7 @@ def generate_api():
return {"text": "Uncaught Exception:" + str(e)}, 500
@api_endpoints.route('/status/<suuid:seed>')
def wait_seed_api(seed: UUID):
seed_id = seed

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import json
import multiprocessing
from datetime import timedelta, datetime
import concurrent.futures
@@ -9,6 +10,8 @@ import time
from pony.orm import db_session, select, commit
from Utils import restricted_loads
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
@@ -25,6 +28,7 @@ class AlreadyRunningException(Exception):
if sys.platform == 'win32':
import os
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -43,6 +47,7 @@ if sys.platform == 'win32':
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -78,14 +83,21 @@ def handle_generation_failure(result: BaseException):
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
options = generation.options
logging.info(f"Generating {generation.id} for {len(options)} players")
meta = generation.meta
pool.apply_async(gen_game, (options,),
{"race": meta["race"], "sid": generation.id, "owner": generation.owner},
handle_generation_success, handle_generation_failure)
generation.state = STATE_STARTED
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(gen_game, (options,),
{"race": meta["race"],
"sid": generation.id,
"owner": generation.owner},
handle_generation_success, handle_generation_failure)
except:
generation.state = STATE_ERROR
commit()
raise
else:
generation.state = STATE_STARTED
def init_db(pony_config: dict):
@@ -138,6 +150,7 @@ multiworlds = {}
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
class MultiworldInstance():
def __init__(self, room: Room, config: dict):
self.room_id = room.id
@@ -162,7 +175,7 @@ class MultiworldInstance():
self.process = None
def _collect(self):
self.process.join() # wait for process to finish
self.process.join() # wait for process to finish
self.process = None
self.guardian = None

View File

@@ -2,12 +2,12 @@ from flask import send_file, Response
from pony.orm import select
from Patch import update_patch_data
from WebHostLib import app, Patch, Room, Seed
from WebHostLib import app, Slot, Room, Seed
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
def download_patch(room_id, patch_id):
patch = Patch.get(id=patch_id)
patch = Slot.get(id=patch_id)
if not patch:
return "Patch not found"
else:
@@ -30,8 +30,9 @@ def download_spoiler(seed_id):
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
def download_raw_patch(seed_id, player_id: int):
patch = select(patch for patch in Patch if
patch.player_id == player_id and patch.seed.id == seed_id).first()
seed = Seed.get(id=seed_id)
patch = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not patch:
return "Patch not found"
@@ -43,3 +44,25 @@ def download_raw_patch(seed_id, player_id: int):
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/slot_file/<suuid:seed_id>/<int:player_id>")
def download_slot_file(seed_id, player_id: int):
seed = Seed.get(id=seed_id)
slot_data: Slot = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not slot_data:
return "Slot Data not found"
else:
import io
if slot_data.game == "Minecraft":
fname = f"AP_{app.jinja_env.filters['suuid'](seed_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)

View File

@@ -1,6 +1,7 @@
import os
import tempfile
import random
import json
from collections import Counter
from flask import request, flash, redirect, url_for, session, render_template
@@ -39,7 +40,7 @@ def generate(race=False):
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
@@ -79,7 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.progression_balancing = {}
erargs.create_diff = True
name_counter = Counter()
@@ -94,10 +94,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
del (erargs.name)
erargs.skip_progression_balancing = {player: not balanced for player, balanced in
erargs.progression_balancing.items()}
del (erargs.progression_balancing)
ERmain(erargs, seed)
return upload_to_db(target.name, owner, sid, race)
@@ -107,7 +103,11 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
gen.meta = (e.__class__.__name__ + ": "+ str(e)).encode()
meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": "+ str(e))
gen.meta = json.dumps(meta)
commit()
raise
@@ -122,12 +122,12 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
return render_template("seedError.html", seed_error=generation.meta.decode())
return render_template("seedError.html", seed_error=generation.meta)
return render_template("waitSeed.html", seed_id=seed_id)
def upload_to_db(folder, owner, sid, race:bool):
patches = set()
slots = set()
spoiler = ""
multidata = None
@@ -137,8 +137,8 @@ def upload_to_db(folder, owner, sid, race:bool):
player_text = file.split("_P", 1)[1]
player_name = player_text.split("_", 1)[1].split(".", 1)[0]
player_id = int(player_text.split(".", 1)[0].split("_", 1)[0])
patches.add(Patch(data=open(file, "rb").read(),
player_id=player_id, player_name = player_name))
slots.add(Slot(data=open(file, "rb").read(),
player_id=player_id, player_name = player_name, game = "A Link to the Past"))
elif file.endswith(".txt"):
spoiler = open(file, "rt", encoding="utf-8-sig").read()
elif file.endswith(".archipelago"):
@@ -146,12 +146,12 @@ def upload_to_db(folder, owner, sid, race:bool):
if multidata:
with db_session:
if sid:
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
id=sid, meta={"tags": ["generated"]})
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
id=sid, meta=json.dumps({"race": race, "tags": ["generated"]}))
else:
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
meta={"tags": ["generated"]})
for patch in patches:
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
meta=json.dumps({"race": race, "tags": ["generated"]}))
for patch in slots:
patch.seed = seed
if sid:
gen = Generation.get(id=sid)

View File

@@ -9,12 +9,13 @@ STATE_STARTED = 1
STATE_ERROR = -1
class Patch(db.Entity):
class Slot(db.Entity):
id = PrimaryKey(int, auto=True)
player_id = Required(int)
player_name = Required(str, 16)
data = Required(bytes, lazy=True)
data = Optional(bytes, lazy=True)
seed = Optional('Seed')
game = Required(str)
class Room(db.Entity):
@@ -37,9 +38,9 @@ class Seed(db.Entity):
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
patches = Set(Patch)
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(Json, lazy=True, default=lambda: {}) # additional meta information/tags
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
class Command(db.Entity):
@@ -51,6 +52,6 @@ class Command(db.Entity):
class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID)
options = Required(Json, lazy=True)
meta = Required(Json, lazy=True)
options = Required(buffer, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)

View File

@@ -1,7 +1,6 @@
flask>=1.1.2
flask>=2.0.1
pony>=0.7.14
waitress>=2.0.0
flask-caching>=1.10.1
Flask-Autoversion>=0.2.0
Flask-Compress>=1.9.0
Flask-Limiter>=1.4

View File

@@ -0,0 +1,51 @@
# Factorio Randomizer Setup Guide
## Required Software
### Server Host
- [Factorio](https://factorio.com)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
### Players
- [Factorio](https://factorio.com)
## General Concept
One Server Host exists per Factorio World in an Archipelago Multiworld, any number of modded Factorio players can then connect to that world. From the view of Archipelago, this Factorio host is a client.
## Installation Procedures
### Dedicated Server Setup
You need a dedicated isolated Factorio installation that the FactorioClient can take control over, if you intend to both emit a world and play, you need to follow both this setup and the player setup.
This requires two Factorio installations. The easiest and cheapest way to do so is to either buy or register a Factorio on factorio.com, which allows you to download as many Factorio games as you want.
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
2. Make sure the Factorio you play and the Factorio you use for hosting do not share paths. If you downloaded the "manual" version, this is already the case, otherwise, go into the hosting Factorio's folder and put the following text into its `config-path.cfg`:
```ini
config-path=__PATH__executable__/../../config
use-system-read-write-data-directories=false
```
3. Navigate to where you installed Archipelago and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your Factorio. If you put Factorio into your Archipelago folder, this would already match.
### Player Setup
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer.
## Joining a MultiWorld Game
1. Make a fresh world. Using any Factorio, create a savegame with options of your choice and save that world as Archipelago.
2. Take that savegame and put it into your Archipelago folder
3. Install the generated Factorio AP Mod
4. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`,
* It should say it loaded the Archipelago mod and found a bridge file. If not, the most likely case is that the mod is not correctly installed or activated.
5. In FactorioClient, do `/connect <Archipelago Server Address>` to join that multiworld. You can find further commands with `/help` as well as `!help` once connected.
* / commands are run on your local client, ! commands are requests for the AP server
* Players should be able to connect to your Factorio Server and begin playing.

View File

@@ -68,7 +68,7 @@ game: Minecraft
# Shared Options supported by all games:
accessibility: locations
progression_balancing: off
progression_balancing: on
# Minecraft Specific Options
# Number of advancements required (out of 92 total) to spawn the
@@ -108,7 +108,6 @@ shuffle_structures:
off: 0
```
For more detail on what each setting does check the default `PlayerSettings.yaml` that comes with the Archipelago install.
## Joining a MultiWorld Game
@@ -126,8 +125,8 @@ previously.
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
status by typing `/op YourMinecraftUsername` in the forge server console then connecting in your Minecraft client.
Once in game type `/connect <AP-Address> (<Password>)` where `<AP-Address>` is the address of the
Archipelago server. `(<Password>)`
Once in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. `(Password)`
is only required if the Archipleago server you are using has a password set.
### Play the game

View File

@@ -86,6 +86,25 @@
}
]
},
{
"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"
]
}
]
}
]
},
{
"gameTitle": "Minecraft",
"tutorials": [

View File

@@ -1,13 +1,7 @@
# A Link to the Past Randomizer Setup Guide
<div id="tutorial-video-container">
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/icWPmse0Z3E" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
</iframe>
</div>
## Benötigte Software
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
- Hardware oder Software zum Laden und Abspielen von SNES Rom-Dateien
- Ein Emulator, der lua-scripts abspielen kann
@@ -21,7 +15,7 @@
### Windows
1. Lade die Multiworld Utilities herunter und führe die Installation aus. Sei sicher, dass du immer die
aktuellste Version installiert hast.**Die Datei befindet sich im "assets"-Kasten unter der jeweiligen Versionsinfo!**.
Für normale Multiworld-Spiele lädst du die `Setup.BerserkerMultiworld.exe` herunter.
Für normale Multiworld-Spiele lädst du die `Setup.Archipelago.exe` herunter.
- Für den Doorrandomizer muss die alternative doors-Variante geladen werden.
- Während der Installation fragt dich das Programm nach der japanischen 1.0 ROM-Datei. Wenn du die Software
bereits installiert hast und einfach nur updaten willst, wirst du nicht nochmal danach gefragt.

View File

@@ -7,7 +7,7 @@
</div>
## Required Software
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of running Lua scripts
@@ -21,7 +21,7 @@
### Windows Setup
1. Download and install the MultiWorld Utilities from the link above, making sure to install the most recent version.
**The file is located in the assets section at the bottom of the version information**. If you intend to play normal
multiworld games, you want `Setup.BerserkerMultiWorld.exe`
multiworld games, you want `Setup.Archipelago.exe`
- If you intend to play the doors variant of multiworld, you will want to download the alternate doors file.
- During the installation process, you will be asked to browse for your Japanese 1.0 ROM file. If you have
installed this software before and are simply upgrading now, you will not be prompted to locate your
@@ -144,7 +144,7 @@ on successfully joining a multiworld game!
## Hosting a MultiWorld game
The recommended way to host a game is to use the hosting service provided on
[the website](https://berserkermulti.world/generate). The process is relatively simple:
[the website](/generate). The process is relatively simple:
1. Collect YAML files from your players.
2. Create a zip file containing your players' YAML files.

View File

@@ -7,7 +7,7 @@
</div>
## Software requerido
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
- Un emulador capaz de ejecutar scripts Lua
@@ -20,7 +20,7 @@
### Instalación en Windows
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.BerserkerMultiWorld.exe`
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar 'Setup.BerserkerMultiWorld.Doors.exe'
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del archivo una segunda vez.
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
@@ -136,7 +136,7 @@ por unirte satisfactoriamente a una partida de multiworld!
## Hospedando una partida de multiworld
La manera recomendad para hospedar una partida es usar el servicio proveído en
[el sitio web](https://berserkermulti.world/generate). El proceso es relativamente sencillo:
[el sitio web](/generate). El proceso es relativamente sencillo:
1. Recolecta los ficheros YAML de todos los jugadores que participen.
2. Crea un fichero ZIP conteniendo esos ficheros.

View File

@@ -7,7 +7,7 @@
</div>
## Logiciels requis
- [Utilitaires du MultiWorld](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
- Un émulateur capable d'éxécuter des scripts Lua

View File

@@ -2,7 +2,7 @@
## Configuration
1. Plando features have to be enabled first, before they can be used (opt-in).
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\BerserkerMultiWorld`),
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`),
then open the host.yaml file with a text editor.
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the
value to
@@ -13,7 +13,7 @@
### Bosses
- This module is enabled by default and available to be used on
[https://archipelago.gg/generate](https://archipelago.gg/generate)
[https://archipelago.gg/generate](/generate)
- Plando versions of boss shuffles can be added like any other boss shuffle option in a yaml and weighted.
- Boss Plando works as a list of instructions from left to right, if any arenas are empty at the end,
it defaults to vanilla

View File

@@ -473,5 +473,11 @@ const generateGame = (raceMode = false) => {
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -170,6 +170,12 @@ const generateGame = (raceMode = false) => {
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -0,0 +1,10 @@
#page-not-found{
width: 40em;
margin-left: auto;
margin-right: auto;
text-align: center;
}
#page-not-found h1{
margin-bottom: 0.5rem;
}

View File

@@ -0,0 +1,3 @@
#factorio{
margin: 1rem;
}

View File

@@ -1,5 +1,5 @@
html{
background-image: url('../static/backgrounds/grass/grass-0007-large.png');
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
@@ -29,6 +29,20 @@ html{
color: #000000;
}
#player-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#player-settings #user-message.visible{
display: block;
}
#player-settings h1{
font-size: 2.5rem;
font-weight: normal;

View File

@@ -0,0 +1,61 @@
#games{
max-width: 1000px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#games p{
margin-top: 0.25rem;
}
#games code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
#games #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#games h1{
font-size: 2.5rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
#games h2{
font-size: 2rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#games h3, #games h4, #games h5, #games h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#games a{
color: #ffef00;
}

View File

@@ -4,9 +4,6 @@
}
html{
background-image: url('../static/backgrounds/oceans/oceans-0002.png');
background-repeat: repeat;
background-size: 250px 250px;
font-family: 'Jost', sans-serif;
font-size: 1.1rem;
color: #000000;

View File

@@ -1,3 +1,9 @@
html{
background-image: url('../../static/backgrounds/dirt/dirt-0005-large.png');
background-repeat: repeat;
background-size: 900px 900px;
}
#base-header{
background: url('../../static/backgrounds/header/dirt-header.png') repeat-x;
}

View File

@@ -1,3 +1,9 @@
#base-header{
html{
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#base-header {
background: url('../../static/backgrounds/header/grass-header.png') repeat-x;
}

View File

@@ -1,3 +1,9 @@
html{
background-image: url('../../static/backgrounds/oceans/oceans-0002.png');
background-repeat: repeat;
background-size: 250px 250px;
}
#base-header{
background: url('../../static/backgrounds/header/ocean-header.png') repeat-x;
}

View File

@@ -1,9 +1,3 @@
html{
background-image: url('../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#host-room{
width: calc(100% - 5rem);
margin-left: auto;

View File

@@ -7,7 +7,6 @@ html{
flex-direction: column;
justify-content: center;
flex-wrap: wrap;
margin-top: 60px;
}
#landing-header{
@@ -53,18 +52,19 @@ html{
font-size: 1.4rem;
}
#uploads-button{
top: 65px;
#far-left-button{
top: 115px;
left: calc(50% - 416px - 200px - 75px);
background-image: url("/static/static/button-images/button-a.png");
background-size: 200px auto;
width: 200px;
height: calc(156px - 40px);
padding-top: 40px;
cursor: default;
}
#setup-guide-button{
top: 270px;
#mid-left-button{
top: 320px;
left: calc(50% - 416px - 200px + 140px);
background-image: url("/static/static/button-images/button-b.png");
background-size: 260px auto;
@@ -73,8 +73,8 @@ html{
padding-top: 35px;
}
#player-settings-button{
top: 350px;
#mid-button{
top: 400px;
left: calc(50% - 100px);
background-image: url("/static/static/button-images/button-a.png");
background-size: 200px auto;
@@ -83,8 +83,8 @@ html{
padding-top: 38px;
}
#discord-button{
top: 250px;
#mid-right-button{
top: 300px;
left: calc(50% + 416px - 166px);
background-image: url("/static/static/button-images/button-c.png");
background-size: 250px auto;
@@ -94,14 +94,15 @@ html{
padding-left: 20px;
}
#generate-button{
top: 75px;
#far-right-button{
top: 125px;
left: calc(50% + 416px + 75px);
background-image: url("/static/static/button-images/button-b.png");
background-size: 260px auto;
width: 260px;
height: calc(130px - 35px);
padding-top: 35px;
cursor: default;
}
#landing-clouds{
@@ -111,7 +112,7 @@ html{
#landing-clouds #cloud1{
position: absolute;
left: 10px;
top: 265px;
top: 365px;
width: 400px;
height: 350px;
@@ -147,23 +148,23 @@ html{
@keyframes c1-float{
from{
left: 10px;
top: 265px;
top: 365px;
}
25%{
left: 14px;
top: 267px;
top: 367px;
}
50%{
left: 17px;
top: 265px;
top: 365px;
}
75%{
left: 14px;
top: 262px;
top: 362px;
}
to{
left: 10px;
top: 265px;
top: 365px;
}
}
@@ -241,32 +242,32 @@ html{
}
#landing-deco-1{
top: 430px;
top: 480px;
left: calc(50% - 276px);
}
#landing-deco-2{
top: 200px;
top: 250px;
left: calc(50% + 150px);
}
#landing-deco-3{
top: 300px;
top: 350px;
left: calc(50% - 150px);
}
#landing-deco-4{
top: 240px;
top: 290px;
left: calc(50% - 580px);
}
#landing-deco-5{
top: 40px;
top: 90px;
left: calc(50% + 450px);
}
#landing-deco-6{
top: 412px;
top: 462px;
left: calc(50% + 196px);
}

View File

@@ -0,0 +1,3 @@
#minecraft{
margin: 1rem;
}

View File

@@ -0,0 +1,129 @@
html{
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#player-settings{
max-width: 1000px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#player-settings #player-settings-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-settings code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
#player-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#player-settings #user-message.visible{
display: block;
}
#player-settings h1{
font-size: 2.5rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
#player-settings h2{
font-size: 2rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-settings a{
color: #ffef00;
}
#player-settings input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-settings input:not([type]):focus{
border: 1px solid #ffffff;
}
#player-settings select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
}
#player-settings #game-options, #player-settings #rom-options{
display: flex;
flex-direction: row;
}
#player-settings .left, #player-settings .right{
flex-grow: 1;
}
#player-settings table select{
width: 250px;
}
#player-settings table label{
display: block;
min-width: 200px;
margin-right: 4px;
cursor: default;
}
@media all and (max-width: 1000px), all and (orientation: portrait){
#player-settings #game-options, #player-settings #rom-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#player-settings .left, #player-settings .right{
flex-grow: unset;
}
#game-options table label, #rom-options table label{
display: block;
min-width: 200px;
}
}

View File

@@ -1,9 +1,3 @@
html{
background-image: url('../static/backgrounds/dirt/dirt-0005-large.png');
background-repeat: repeat;
background-size: 900px 900px;
}
#tracker-wrapper {
display: flex;
flex-direction: column;

View File

@@ -1,9 +1,3 @@
html{
background-image: url('../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#tutorial-wrapper{
display: flex;
flex-direction: column;

View File

@@ -1,9 +1,3 @@
html{
background-image: url('../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#tutorial-landing{
display: flex;
flex-direction: column;

View File

@@ -1,9 +1,3 @@
html{
background-image: url('../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#weighted-settings{
width: 60rem;
margin-left: auto;
@@ -14,7 +8,7 @@ html{
color: #eeffeb;
}
#user-warning{
#user-warning, #weighted-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
@@ -25,6 +19,10 @@ html{
cursor: pointer;
}
#weighted-settings #user-message.visible{
display: block;
}
#weighted-settings code{
background-color: #d9cd8e;
border-radius: 4px;

View File

@@ -0,0 +1,129 @@
html{
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#player-settings{
max-width: 1000px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#player-settings #player-settings-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-settings code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
#player-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#player-settings #user-message.visible{
display: block;
}
#player-settings h1{
font-size: 2.5rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
#player-settings h2{
font-size: 2rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-settings a{
color: #ffef00;
}
#player-settings input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-settings input:not([type]):focus{
border: 1px solid #ffffff;
}
#player-settings select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
}
#player-settings #game-options, #player-settings #rom-options{
display: flex;
flex-direction: row;
}
#player-settings .left, #player-settings .right{
flex-grow: 1;
}
#player-settings table select{
width: 250px;
}
#player-settings table label{
display: block;
min-width: 200px;
margin-right: 4px;
cursor: default;
}
@media all and (max-width: 1000px), all and (orientation: portrait){
#player-settings #game-options, #player-settings #rom-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#player-settings .left, #player-settings .right{
flex-grow: unset;
}
#game-options table label, #rom-options table label{
display: block;
min-width: 200px;
}
}

View File

@@ -0,0 +1,3 @@
#zelda3{
margin: 1rem;
}

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% block head %}
<title>Page Not Found (404)</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/404.css") }}" />
{% endblock %}
{% block body %}
{% include 'header/oceanHeader.html' %}
<div id="page-not-found" class="grass-island">
<h1>This page is out of logic!</h1>
The page you're looking for doesn&apos;t exist.<br />
<a href="/">Click here to return to safety.</a>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<script type="application/ecmascript" src="{{ static_autoversion("assets/autodatatable.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/autodatatable.js") }}"></script>
{% endblock %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Mystery Check Result</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/check.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/check.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,7 +3,7 @@
{% block head %}
{{ super() }}
<title>Mystery YAML Test Roll Results</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/checkResult.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/checkResult.css") }}" />
{% endblock %}
{% block body %}

View File

@@ -0,0 +1,15 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Factorio</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/factorio/factorio.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="factorio">
Coming Soon™
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Factorio Settings</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/factorio/player-settings.css") }}" />
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="player-settings">
<div id="user-message"></div>
<h1>Factorio Settings</h1>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download a settings file you can use to participate in a MultiWorld. If you would like to make
your settings extra random, check out the advanced <a href="/weighted-settings">weighted settings</a>
page. There, you will find examples of all available sprites as well.</p>
<p>A list of all games you have generated can be found <a href="/user-content">here</a>.</p>
<div>
More content coming soon™.
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/games.css") }}" />
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="games">
<h1>Currently Supported Games</h1>
{% for game, (display_name, description) in games_list.items() %}
<h3><a href="{{ url_for("game_page", game=game) }}">{{ display_name}}</a></h3>
<p>{{ description}}</p>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Minecraft</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/minecraft/minecraft.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="minecraft">
Coming Soon™
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Minecraft Settings</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/minecraft/player-settings.css") }}" />
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="player-settings">
<div id="user-message"></div>
<h1>Minecraft Settings</h1>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download a settings file you can use to participate in a MultiWorld. If you would like to make
your settings extra random, check out the advanced <a href="/weighted-settings">weighted settings</a>
page. There, you will find examples of all available sprites as well.</p>
<p>A list of all games you have generated can be found <a href="/user-content">here</a>.</p>
<div>
More content coming soon™.
</div>
</div>
{% endblock %}

View File

@@ -1,17 +1,18 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/playerSettings.css") }}" />
<title>A Link to the Past Settings</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/zelda3/player-settings.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/playerSettings.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/zelda3/player-settings.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="player-settings">
<h1>Start Game</h1>
<div id="user-message"></div>
<h1>A Link to the Past Settings</h1>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download a settings file you can use to participate in a MultiWorld. If you would like to make
your settings extra random, check out the advanced <a href="/weighted-settings">weighted settings</a>

View File

@@ -0,0 +1,15 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>A Link to the Past</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/zelda3/zelda3.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="zelda3">
Coming Soon™
</div>
{% endblock %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/generate.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
{% endblock %}
{% block body %}
@@ -31,7 +31,7 @@
<p>
After generation is complete, you will have the option to download a patch file.
This patch file can be opened with the
<a href="https://github.com/Berserker66/MultiWorld-Utilities/releases">client</a>, which can be
<a href="https://github.com/ArchipelagoMW/Archipelago/releases">client</a>, which can be
used to to create a rom file. In-browser patching is planned for the future.
</p>
<div id="generate-game-form-wrapper">

View File

@@ -0,0 +1,63 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span>
</div>
<div class="table-wrapper">
<table class="table non-unique-item-table">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{% for name, count in inventory.items() %}
<tr>
<td>{{ name | item_name }}</td>
<td>{{ count }}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
<div class="table-wrapper">
<table class="table non-unique-item-table">
<thead>
<tr>
<th>Location</th>
<th>Checked</th>
</tr>
</thead>
<tbody>
{% for name in checked_locations %}
<tr>
<td>{{ name | location_name}}</td>
<td></td>
</tr>
{%- endfor -%}
{% for name in not_checked_locations %}
<tr>
<td>{{ name | location_name}}</td>
<td></td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/header/baseHeader.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/header/baseHeader.css") }}" />
{% endblock %}
{% block header %}
@@ -11,10 +11,8 @@
<a href="/">archipelago</a>
</div>
<div id="base-header-right">
<a href="/player-settings">start game</a>
<a href="/uploads">host game</a>
<a href="/games">games</a>
<a href="/tutorial">setup guides</a>
<a href="/generate">upload config</a>
<a href="https://discord.gg/8Z65BR2">discord</a>
</div>
</header>

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Upload Multidata</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/hostGame.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -2,7 +2,7 @@
{% import "macros.html" as macros %}
{% block head %}
<title>Multiworld {{ room.id|suuid }}</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/hostRoom.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %}
{% block body %}
@@ -21,7 +21,7 @@
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 archipelago.gg:{{ room.last_port }}'
in the <a href="https://github.com/Berserker66/MultiWorld-Utilities/releases">client</a>.<br>{% endif %}
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
<form method=post>

View File

@@ -2,17 +2,17 @@
<footer id="island-footer">
<div id="copyright-notice">Copyright 2021 Archipelago</div>
<div id="links">
<a href="https://github.com/Berserker66/MultiWorld-Utilities">Source Code</a>
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
-
<a href="https://github.com/Berserker66/MultiWorld-Utilities/wiki">Wiki</a>
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
-
<a href="https://github.com/Berserker66/MultiWorld-Utilities/graphs/contributors">Contributors</a>
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
-
<a href="https://github.com/Berserker66/MultiWorld-Utilities/issues">Bug Report</a>
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>
</div>
</footer>
{% endblock %}
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/islandFooter.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/islandFooter.css") }}" />
{% endblock %}

View File

@@ -2,21 +2,22 @@
{% block head %}
<title>MultiWorld</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/landing.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/landing.css") }}" />
{% endblock %}
{% block body %}
{% include 'header/oceanHeader.html' %}
<div id="landing-wrapper">
<div id="landing-header">
<h4>the legend of zelda: a link to the past</h4>
<h1>MULTIWORLD RANDOMIZER</h1>
<h1>ARCHIPELAGO</h1>
<h4>multiworld randomizer ecosystem</h4>
</div>
<div id="landing-links">
<a href="/player-settings" id="player-settings-button">start<br />playing</a>
<a href="/uploads" id="uploads-button">host<br />game</a>
<a href="/tutorial" id="setup-guide-button">setup guides</a>
<a href="/generate" id="generate-button">upload config</a>
<a href="https://discord.gg/8Z65BR2" id="discord-button">discord</a>
<a href="/games" id="mid-button">start<br />playing</a>
<a id="far-left-button"></a>
<a href="/tutorial" id="mid-left-button">setup guide</a>
<a id="far-right-button"></a>
<a href="https://discord.gg/8Z65BR2" id="mid-right-button">discord</a>
</div>
<div id="landing-clouds">
<img id="cloud1" src="/static/static/backgrounds/clouds/cloud-0001.png"/>
@@ -33,19 +34,18 @@
</div>
<div id="landing" class="grass-island">
<div id="landing-body">
<p id="first-line">Welcome to the Archipelago Multiworld Randomizer!</p>
<p>This is a <span data-tooltip="Allegedly.">randomizer</span> for The Legend of Zelda: A
Link to the Past.</p>
<p>It is also a multi-world, meaning Link's items may have been placed into other players' games.
When a player picks up an item which does not belong to them, it is sent back to the player
it belongs to.</p>
<p>On this website you are able to generate and host multiworld games, and item and location
trackers are provided for games hosted here.</p>
<p id="first-line">Welcome to Archipelago!</p>
<p>
This is a cross-game modification system which randomizes different games, then uses the result to
build a single unified multi-player game. Items from one game may be present in another, and
you will need your fellow players to find items you need in their games to help you complete
your own.
</p>
<p>
This project is the cumulative effort of many
<a href="https://github.com/Berserker66/MultiWorld-Utilities/graphs/contributors">talented people.</a>
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">talented people.</a>
Together, they have spent countless hours creating a huge repository of
<a href="https://github.com/Berserker66/MultiWorld-Utilities">source code</a> which has turned
<a href="https://github.com/ArchipelagoMW/Archipelago">source code</a> which has turned
our crazy idea into a reality.
</p>
<p>

View File

@@ -2,8 +2,8 @@
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/playerTracker.css") }}"/>
<script type="application/ecmascript" src="{{ static_autoversion("assets/playerTracker.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/playerTracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/playerTracker.js") }}"></script>
</head>
<body>
@@ -39,7 +39,7 @@
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ gloves_url }}" class="{{ 'acquired' if gloves_acquired }}" /></td>
<td><img src="{{ glove_url }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>

View File

@@ -7,11 +7,19 @@
</ul>
{%- endmacro %}
{% macro list_patches_room(room) %}
{% if room.seed.patches %}
{% if room.seed.slots %}
<ul>
{% for patch in room.seed.patches|list|sort(attribute="player_id") %}
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
{% if patch.game == "Minecraft" %}
<li><a href="{{ url_for("download_slot_file", seed_id=room.seed.id, player_id=patch.player_id) }}">
APMC for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% elif patch.game == "Factorio" %}
<li><a href="{{ url_for("download_slot_file", seed_id=room.seed.id, player_id=patch.player_id) }}">
Mod for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% else %}
<li><a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}">
Patch for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -4,11 +4,11 @@
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/cookieNotice.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
{% block head %}
<title>MultiWorld</title>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block head %}
<title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/waitSeed.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
{% block body %}

View File

@@ -7,5 +7,5 @@
<script type="text/javascript"
src="https://cdn.datatables.net/v/dt/jq-3.3.1/dt-1.10.21/sc-2.0.2/sp-1.1.1/datatables.min.js"
></script>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tablepage.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tablepage.css") }}" />
{% endblock %}

View File

@@ -2,9 +2,9 @@
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ static_autoversion("assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/tracker.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,11 +3,11 @@
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tutorial.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorial.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/tutorial.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Archipelago Guides</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/tutorialLanding.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -3,8 +3,8 @@
{% block head %}
{{ super() }}
<title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/userContent.css") }}" />
<script type="application/ecmascript" src="{{ static_autoversion("assets/userContent.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/userContent.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/userContent.js") }}"></script>
{% endblock %}
{% block body %}
@@ -31,10 +31,7 @@
<tr>
<td><a href="{{ url_for("viewSeed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("hostRoom", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td
class="center"
data-tooltip="{{ room.seed.multidata.names[0]|join(", ")|truncate(256, False, " ...") }}"
>{{ room.seed.multidata.names[0]|length }}</td>
<td>>={{ room.seed.slots|length }}</td>
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
</tr>
@@ -59,11 +56,7 @@
{% for seed in seeds %}
<tr>
<td><a href="{{ url_for("viewSeed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td class="center"
{% if seed.multidata %}
data-tooltip="{{ seed.multidata.names[0]|join(", ")|truncate(256, False, " ...") }}"
{% endif %}
>{% if seed.multidata %}{{ seed.multidata.names[0]|length }}{% else %}1{% endif %}
<td>{% if seed.multidata %}>={{ seed.slots|length }}{% else %}1{% endif %}
</td>
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
</tr>

View File

@@ -3,8 +3,8 @@
{% block head %}
<title>View Seed {{ seed.id|suuid }}</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/viewSeed.css") }}"/>
<script type="application/ecmascript" src="{{ static_autoversion("assets/viewSeed.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/viewSeed.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/viewSeed.js") }}"></script>
{% endblock %}
{% block body %}
@@ -37,17 +37,10 @@
<td>Players:&nbsp;</td>
<td>
<ul>
{% for team in seed.multidata["names"] %}
{% set outer_loop = loop %}
<li>Team #{{ loop.index }} - {{ team | length }}
<ul>
{% for player in team %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=loop.index, team_id=outer_loop.index0) }}">{{ player }}</a>
</li>
{% endfor %}
</ul>
</li>
{% for patch in seed.slots|sort(attribute='player_id') %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=patch.player_id) }}">{{ patch.player_name }}</a>
</li>
{% endfor %}
</ul>
</td>
@@ -64,13 +57,13 @@
</tr>
{% else %}
<tr>
<td>Patches:&nbsp;</td>
<td>Files:&nbsp;</td>
<td>
<ul>
{% for patch in seed.patches %}
{% for slot in seed.slots %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=patch.player, team_id=0) }}">Player {{ patch.player }}</a>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=slot.player_id) }}">Player {{ slot.player_name }}</a>
</li>

View File

@@ -4,7 +4,7 @@
{% block head %}
<title>Generation in Progress</title>
<meta http-equiv="refresh" content="1">
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/waitSeed.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
{% block body %}

View File

@@ -2,16 +2,17 @@
{% block head %}
<title>Player Settings</title>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("styles/weightedSettings.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weightedSettings.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ static_autoversion("assets/weightedSettings.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weightedSettings.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="weighted-settings">
<header id="user-warning"></header>
<div id="user-message"></div>
<h1>Weighted Settings</h1>
<div id="instructions">
This page is used to configure your weighted settings. You have three presets you can control, which

View File

@@ -5,18 +5,17 @@ from werkzeug.exceptions import abort
import datetime
from uuid import UUID
from worlds.alttp import Items, Regions
from worlds.alttp import Items
from WebHostLib import app, cache, Room
from NetUtils import Hint
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
def get_id(item_name):
def get_alttp_id(item_name):
return Items.item_table[item_name][2]
app.jinja_env.filters["location_name"] = lambda location: Regions.lookup_id_to_name.get(location, location)
app.jinja_env.filters['item_name'] = lambda id: Items.lookup_id_to_name.get(id, id)
app.jinja_env.filters["location_name"] = lambda location: lookup_any_location_id_to_name.get(location, location)
app.jinja_env.filters['item_name'] = lambda id: lookup_any_item_id_to_name.get(id, id)
icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -155,9 +154,9 @@ levels = {"Fighter Sword": 1,
"Bow": 1,
"Silver Bow": 2}
multi_items = {get_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")}
links = {get_id(key): get_id(value) for key, value in links.items()}
levels = {get_id(key): value for key, value in levels.items()}
multi_items = {get_alttp_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")}
links = {get_alttp_id(key): get_alttp_id(value) for key, value in links.items()}
levels = {get_alttp_id(key): value for key, value in levels.items()}
tracking_names = ["Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer",
"Hookshot", "Magic Mirror", "Flute",
@@ -237,7 +236,7 @@ ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower',
tracking_ids = []
for item in tracking_names:
tracking_ids.append(get_id(item))
tracking_ids.append(get_alttp_id(item))
small_key_ids = {}
big_key_ids = {}
@@ -266,6 +265,7 @@ def attribute_item(inventory, team, recipient, item):
def attribute_item_solo(inventory, item):
"""Adds item to inventory counter, converts everything to progressive."""
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[target_item] = max(inventory[target_item], levels[item])
@@ -319,20 +319,22 @@ def get_static_room_data(room: Room):
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
for _, (item_id, item_player) in locations.items():
if item_id in ids_big_key:
player_big_key_locations[item_player].add(ids_big_key[item_id])
if item_id in ids_small_key:
player_small_key_locations[item_player].add(ids_small_key[item_id])
for loc_data in locations.values():
for item_id, item_player in loc_data.values():
if item_id in ids_big_key:
player_big_key_locations[item_player].add(ids_big_key[item_id])
elif item_id in ids_small_key:
player_small_key_locations[item_player].add(ids_small_key[item_id])
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
player_big_key_locations, player_small_key_locations, multidata["precollected_items"]
player_big_key_locations, player_small_key_locations, multidata["precollected_items"], \
multidata["games"]
_multidata_cache[room.seed.id] = result
return result
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=15)
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
# Team and player must be positive and greater than zero
if tracked_team < 0 or tracked_player < 1:
@@ -343,9 +345,9 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
abort(404)
# Collect seed information and pare it down to a single player
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations, precollected_items = get_static_room_data(room)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
player_big_key_locations, player_small_key_locations, precollected_items, games = get_static_room_data(room)
player_name = names[tracked_team][tracked_player - 1]
seed_checks_in_area = seed_checks_in_area[tracked_player]
location_to_area = player_location_to_area[tracked_player]
inventory = collections.Counter()
checks_done = {loc_name: 0 for loc_name in default_locations}
@@ -362,130 +364,82 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
multisave = {}
# Add items to player inventory
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}):
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
# logging.info(f"{ms_team}, {ms_player}, {locations_checked}")
# Skip teams and players not matching the request
player_locations = locations[ms_player]
if ms_team == tracked_team:
# If the player does not have the item, do nothing
for location in locations_checked:
if (location, ms_player) not in locations:
continue
item, recipient = locations[location, ms_player]
if recipient == tracked_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1
# Note the presence of the triforce item
for (ms_team, ms_player), game_state in multisave.get("client_game_state", []):
# Skip teams and players not matching the request
if ms_team != tracked_team or ms_player != tracked_player:
continue
if game_state:
if location in player_locations:
item, recipient = player_locations[location]
if recipient == tracked_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1
if games[tracked_player] == "A Link to the Past":
# Note the presence of the triforce item
game_state = multisave.get("client_game_state", {}).get((tracked_team, tracked_player), 0)
if game_state == 30:
inventory[106] = 1 # Triforce
acquired_items = []
for itm in inventory:
acquired_items.append(get_item_name_from_id(itm))
# Progressive items need special handling for icons and class
progressive_items = {
"Progressive Sword": 94,
"Progressive Glove": 97,
"Progressive Bow": 100,
"Progressive Mail": 96,
"Progressive Shield": 95,
}
progressive_names = {
"Progressive Sword": [None, 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
"Progressive Glove": [None, 'Power Glove', 'Titan Mitts'],
"Progressive Bow": [None, "Bow", "Silver Bow"],
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
"Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"]
}
# Progressive items need special handling for icons and class
progressive_items = {
"Progressive Sword": 94,
"Progressive Glove": 97,
"Progressive Bow": 100,
"Progressive Mail": 96,
"Progressive Shield": 95,
}
# Determine which icon to use
display_data = {}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name])-1)
display_name = progressive_names[item_name][level]
acquired = True
if not display_name:
acquired = False
display_name = progressive_names[item_name][level+1]
base_name = item_name.split(maxsplit=1)[1].lower()
display_data[base_name+"_acquired"] = acquired
display_data[base_name+"_url"] = icons[display_name]
# Determine which icon to use for the sword
sword_url = icons["Fighter Sword"]
sword_acquired = False
sword_names = ['Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword']
if "Progressive Sword" in acquired_items:
sword_url = icons[sword_names[min(inventory[progressive_items["Progressive Sword"]], 4) - 1]]
sword_acquired = True
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
sp_areas = ordered_areas[2:15]
return render_template("lttpTracker.html", inventory=inventory,
player_name=player_name, room=room, icons=icons, checks_done=checks_done,
checks_in_area=seed_checks_in_area[tracked_player], acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
key_locations=player_small_key_locations[tracked_player],
big_key_locations=player_big_key_locations[tracked_player],
**display_data)
else:
for sword in reversed(sword_names):
if sword in acquired_items:
sword_url = icons[sword]
sword_acquired = True
break
gloves_url = icons["Power Glove"]
gloves_acquired = False
glove_names = ["Power Glove", "Titan Mitts"]
if "Progressive Glove" in acquired_items:
gloves_url = icons[glove_names[min(inventory[progressive_items["Progressive Glove"]], 2) - 1]]
gloves_acquired = True
else:
for glove in reversed(glove_names):
if glove in acquired_items:
gloves_url = icons[glove]
gloves_acquired = True
break
bow_url = icons["Bow"]
bow_acquired = False
bow_names = ["Bow", "Silver Bow"]
if "Progressive Bow" in acquired_items:
bow_url = icons[bow_names[min(inventory[progressive_items["Progressive Bow"]], 2) - 1]]
bow_acquired = True
else:
for bow in reversed(bow_names):
if bow in acquired_items:
bow_url = icons[bow]
bow_acquired = True
break
mail_url = icons["Green Mail"]
mail_names = ["Blue Mail", "Red Mail"]
if "Progressive Mail" in acquired_items:
mail_url = icons[mail_names[min(inventory[progressive_items["Progressive Mail"]], 2) - 1]]
else:
for mail in reversed(mail_names):
if mail in acquired_items:
mail_url = icons[mail]
break
shield_url = icons["Blue Shield"]
shield_acquired = False
shield_names = ["Blue Shield", "Red Shield", "Mirror Shield"]
if "Progressive Shield" in acquired_items:
shield_url = icons[shield_names[min(inventory[progressive_items["Progressive Shield"]], 3) - 1]]
shield_acquired = True
else:
for shield in reversed(shield_names):
if shield in acquired_items:
shield_url = icons[shield]
shield_acquired = True
break
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
sp_areas = ordered_areas[2:15]
return render_template("playerTracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
player_name=player_name, room=room, icons=icons, checks_done=checks_done,
checks_in_area=seed_checks_in_area, acquired_items=acquired_items,
sword_url=sword_url, sword_acquired=sword_acquired, gloves_url=gloves_url,
gloves_acquired=gloves_acquired, bow_url=bow_url, bow_acquired=bow_acquired,
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
key_locations=player_small_key_locations[tracked_player],
big_key_locations=player_big_key_locations[tracked_player],
mail_url=mail_url, shield_url=shield_url, shield_acquired=shield_acquired)
checked_locations = multisave.get("location_checks", {}).get((tracked_team, tracked_player), set())
return render_template("genericTracker.html",
inventory=inventory,
player=tracked_player, team=tracked_team, room=room, player_name=player_name,
checked_locations= checked_locations, not_checked_locations = set(locations[tracked_player])-checked_locations)
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=30) # update every 30 seconds
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getTracker(tracker: UUID):
room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, \
player_small_key_locations, precollected_items = get_static_room_data(room)
player_small_key_locations, precollected_items, games = get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1)}
for teamnumber, team in enumerate(names)}
@@ -500,26 +454,26 @@ def getTracker(tracker: UUID):
else:
multisave = {}
if "hints" in multisave:
for key, hintdata in multisave["hints"]:
for hint in hintdata:
hints[key[0]].add(Hint(*hint))
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
for (team, player), locations_checked in multisave.get("location_checks", {}):
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
player_locations = locations[player]
if precollected_items:
precollected = precollected_items[player]
for item_id in precollected:
attribute_item(inventory, team, player, item_id)
for location in locations_checked:
if (location, player) not in locations or location not in player_location_to_area[player]:
if location not in player_locations or location not in player_location_to_area[player]:
continue
item, recipient = locations[location, player]
item, recipient = player_locations[location]
attribute_item(inventory, team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
for (team, player), game_state in multisave.get("client_game_state", []):
if game_state:
for (team, player), game_state in multisave.get("client_game_state", {}).items():
if game_state == 30:
inventory[team][player][106] = 1 # Triforce
group_big_key_locations = set()
@@ -538,7 +492,7 @@ def getTracker(tracker: UUID):
for player, name in enumerate(names, 1):
player_names[(team, player)] = name
long_player_names = player_names.copy()
for (team, player), alias in multisave.get("name_aliases", []):
for (team, player), alias in multisave.get("name_aliases", {}).items():
player_names[(team, player)] = alias
long_player_names[(team, player)] = f"{alias} ({long_player_names[(team, player)]})"

View File

@@ -1,13 +1,14 @@
import json
import zlib
import zipfile
import logging
import lzma
import json
import base64
import MultiServer
from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, select
from pony.orm import flush, select
from WebHostLib import app, Seed, Room, Patch
from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml
accepted_zip_contents = {"patches": ".apbp",
"spoiler": ".txt",
@@ -30,7 +31,7 @@ def uploads():
flash('No selected file')
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
patches = set()
slots = set()
spoiler = ""
multidata = None
with zipfile.ZipFile(file, 'r') as zfile:
@@ -40,9 +41,28 @@ def uploads():
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
elif file.filename.endswith(".apbp"):
splitted = file.filename.split("/")[-1][3:].split("P", 1)
player_id, player_name = splitted[1].split(".")[0].split("_")
patches.add(Patch(data=zfile.open(file, "r").read(), player_name=player_name, player_id=player_id))
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)", 500
metadata = yaml_data["meta"]
slots.add(Slot(data=data, player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="A Link to the Past"))
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
slots.add(Slot(data=data, player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="Minecraft"))
elif file.filename.endswith(".zip"):
# Factorio mods needs a specific name or they do no function
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-")
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Factorio"))
elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
elif file.filename.endswith(".archipelago"):
@@ -54,11 +74,11 @@ def uploads():
else:
multidata = zfile.open(file).read()
if multidata:
commit() # commit patches
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=session["_id"])
commit() # create seed
for patch in patches:
patch.seed = seed
flush() # commit slots
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=session["_id"])
flush() # create seed
for slot in slots:
slot.seed = seed
return redirect(url_for("viewSeed", seed=seed.id))
else:
@@ -72,7 +92,7 @@ def uploads():
raise
else:
seed = Seed(multidata=multidata, owner=session["_id"])
commit() # place into DB and generate ids
flush() # place into DB and generate ids
return redirect(url_for("viewSeed", seed=seed.id))
else:
flash("Not recognized file format. Awaiting a .multidata file.")

159
WebUI.py
View File

@@ -1,159 +0,0 @@
import http.server
import logging
import json
import typing
import socket
import socketserver
import threading
import webbrowser
import asyncio
from functools import partial
from NetUtils import Node
from LttPClient import Context
import Utils
class WebUiClient(Node, logging.Handler):
loader = staticmethod(json.loads)
dumper = staticmethod(json.dumps)
def __init__(self):
super(WebUiClient, self).__init__()
self.manual_snes = None
@staticmethod
def build_message(msg_type: str, content: typing.Union[str, dict]) -> dict:
return {'type': msg_type, 'content': content}
def emit(self, record: logging.LogRecord) -> None:
self.broadcast_all({"type": record.levelname.lower(), "content": str(record.msg)})
def send_chat_message(self, message):
self.broadcast_all(self.build_message('chat', message))
def send_connection_status(self, ctx: Context):
asyncio.create_task(self._send_connection_status(ctx))
async def _send_connection_status(self, ctx: Context):
cache = Utils.persistent_load()
cached_address = cache.get("servers", {}).get("default", None)
server_address = ctx.server_address if ctx.server_address else cached_address if cached_address else None
self.broadcast_all(self.build_message('connections', {
'snesDevice': ctx.snes_attached_device[1] if ctx.snes_attached_device else None,
'snes': ctx.snes_state,
'serverAddress': server_address,
'server': 1 if ctx.server is not None and not ctx.server.socket.closed else 0,
}))
def send_device_list(self, devices):
self.broadcast_all(self.build_message('availableDevices', {
'devices': devices,
}))
def poll_for_server_ip(self):
self.broadcast_all(self.build_message('serverAddress', {}))
def notify_item_sent(self, finder, recipient, item, location, i_am_finder: bool, i_am_recipient: bool,
item_is_unique: bool = False):
self.broadcast_all(self.build_message('itemSent', {
'finder': finder,
'recipient': recipient,
'item': item,
'location': location,
'iAmFinder': int(i_am_finder),
'iAmRecipient': int(i_am_recipient),
'itemIsUnique': int(item_is_unique),
}))
def notify_item_found(self, finder: str, item: str, location: str, i_am_finder: bool, item_is_unique: bool = False):
self.broadcast_all(self.build_message('itemFound', {
'finder': finder,
'item': item,
'location': location,
'iAmFinder': int(i_am_finder),
'itemIsUnique': int(item_is_unique),
}))
def notify_item_received(self, finder: str, item: str, location: str, item_index: int, queue_length: int,
item_is_unique: bool = False):
self.broadcast_all(self.build_message('itemReceived', {
'finder': finder,
'item': item,
'location': location,
'itemIndex': item_index,
'queueLength': queue_length,
'itemIsUnique': int(item_is_unique),
}))
def send_hint(self, finder, recipient, item, location, found, i_am_finder: bool, i_am_recipient: bool,
entrance_location: str = None):
self.broadcast_all(self.build_message('hint', {
'finder': finder,
'recipient': recipient,
'item': item,
'location': location,
'found': int(found),
'iAmFinder': int(i_am_finder),
'iAmRecipient': int(i_am_recipient),
'entranceLocation': entrance_location,
}))
def send_game_info(self, ctx: Context):
self.broadcast_all(self.build_message('gameInfo', {
'clientVersion': Utils.__version__,
'hintCost': ctx.hint_cost,
'checkPoints': ctx.check_points,
'forfeitMode': ctx.forfeit_mode,
'remainingMode': ctx.remaining_mode,
}))
def send_location_check(self, ctx: Context, last_check: str):
self.broadcast_all(self.build_message('locationCheck', {
'totalChecks': len(ctx.locations_checked),
'hintPoints': ctx.hint_points,
'lastCheck': last_check,
}))
web_thread = None
PORT = 5050
class RequestHandler(http.server.SimpleHTTPRequestHandler):
def log_request(self, code='-', size='-'):
pass
def log_message(self, format, *args):
pass
def log_date_time_string(self):
pass
Handler = partial(RequestHandler,
directory=Utils.local_path("data", "web", "public"))
def start_server(socket_port: int, on_start=lambda: None):
global web_thread
try:
server = socketserver.TCPServer(("", PORT), Handler)
except OSError:
# In most cases "Only one usage of each socket address (protocol/network address/port) is normally permitted"
import logging
# If the exception is caused by our desired port being unavailable, assume the web server is already running
# from another client instance
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex(('localhost', PORT)) == 0:
logging.info("Web server is already running in another client window.")
webbrowser.open(f'http://localhost:{PORT}?port={socket_port}')
return
# If the exception is caused by something else, report on it
logging.exception("Unable to bind port for local web server. The CLI client should work in all cases.")
else:
print("serving at port", PORT)
on_start()
web_thread = threading.Thread(target=server.serve_forever).start()

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 B

View File

@@ -0,0 +1 @@
{"stone-furnace":{"smelting":true},"steel-furnace":{"smelting":true},"electric-furnace":{"smelting":true},"assembling-machine-1":{"crafting":true,"basic-crafting":true,"advanced-crafting":true},"assembling-machine-2":{"basic-crafting":true,"crafting":true,"advanced-crafting":true,"crafting-with-fluid":true},"assembling-machine-3":{"basic-crafting":true,"crafting":true,"advanced-crafting":true,"crafting-with-fluid":true},"oil-refinery":{"oil-processing":true},"chemical-plant":{"chemistry":true},"centrifuge":{"centrifuging":true},"rocket-silo":{"rocket-building":true},"character":{"crafting":true}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 34 KiB

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