Compare commits

...

390 Commits
0.3.1 ... 0.3.4

Author SHA1 Message Date
Fabian Dill
db5b7e5db9 Core: update version 2022-08-05 14:32:09 +02:00
black-sliver
7c808bb03b SMZ3: Fix Swamp Palace Entrace for minimal accessibility 2022-08-05 14:29:36 +02:00
black-sliver
530b6cc360 SMZ3: FixJunkFillGT making invalid placements 2022-08-05 14:29:22 +02:00
Fabian Dill
95012c004f Subnautica: update docs with resume info (#853)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: strotlog <49286967+strotlog@users.noreply.github.com>
2022-08-05 14:23:21 +02:00
Fabian Dill
59918b9dbc Core: patch stream_input to ignore non-parsable input (such as EOF encoded as 0xff) (#854) 2022-08-03 14:53:14 +02:00
alwaysintreble
b47cca4515 HK: Add bug report link (#824)
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-08-03 14:41:27 +02:00
CaitSith2
5f27019855 Add an optional path to factorio server-settings.json (#851)
* Add an optional path to factorio server-settings.json

* factorio: changes

* use forward slashs in host.yaml going forward.  (works on all OSes.)
* comment out the host.yaml server_settings option.
* assume that server_settings is NOT provided and explicitly check for its existence in factorio_client.
2022-08-01 14:57:30 -07:00
NewSoupVi
0b228834c2 The Witness: Logic fix (unbeatable seed) (#850) 2022-08-01 20:09:34 +02:00
Fabian Dill
57979b9287 WebHost: update flask (#804) 2022-08-01 12:41:15 +02:00
CaitSith2
4b85000960 Fixed a crafting category bug related to fluids. (#848) 2022-07-31 14:01:39 -07:00
Zach Parks
d1f34d088b WebHost: Add links to "Setup Guides" in Supported Games page (#847)
* WebHost: Add links to "Setup Guides" in Supported Games page

* Remove a hanging console.log() I left in
2022-07-31 11:17:26 -04:00
alwaysintreble
3bc9392e5b Core: have generation print plando settings as string instead of numbers (#843)
* have generation print plando settings as string instead of numbers

* Change to __str__

* Make to_string not a class method

* Suggested fix

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

* Fix the fix

* Better quotes

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-07-31 12:02:36 +02:00
lordlou
75165803a0 Sm smz3 create item fix (#844) 2022-07-31 11:08:41 +02:00
lordlou
afc9c772be Sm broken start location fix (#841)
* - fixed basepatches application order breaking (at least) starting location
2022-07-30 18:42:02 +02:00
PoryGone
07450bb83d Migrate DKC3 to APDeltaPatch (#838)
* Add DKC3 to APDeltaPatch

* Undo unintended commit

* More undoing

* Remove else clause
2022-07-29 01:51:22 +02:00
Fabian Dill
2ff7e83ad9 WebHost: make a deeply buried if tree for games a bit more automatic 2022-07-29 01:47:19 +02:00
black-sliver
d817fdcfdb Doc: move Running from source from wiki to docs (#797)
* Doc: move "Running from source" from wiki to docs/

* Doc: update links and reformat running from source

* Doc: implement suggestions in "Running from source"

thanks @alwaysintreble

* Doc: update link to "Running from source"

also link docs/ folder

* Doc: Running from source: Apply suggestions from code review

Co-authored-by: KonoTyran <Kono.Tyran@gmail.com>

Co-authored-by: KonoTyran <Kono.Tyran@gmail.com>
2022-07-29 01:18:59 +02:00
PoryGone
f3d966897f Prevent Krematoa Crash (#832)
* Prevent Krematoa Crash, add crash robustness

* Remove print statements

* Don't remove ctx.rom if save file dies

* Consolidate logic for readability
2022-07-29 01:13:00 +02:00
Jarno
9acaf1c279 [Docs] Further explained the mythical InvalidPacket (#828)
* [Docs] Further explained the mythical `InvalidPacket`

* Fixed header category

* Update docs/network protocol.md

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

* Update docs/network protocol.md

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

* Apply suggestions from code review

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

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-07-29 01:11:52 +02:00
NewSoupVi
fd6a0b547f Witness: Fatal logic bug fix (#837)
* Renamed some event items

* Fatal logic bug: Door panels did not check their symbol items
2022-07-28 23:43:35 +02:00
lordlou
c02f355479 Smz3 no progression gt fix (#818) 2022-07-28 12:04:48 +02:00
black-sliver
7d9203ef84 CI: update SNI to 0.0.82 2022-07-28 07:55:53 +02:00
Fabian Dill
e849e4792d WebHost: games played per day plot per game on stats page (#827)
* WebHost: generate stats page palette for maximum hue difference between neighbours.

* WebHost: add per game played stats
2022-07-27 23:36:20 +02:00
Fabian Dill
4565b3af8d DKC3: fix missing default options in Utils.py 2022-07-27 23:34:14 +02:00
Fabian Dill
e5b868e0e9 WebHost: fix 30 days cutoff for stats (#826) 2022-07-27 23:09:40 +02:00
Fabian Dill
489450d3fa SNIClient: fix program not exiting if SNI does not exist nor is running 2022-07-27 22:45:53 +02:00
Fabian Dill
73afab67c8 LttP: fix deprecated use of isSet() (#831) 2022-07-27 22:21:06 +02:00
SoldierofOrder
c61f77029b SC2 docs: Extensive reworks and rewordings. (#809) 2022-07-26 16:53:30 +02:00
Fabian Dill
79702aba65 WebHost: flask caching did a rename 2022-07-26 09:53:18 +02:00
strotlog
1e366ff66f SM: smoother co-op, basepatch internal improvements (#793)
* SM: remote touch instantly, pull ips refactor and symbols

* SM: remove hard-coded ROM address writes

* SM: Full length player table, incl. receive-only player ids

+ apply PR feedback (correct graphic offset, readable data file paths)
2022-07-26 09:43:39 +02:00
Fabian Dill
a0482cf27e Archipidle: Fix forgotten version increment when a new item was added 2022-07-26 09:32:21 +02:00
Ludovic Marechal
288a623ab6 Update ds3 locations and items (#819)
* DS3: Add more rules to avoid softlocks, remove Path of the Dragon gesture location/item and remove useless comments

* DS3: Add more Hostile NPCs locations/items

* DS3: Add missing key items to the key items list
2022-07-26 09:31:16 +02:00
Alchav
3b2037a2d4 HK - focus location (#778) 2022-07-25 22:19:07 +02:00
Fabian Dill
ce536fa3ac Subnautica: fix Multipurpose Room not acquirable in valuable item pool
BaseRoomFragment doesn't exist in vanilla, so when valuable item pool marked it as scannable in vanilla location it did not work, as it's technically BaseRoom
BaseRoom is also required to install other modules into, modules that are already marked as useful, so logically if it's required for other useful stuff it should also be marked as useful
By switching from Fragment to non-fragment one now needs 1 out of 2 instead of 2 out of 2 items, which I consider a plus as well.
2022-07-25 22:17:42 +02:00
PoryGone
41883e44e7 DKC3 - Logic Softlock Fix (#817)
* Add two locations to Trade Sequence List

* Remove trace sequence locations from ROM data dict
2022-07-25 21:34:31 +02:00
Yussur Mustafa Oraji
c3ff201b90 sm64ex: Various Features (#790)
* sm64ex: Course and Secret Randomizer

* sm64ex: Allow higher star door costs, raise minimum amount of stars, deprecate ExtraStars

* sm64ex: Support setting MIPS costs

* sm64ex: Safeguard MIPS Costs
2022-07-25 18:39:31 +02:00
espeon65536
e6635cdd77 OOT updates (#821)
* oot: remove all escape characters in LogicTricks.py

* only attempt to connect to client once

* oot: don't kill player outside ToT or in market entrance
fixed camera makes the game crash outside ToT. added market entrance to be safe, it doesn't matter if you don't die there
2022-07-25 02:07:22 +02:00
NewSoupVi
cfc9d79c79 The Witness: Small changes in response to beta tests (#801)
* Option order and better tooltip

* Logic fix: Hedge Laser requires access to all Hedges

* Add item groups: Lasers, Symbols, Doors

* Update worlds/witness/items.py

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

* Comment for clarity

* Logic fix

* Another logic fix

Co-authored-by: metzner <unconfigured@null.spigotmc.org>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-07-23 12:42:14 +02:00
lordlou
fe2c355739 Sm beam door speedkeep fun accessibility (#785)
added speedkeep option
now forces accessibility to "minimal" instead of (to be deprecated) "item" when "fun" settings is used
2022-07-22 09:44:58 +02:00
alwaysintreble
04c3429839 LttP: Fix scam options (#806) 2022-07-22 00:04:41 -05:00
PoryGone
cabbe0aaf6 Donkey Kong Country 3 Implementation (#798)
* Baseline patching and logic for DKC3

* Client can send, but not yet receive

* Alpha Test Baseline

* Bug Fixes and Starting Lives Option

* Finish BBH, add world hints

* Add music shuffle

* Boomer Costs Text

* Stubbed in Collect behaviour

* Adjust Gyrocopter option

* Add Bonus Coin junk replacement and tracker support

* Delete bad logs

* Undo host.yaml change

* Refactored SNIClient

* Make Swanky Free

* Fix Typo

* Undo SNIClient run_game hack

* Fix Typo

* Remove Bosses from Level Shuffle

* Remove duplicate kivy Data

* Add DKC3 Docs and increment Data version

* Remove dead code

* Fix mislabeled region

* Add Dark Souls 3 to README

* Always force Cog on Rocket Rush Flag

* Fix Single Ski lock and too many DK Coins

* Update Retroarch version number

* Don't send DKC3 through LttP Adjuster

* Comment Location ROM Table

* Change ROM Hash prefix to D3

* Remove redundant constructor

* Add ROM Change Safeguards

* Properly mark WRAM accesses

* Remove outdated region connect

* Fix syntax error

* Fix Game description

* Fix SNES Bank Access

* Add isso_setup for DKC3

* Double Quote strings

* Escape single quotes I guess
2022-07-22 00:02:25 -05:00
Jolteon0163
a7787d87f9 Add to the ArchipIDLE items list (#807)
* Add to the ArchipIDLE items list

* Update Items.py

* Update Items.py
2022-07-21 18:08:07 -04:00
KonoTyran
79b851189f HK - Fix typos in option names
Fixed max charm and max geo cost display names.
2022-07-21 09:57:10 -07:00
Fabian Dill
9e972eafb2 Subnautica: Add DeathLink (#803) 2022-07-21 08:39:34 -05:00
Fabian Dill
53a995372f Subnautica: add missed PDA 2022-07-21 10:08:19 +02:00
Fabian Dill
17351021b3 Factorio: update rcon lib 2022-07-20 22:29:51 +02:00
Ludovic Marechal
8ff2c1b6f3 DS3: Add the Dark Souls 3 World into Archipelago (#769) 2022-07-20 12:48:14 +02:00
strotlog
45aea2c8ff ChecksFinder: Linux support via wine (#795)
* ChecksFinder: Linux support via wine

* ChecksFinder: account for custom $WINEPREFIX

* ChecksFinder: wine detection
2022-07-19 07:44:04 +02:00
Fabian Dill
9f5e40283a WebHost: reduce server uptime (#794)
* WebHost: attempt to improve wording of server resume
* WebHost: reduce default room timeout to 2 hours


Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-07-18 21:10:29 +02:00
lordlou
025309ec64 SMZ3: Pedestal hint (#792)
* - fixed missing pedestal and tablets hint text for foreign items (was "Don't waste yout time!", is now "A small victory!")

- small precision to SMZ3 and SM docs about "What does another world's item look like in Super Metroid"
2022-07-17 19:40:23 -05:00
NewSoupVi
bd4850b2b5 The Witness 0.3.4 features (#780)
New options:

Shuffle Doors: Many doors in the game will open on their own upon receiving an item ("key").
Variant - Shuffle Door/Control Panels: Many panels in the game that open doors or control devices in the world will be off until receiving their respective item ("key").
Shuffle Lasers: Lasers no longer activate by solving the laser panel, instead you will get an item that activates the laser.
Shuffle Symbols: Now that there is something else to shuffle (doors / door panels), you can turn off Symbol Rando.
Shuffle Postgame (replaces "Shuffle Hard"): The randomizer will now determine by your settings which panels are in the "postgame" - Meaning they can only be accessed after you can complete your win condition anyway.
2022-07-17 12:56:22 +02:00
Fabian Dill
472e114fb9 Final Fantasy: fix outdated advancement flag 2022-07-17 11:19:00 +02:00
espeon65536
828bcb1266 OoT: Fix gerudo_fortress on normal (#784) 2022-07-16 13:00:00 -05:00
t3hf1gm3nt
9897f4eb4b LTTP: Yaml Update (#765)
removes vendor option from hints, adds scam setting, and adds P option to shop shuffle.
2022-07-16 12:56:23 -05:00
Fabian Dill
e1ef820184 Subnautica: add creature scans 2022-07-16 19:54:55 +02:00
lordlou
b3ad766680 SMZ3: Item link support (#756)
* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* Fixed multiworld support patch not working with VariaRandomizer's

Added stage_fill_hook to set morph first in progitempool
Added back VariaRandomizer's standard patches

* + added missing files from variaRandomizer project

* + added missing variaRandomizer files (custom sprites)

+ started integrating VariaRandomizer options (WIP)

* Some fixes for player and server name display

- fixed player name of 16 characters reading too far in SM client
- fixed 12 bytes SM player name limit (now 16)
- fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO)
- request: temporarly changed default seed names displayed in SM main menu to OWTCH

* Fixed Goal completion not triggering in smClient

* integrated VariaRandomizer's options into AP (WIP)

- startAP is working
- door rando is working
- skillset is working

* - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off")

* skillset are now instanced per player instead of being a singleton class

* RomPatches are now instanced per player instead of being a singleton class

* DoorManager is now instanced per player instead of being a singleton class

* - fixed the last bugs that prevented generation of >1 SM world

* fixed crash when no skillset preset is specified in randoPreset (default to "casual")

* maxDifficulty support and itemsounds removal

- added support for maxDifficulty
- removed itemsounds patch as its always applied from multiworld patch for now

* Fixed bad merge

* Post merge adaptation

* fixed player name length fix that got lost with the merge

* fixed generation with other game type than SM

* added default randoPreset json for SM in playerSettings.yaml

* fixed broken SM client following merge

* beautified json skillset presets

* Fixed ArchipelagoSmClient not building

* Fixed conflict between mutliworld patch and beam_doors_plms patch

- doorsColorsRando now working

* SM generation now outputs APBP

- Fixed paths for patches and presets when frozen

* added missing file and fixed multithreading issue

* temporarily set data_version = 0

* more work

- added support for AP starting items
- fixed client crash with gamemode being None
- patch.py "compatible_version" is now 3

* commited missing asm files

fixed start item reserve breaking game (was using bad write offset when patching)

* Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it).

fixed crash in SMClient when loosing connection to SNI

* fixed No Energy Item missing its ID

fixed Plando

* merge post fixes

* fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color)

* fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses)

* fixed start item x-ray HUD display

* Fixed start items being sent by the server (is all handled in ROM)

Start items are now not removed from itempool anymore
Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though.
Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified

* fixed settings that could be applied to any SM players

* fixed auth to server only using player name (now does as ALTTP to authenticate)

* - fixed End Credits broken text

* added non SM item name display

* added all supported SM options in playerSettings.yaml

* fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region

did some cleaning (mainly reverts on unnecessary core classes

* minor setting fixes and tweaks

- merged Area and lightArea settings
- made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating
- fixed inverted layoutPatch setting

* added option start_inventory_removes_from_pool

fixed option names formatting
fixed lint errors
small code and repo cleanup

* Hopefully fixed ROR2 that could not send any items

* - fixed missing required change to ROR2

* fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum)

* fixed typo with doors_colors_rando

* fixed checksum

* added custom sprites for off-world items (progression or not)

the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu

* - added missing change following upstream merge

- changed patch filename extension from apbp to apm3 so patch can be used with the new client

* added morph placement options: early means local and sphere 1

* fixed failing unit tests

* - fixed broken custom_preset options

* - big cleanup to remove unnecessary or unsupported features

* - more cleanup

* - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips

- small cleanup

* - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch)

* fixed g4_skip patch that can be not applied if hud is enabled

* - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette)

* - updated basepatch to reflect g4_skip removal

- moved more asm files to SMBasepatch project

* - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed)

* fixed wrong path if using built as exe

* - cleaned exposed maxDifficulty options

- removed always enabled Knows

* Merged LttPClient and SMClient into SNIClient

* added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service

* small doc precision

* - added death_link support

- fixed broken Goal Completion
- post merge fix

* - removed now useless presets

* - fixed bad internal mapping with maxDiff

- increases maxDiff if only Bosses is preventing beating the game

* - added support for lowercase custom preset sections (knows, settings and controller)

- fixed controller settings not applying to ROM

* - fixed death loop when dying with Door rando, bomb or speed booster as starting items

- varia's backup save should now be usable (automatically enabled when doing door rando)

* -added docstring for generated yaml

* fixed bad merge

* fixed broken infinity max difficulty

* commented debug prints

* adjusted credits to mark progression speed and difficulty as Non Available

* added support for more than 255 players (will print Archipelago for higher player number)

* fixed missing cleanup

* added support for 65535 different player names in ROM

* fixed generations failing when only bosses are unreachable

* - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish

* fixed failling generations when using 'fun' settings

Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings

* fixed debug logger

* removed unsupported "suits_restriction" option

* fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP)

* - fixed deathlink emptying reserves

- added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves

* - merged death_link and death_link_survive options

* fixed death_link

* added a fallback default starting location instead of failing generation if an invalid one was chosen

* added Nothing and NoEnergy as hint blacklist

added missing NoEnergy as local items and removed it from progression

* - enabled local item dialog boxes for dungeon and keycard items when keysanity is used

* - fixed ItemLink support

* fixed shops sending checks

* Added get_filler_item_name() returning a random junk item

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2022-07-16 12:47:26 -05:00
Fabian Dill
74b19dc1f5 WebHost: cleanup generate and hopefully fix SQL concurrency problems 2022-07-16 19:44:29 +02:00
Fabian Dill
449bc93307 Rogue Legacy: obliterate any outdated remnants before installer adds new files 2022-07-16 19:40:59 +02:00
Fabian Dill
622af17705 MultiServer: make !hint prefer non-local 2022-07-16 12:19:24 +02:00
Fabian Dill
a42f7f99fe Factorio: specify rcon version 2022-07-16 01:31:20 +02:00
black-sliver
3c6bd555b4 doc: add style guide (#746)
* doc: add style guide

* doc: style guide for python and markdown

* doc: consistent use of periods and explicit double quotes in style guide

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

* doc: better define string style in style guide

* doc: add format string literals to style guide

* doc: add HTML, CSS and JS to style guide

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-07-15 23:52:35 +02:00
Rome Reginelli
a4211d5f11 Improve Risk of Rain 2 docs (#770)
* Improve Risk of Rain 2 docs

* RoR2: clarify custom item weight settings

* Update worlds/ror2/docs/en_Risk of Rain 2.md

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-07-15 17:24:40 -04:00
Vale
090c5bcf00 RoR2: FinalStageDeath (#766)
Added a YAML option for 'FinalStageDeath', a toggle for 'death on the final boss stage counts as a win'. Defaults to on.

Co-authored-by: Vale <58179315+DelosIX@users.noreply.github.com>
2022-07-15 17:21:36 -04:00
alwaysintreble
82850d7f66 Ror2: reduce locations to 250 and mark legendary items as useful (#776)
* reduce total locations to 250

* minor styling cleanup. mark legendary items as useful

* 😡

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

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-07-15 17:19:36 -04:00
Yussur Mustafa Oraji
86112351a6 sm64ex: Adapt area_connections slotdata Format (#767) 2022-07-15 20:04:26 +02:00
black-sliver
ce789d1e3e SoE: texts, energy core, fragments, useful (#777)
* fix missing fields in custom prog balancing option
* fix typos and pep8
* update and implement pyevermizer 0.41.3
  * allow randomizing energy core
  * add energy core fragments (turn in at Prof. Ruffleberg)
  * rename some items to avoid confusion
  * differentiate between progression and useful
* remove obsolete 'Bazooka' group
* don't add items to the pool that get removed
2022-07-15 18:01:07 +02:00
Fabian Dill
73fb1b8074 Subnautica: updates (#759)
* Subnautica: add more goals

* Subnautica: fix wrongly positioned Databox

* Subnautica: allow techs to remain vanilla

* Subnautica: make zipimport compatible

* Subnautica: force two Seaglide fragments into local sphere 1
2022-07-15 17:41:53 +02:00
black-sliver
8e15fe51b6 Put common options first (#774)
* this applies to yaml and webhost
* this allows overwriting common options from the world
2022-07-15 06:54:29 +02:00
Fabian Dill
aa954b776d MultiServer: add /status and allow status command to dynamically filter for Tags 2022-07-15 02:52:26 +02:00
jsd1982
76f6eb1434 SNIClient: update default SNI port from 8080 to 23074 2022-07-15 02:51:36 +02:00
Yussur Mustafa Oraji
e38308bac3 sm64ex: Allow setting Big Star Door requirements (#773)
* sm64ex: Allow setting Big Star Door requirements

* sm64ex: Lower requirements for StarsToFinish
2022-07-14 18:37:14 +02:00
SoldierofOrder
e804f592de SC2: Windows ".dll missing" fix and fix for finding SC2 install automatically (#721) 2022-07-14 09:51:00 +02:00
Fabian Dill
6e0a0c5c4a Core: skip second sanity check when pushing an item into a location (-O) (#745) 2022-07-14 09:46:03 +02:00
alwaysintreble
122590fc68 lttp: move open pyramid to new options system (#762) 2022-07-14 09:39:53 +02:00
lordlou
c806366469 Sm comeback too strict (#755) 2022-07-14 09:37:45 +02:00
Zach Parks
0d3bd6e2e8 gitignore general Windows/macOS files (#763) 2022-07-10 19:24:07 +00:00
Fabian Dill
beac0b1acd Requirements: update some modules 2022-07-10 19:37:50 +02:00
Bicoloursnake
1cc9c7a469 Doc: Add english mac guide (running from source) (#744)
* Create RunFromSourceGuideForMac.md

* Update and rename RunFromSourceGuideForMac.md to docs/RunFromSourceGuideForMac.md

* Clarified the source code download.

* Rename docs/RunFromSourceGuideForMac.md to worlds/generic/docs/RunFromSourceGuideForMac.md

* Update __init__.py

* Noted the case where a user might want EnemizerCLI

* Updated document to reflect requested changes

Updated to reflect the requested changes as well as including some information on virtual environments.

* Added Capital Letters to SNIClient.py

* Reworked Document Structure

Numeric order of lists now makes sense and changed the virtual environment section to match Archipelago tradition.

* Update __init__.py

* Minor Changes for clarity's sake

* Renamed file to make webhost happy

* Changed mac guide filename
2022-07-10 03:16:41 +02:00
CaitSith2
17db0805a7 Allow potentially all rocket-part ingredients to be fluids. (#753) 2022-07-09 12:35:38 +02:00
CaitSith2
2f53972c85 Factorio: fix accidental removal of fluids from make_balanced_recipe (#754) 2022-07-08 15:35:33 +02:00
black-sliver
9ac780102e Subnautica: display item_pool as Item Pool on the settings page 2022-07-07 21:22:24 +02:00
Fabian Dill
60b80083e0 LttP: fix shop inventory corruption in upgrade fairy 2022-07-07 13:25:17 +02:00
alwaysintreble
8597b04c41 WebHost: Advanced guide cleanup (#725)
* advanced yaml cleanup

* Update advanced_settings_en.md

* i hate this game now

* formatting reverting
2022-07-06 16:06:32 -05:00
Fabian Dill
6a60c46a99 Subnautica: fix generation crash on valuable item pool (#739) 2022-07-06 17:04:22 -04:00
Fabian Dill
5c2163a1a7 WebHost: fix comment typo 2022-07-06 22:32:33 +02:00
Hussein Farran
a49bcd618d Dev Docs: Add SA2B and SC2 to network diagram (#719)
* Add SA2B and SC2 to network diagram

* Remove jpg version of image.

* Fix png of image... Github web editor borked it

* Update network diagram.svg

* We're back to light mode, friends.

Use SVG and JPG that are valid and let you zoom in properly.
2022-07-06 16:12:53 -04:00
Doug Hoskisson
d76b41afe7 RL: Rename Rogue Legacy Folder (#452)
* rename rogue legacy
"`rogue-legacy` is not a valid python module name"

* revert rename of the documentation file
2022-07-06 14:18:28 -05:00
Sunny Bat
ab2b635a77 Update Raft for Final Chapter (#724) 2022-07-06 04:37:08 +02:00
strotlog
7072c7bd45 docs: fix 2 URLs (#738)
* URL of image in Alttp ES tutorial
* Link to RA download in SMZ3 EN tutorial
2022-07-04 10:49:25 +02:00
Alchav
530c5500c3 Break out of fill loop if locations is empty (#690) 2022-07-03 17:11:11 +02:00
Daniel Grace
8870b577d0 Hollow Knight June 2022 Updates (#720)
This is a combined PR for assorted Hollow Knight updates for June 2022 that have cleared testing. It supersedes any HK-exclusive PRs open by myself or @Alchav unless stated otherwise.

Summary of changes below:

 * Implement Split Claw, Split Cloak, Split Superdash, Randomize Nail, Randomize Focus, Randomize Swim and Elevator 
 * Pass options (@Alchav)
 * Add support for Deathlink with three different modes (@dewiniaid)
 * Add customizable additional shop slots per-shop (@Alchav) and overall (@dewiniaid)
 * Overhaul shop cost output to be more generic and account for all locations with standard costs (such as Stag Stations, Cornifer, and Divine) (@dewiniaid)
 * Add "CostSanity", allowing random prices using any cost type to be chosen for any location with a cost. (e.g. a Stag station requiring 15 grubs to obtain an item)
 * Item classification fixes (Map and Journal items are fillter, Mask Shards/Pale Ore/Vessel Fragments are useful) (@Alchav)
 * Fix Ijii -> Jiji (@Alchav )
 * General code quality updates

The above changes are only for the HK world.
2022-07-03 17:10:10 +02:00
Colin Lenzen
7d85ab471a [Timespinner] Rename flag and add tiered loot settings (#699) 2022-07-03 17:05:44 +02:00
Fabian Dill
3205cbf932 Generate: convert plando settings to an IntFlag with error reporting for unknown plando names (#735) 2022-07-03 14:11:52 +02:00
Fabian Dill
b9fb4de878 BaseClasses: make ItemClassification properties faster 2022-07-02 13:56:35 +02:00
Jarno Westhof
bcd7096e1d [The Witness] Update data_version as it was forgotten for 0.3.3
# Conflicts:
#	worlds/witness/docs/setup_en.md
2022-07-02 12:19:08 +02:00
strotlog
b206f2846a SNES games: use JPN as abbreviation for Japan/Japanese 2022-07-02 12:16:15 +02:00
CaitSith2
8a8bc6aa34 Factorio: Fix impossible seeds for rocket-part recipes as well. (#733) 2022-07-01 00:40:31 +02:00
black-sliver
bce7c258c3 CI: update Enemizer to 7.0.1 2022-06-30 22:55:05 +02:00
Fabian Dill
cea7278faf LttP: now that Enemizer allows for AP rom name, rename it. (#730)
* LttP: now that Enemizer allows for AP rom name, rename it.

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

* MC: better link naming for non-windows doc

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

* MC: doc change manual forge link to index

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

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

* minor cleanup

* some rewording and reformatting.

* tighten up world definition clarity

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

* Clarify seed definition a bit better

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

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

* rename common terms to glossary

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

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

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

* Add a missing space on line 1135

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

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

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

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

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

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

* Update setup_en.md

* Update setup_en.md

* Update setup_en.md

* typo fix spaces clarification

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

* Grammar corrections, clarifications, removed redundant explanations

* Markdown syntax fix

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

* UI: add support for gtk/kde messagebox

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

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

* Add stone theme preview to world api.md

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

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

* MC: linux fixes

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

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

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

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

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

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

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

* Hollow Knight updates

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

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

* Add support for SpecialRange to player-settings pages

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

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

* Update advanced_settings_en.md

* Update Items.py

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* improve consistency

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

* fix formating on game setting in example

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

* change version

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

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* Update advanced_settings_en.md

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

* tutorials: add description for null replacement

* Update worlds/generic/docs/advanced_settings_en.md

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

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* Update worlds/generic/docs/advanced_settings_en.md

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

* Update worlds/generic/docs/advanced_settings_en.md

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

* Update worlds/generic/docs/advanced_settings_en.md

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

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

* tutorials: hardcode not generating ArchipIDLE tutorial files outside april

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

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

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

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

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

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

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

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

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

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

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

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

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

* WebHost: pin flask-caching

until https://github.com/pallets-eco/flask-caching/pull/352 is merged or fixed otherwise
2022-05-28 23:20:46 +02:00
Fabian Dill
1a0bfecb5f LttP: convert vendors hint into separate scams option 2022-05-28 20:08:06 +02:00
Felix R
5d3b4c8efd Meritous: Minor logic change (#584) 2022-05-28 00:52:14 +02:00
TheCondor07
8adc0dd7eb SC2: Fixed issue in random mission order with some missions being available too early 2022-05-27 20:53:06 +02:00
Jarno Westhof
2cb71c5352 [Timespinner] Removed backwarp from refugee camp to library from logic 2022-05-27 20:51:29 +02:00
TheCondor07
b6068f4519 SC2: Updated webhost details page 2022-05-27 18:32:33 +02:00
Fabian Dill
21a6b0143d MC: fix Bee Trap name 2022-05-26 20:49:24 -07:00
Fabian Dill
28949853f7 Setup: "ParseVersion" gives Deprecated Warning, fixing the warning. 2022-05-26 20:17:44 -07:00
Fabian Dill
65c83393bb SC2: fix copy pasta in client 2022-05-26 20:11:46 -07:00
Fabian Dill
960988ddcd WebHost: undo autoconnect link as not all browsers behave like Vivaldi. (#577)
* WebHost: undo autoconnect link as not all browsers behave like Vivaldi.

* Increase tooltip z-index

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

* Clients: fade in tooltip over 0.25 seconds

* Clients: reset slot and team when disconnecting

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

* client version 8

* MC: multiworkd -> multiworld

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

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

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

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

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

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

* fix fetching correct mod for specified version.

* add support for other java/forge versions

* fix fetching correct mod for specified version.

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

* add minecraft_versions.json to gitignore.

* remove redundant json import

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

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

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

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

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

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

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

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

* SM64: write entrances to spoiler log

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

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

* Add server_password

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

* Add Plando options to generation page.

* Remove debugging code

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

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


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

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

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

* update Minecraft bug report page to a template.

* change wording of link.

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

* HK: Allow Plando Charm costs

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

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

* just low, high, and default for range text

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

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

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

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

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

* Add allow_collect to the sample yaml.
2022-04-05 03:54:49 +02:00
Fabian Dill
f7c601b863 HK: Fix web gen
By allowing pickle to find the options
2022-04-05 02:35:55 +02:00
Fabian Dill
7315da2ccb AutoWorld: don't import __pycache__ 2022-04-05 02:26:58 +02:00
Fabian Dill
2f7f6a0b58 Setup: copy LttP yaml to build automatically 2022-04-05 02:26:41 +02:00
Chris Wilson
3f43051c35 [WebHost] Do not calculate settingHash multiple times in weighted-settings 2022-04-04 16:48:59 -07:00
Chris Wilson
535c35310d [WebHost] Fix a bug causing player-settings to fail to update the hash on JSON updates 2022-04-04 16:48:59 -07:00
Chris Wilson
8fbe6a4511 [WebHost] Only calculate settingHash once in player-settings 2022-04-04 16:48:59 -07:00
379 changed files with 38861 additions and 21191 deletions

View File

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

View File

@@ -26,7 +26,7 @@ jobs:
# build-release-macos: # LF volunteer
build-release-ubuntu1804:
runs-on: ubuntu-latest
runs-on: ubuntu-18.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -51,11 +51,11 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |

View File

@@ -34,7 +34,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
python ModuleUpdate.py --yes --force
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |
pytest test

16
.gitignore vendored
View File

@@ -77,6 +77,7 @@ MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
@@ -115,6 +116,9 @@ target/
profile_default/
ipython_config.py
# vim editor
*.swp
# SageMath parsed files
*.sage.py
@@ -151,9 +155,17 @@ dmypy.json
# Cython debug symbols
cython_debug/
#minecraft server stuff
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
#pyenv
# pyenv
.python-version
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

View File

@@ -1,12 +1,13 @@
from __future__ import annotations
import copy
from enum import Enum, unique
from enum import unique, IntEnum, IntFlag
import logging
import json
import functools
from collections import OrderedDict, Counter, deque
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
import typing # this can go away when Python 3.8 support is dropped
import secrets
import random
@@ -22,6 +23,8 @@ class Group(TypedDict, total=False):
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
local_items: Set[str]
non_local_items: Set[str]
class MultiWorld():
@@ -35,7 +38,7 @@ class MultiWorld():
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, Any]
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
itempool: List[Item]
is_race: bool = False
@@ -46,6 +49,7 @@ class MultiWorld():
local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]]
class AttributeProxy():
def __init__(self, rule):
@@ -122,7 +126,6 @@ class MultiWorld():
set_player_attr('beemizer_total_chance', 0)
set_player_attr('beemizer_trap_chance', 0)
set_player_attr('escape_assist', [])
set_player_attr('open_pyramid', False)
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0)
set_player_attr('clock_mode', False)
@@ -212,31 +215,51 @@ class MultiWorld():
for player in self.player_ids:
for item_link in self.item_links[player].value:
if item_link["name"] in item_links:
if item_link["game"] != self.game[player]:
if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
else:
if item_link["name"] in self.player_name.values():
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
item_links[item_link["name"]] = {
"players": {player: item_link["replacement_item"]},
"item_pool": set(item_link["item_pool"]),
"game": self.game[player]
"exclude": set(item_link.get("exclude", [])),
"game": self.game[player],
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", []))
}
for name, item_link in item_links.items():
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set()
local_items = set()
non_local_items = set()
for item in item_link["item_pool"]:
pool |= current_item_name_groups.get(item, {item})
for item in item_link["exclude"]:
pool -= current_item_name_groups.get(item, {item})
for item in item_link["local_items"]:
local_items |= current_item_name_groups.get(item, {item})
for item in item_link["non_local_items"]:
non_local_items |= current_item_name_groups.get(item, {item})
local_items &= pool
non_local_items &= pool
item_link["item_pool"] = pool
item_link["local_items"] = local_items
item_link["non_local_items"] = non_local_items
for group_name, item_link in item_links.items():
game = item_link["game"]
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
group["replacement_items"] = item_link["players"]
group["local_items"] = item_link["local_items"]
group["non_local_items"] = item_link["non_local_items"]
# intended for unittests
def set_default_common_options(self):
@@ -366,20 +389,14 @@ class MultiWorld():
self.state.collect(item, True)
def push_item(self, location: Location, item: Item, collect: bool = True):
if not isinstance(location, Location):
raise RuntimeError(
'Cannot assign item %s to invalid location %s (player %d).' % (item, location, item.player))
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
location.item = item
item.location = location
item.world = self # try to not have this here anymore and create it with item?
if collect:
self.state.collect(item, location.event, location)
if location.can_fill(self.state, item, False):
location.item = item
item.location = location
item.world = self # try to not have this here anymore
if collect:
self.state.collect(item, location.event, location)
logging.debug('Placed %s at %s', item, location)
else:
raise RuntimeError('Cannot assign item %s to location %s.' % (item, location))
logging.debug('Placed %s at %s', item, location)
def get_entrances(self) -> List[Entrance]:
if self._cached_entrances is None:
@@ -562,9 +579,20 @@ class MultiWorld():
return False
PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState():
additional_init_functions: List[Callable] = []
additional_copy_functions: List[Callable] = []
prog_items: typing.Counter[Tuple[str, int]]
world: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
events: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
stale: Dict[int, bool]
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld):
self.prog_items = Counter()
@@ -602,6 +630,7 @@ class CollectionState():
if new_region in rrp:
bc.remove(connection)
elif connection.can_reach(self):
assert new_region, "tried to search through an Entrance with no Region"
rrp.add(new_region)
bc.remove(connection)
bc.update(new_region.exits)
@@ -632,7 +661,8 @@ class CollectionState():
spot: Union[Location, Entrance, Region, str],
resolution_hint: Optional[str] = None,
player: Optional[int] = None) -> bool:
if not hasattr(spot, "can_reach"):
if isinstance(spot, str):
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
spot = self.world.get_location(spot, player)
@@ -643,7 +673,7 @@ class CollectionState():
spot = self.world.get_region(spot, player)
return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Set[Location] = None):
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.world.get_filled_locations()
new_locations = True
@@ -655,6 +685,7 @@ class CollectionState():
new_locations = reachable_events - self.events
for event in new_locations:
self.events.add(event)
assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event)
def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -669,7 +700,7 @@ class CollectionState():
def count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def has_group(self, item_name_group: str, player: int, count: int = 1):
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
@@ -677,7 +708,7 @@ class CollectionState():
return True
return False
def count_group(self, item_name_group: str, player: int):
def count_group(self, item_name_group: str, player: int) -> int:
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
@@ -752,7 +783,7 @@ class CollectionState():
or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool:
if self.world.retro[player]:
if self.world.retro_bow[player]:
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player)
@@ -873,7 +904,7 @@ class CollectionState():
@unique
class RegionType(int, Enum):
class RegionType(IntEnum):
Generic = 0
LightWorld = 1
DarkWorld = 2
@@ -1028,7 +1059,7 @@ class Boss():
return f"Boss({self.name})"
class LocationProgressType(Enum):
class LocationProgressType(IntEnum):
DEFAULT = 1
PRIORITY = 2
EXCLUDED = 3
@@ -1100,19 +1131,29 @@ class Location:
return "at " + self.name.replace("_", " ").replace("-", " ")
class Item():
class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
trap = 0b0100 # detrimental or entirely useless (nothing) item
skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items.
progression_skip_balancing = 0b1001 # only progression gets balanced
def as_flag(self) -> int:
"""As Network API flag int."""
return int(self & 0b0111)
class Item:
location: Optional[Location] = None
world: Optional[MultiWorld] = None
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
name: str
game: str = "Generic"
type: str = None
# indicates if this is a negative impact item. Causes these to be handled differently by various games.
trap: bool = False
# change manually to ensure that a specific non-progression item never goes on an excluded location
never_exclude = False
# item is not considered by progression balancing despite being progression
skip_in_prog_balancing: bool = False
classification: ItemClassification
# need to find a decent place for these to live and to allow other games to register texts if they want.
pedestal_credit_text: str = "and the Unknown Item"
@@ -1127,9 +1168,9 @@ class Item():
map: bool = False
compass: bool = False
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
self.name = name
self.advancement = advancement
self.classification = classification
self.player = player
self.code = code
@@ -1141,9 +1182,25 @@ class Item():
def pedestal_hint_text(self):
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
def advancement(self) -> bool:
return ItemClassification.progression in self.classification
@property
def skip_in_prog_balancing(self) -> bool:
return ItemClassification.progression_skip_balancing in self.classification
@property
def useful(self) -> bool:
return ItemClassification.useful in self.classification
@property
def trap(self) -> bool:
return ItemClassification.trap in self.classification
@property
def flags(self) -> int:
return self.advancement + (self.never_exclude << 1) + (self.trap << 2)
return self.classification.as_flag()
def __eq__(self, other):
return self.name == other.name and self.player == other.player
@@ -1367,8 +1424,6 @@ class Spoiler():
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
if self.world.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
outfile.write('Pyramid hole pre-opened: %s\n' % (
'Yes' if self.world.open_pyramid[player] else 'No'))
outfile.write('Shop inventory shuffle: %s\n' %
bool_to_text("i" in self.world.shop_shuffle[player]))
outfile.write('Shop price shuffle: %s\n' %
@@ -1442,6 +1497,19 @@ class Spoiler():
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
class Tutorial(NamedTuple):
"""Class to build website tutorial pages from a .md file in the world's /docs folder. Order is as follows.
Name of the tutorial as it will appear on the site. Concise description covering what the guide will entail.
Language the guide is written in. Name of the file ex 'setup_en.md'. Name of the link on the site; game name is
filled automatically so 'setup/en' etc. Author or authors."""
tutorial_name: str
description: str
language: str
file_name: str
link: str
authors: List[str]
seeddigits = 20
@@ -1454,4 +1522,4 @@ def get_seed(seed=None) -> int:
from worlds import AutoWorld
auto_world = AutoWorld.World
auto_world = AutoWorld.World

View File

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

View File

@@ -6,6 +6,9 @@ import sys
import typing
import time
import ModuleUpdate
ModuleUpdate.update()
import websockets
import Utils
@@ -14,13 +17,14 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
import os
logger = logging.getLogger("Client")
# without terminal we have to use gui mode
# without terminal, we have to use gui mode
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@@ -39,12 +43,14 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True
@@ -52,7 +58,7 @@ class ClientCommandProcessor(CommandProcessor):
"""List all received items"""
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
@@ -110,26 +116,57 @@ class ClientCommandProcessor(CommandProcessor):
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext():
class CommonContext:
# Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
# datapackage
# Contents in flux until connection to server is made, to download correct data for this multiworld.
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: int = ClientCommandProcessor
game = None
command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None
keep_alive_task = None
items_handling: typing.Optional[int] = None
current_energy_link_value = 0 # to display in UI, gets set by server
ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional[asyncio.Task] = None
server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0)
current_energy_link_value: int = 0 # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: str
password: typing.Optional[str]
hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str]
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
missing_locations: typing.Set[int]
checked_locations: typing.Set[int] # server state
locations_info: typing.Dict[int, NetworkItem]
# internals
# current message box through kvui
_messagebox = None
def __init__(self, server_address, password):
# server state
self.server_address = server_address
self.username = None
self.password = password
self.server_task = None
self.server: typing.Optional[Endpoint] = None
self.server_version = Version(0, 0, 0)
self.hint_cost: typing.Optional[int] = None
self.games: typing.Dict[int, str] = {}
self.hint_cost = None
self.slot_info = {}
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
@@ -144,26 +181,23 @@ class CommonContext():
self.auth = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set() # local state
self.locations_scouted: typing.Set[int] = set()
self.locations_checked = set() # local state
self.locations_scouted = set()
self.items_received = []
self.missing_locations: typing.Set[int] = set()
self.checked_locations: typing.Set[int] = set() # server state
self.locations_info: typing.Dict[int, NetworkItem] = {}
self.missing_locations = set()
self.checked_locations = set() # server state
self.locations_info = {}
self.input_queue = asyncio.Queue()
self.input_requests = 0
self.last_death_link: float = time.time() # last send/received death link on AP layer
# game state
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.player_names = {0: "Archipelago"}
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
self.slow_mode = False
self.jsontotextparser = JSONtoTextParser(self)
self.set_getters(network_data_package)
self.update_datapackage(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@@ -175,50 +209,25 @@ class CommonContext():
return len(self.checked_locations | self.missing_locations)
async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None:
await self.server.socket.close()
def reset_server_state(self):
self.auth = None
self.slot = None
self.team = None
self.items_received = []
self.locations_info = {}
self.server_version = Version(0, 0, 0)
if self.server and self.server.socket is not None:
await self.server.socket.close()
self.server = None
self.server_task = None
# noinspection PyAttributeOutsideInit
def set_getters(self, data_package: dict, network=False):
if not network: # local data; check if newer data was already downloaded
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
if local_package and local_package["version"] > network_data_package["version"]:
data_package: dict = local_package
elif network: # check if data from server is newer
if data_package["version"] > network_data_package["version"]:
Utils.persistent_store("datapackage", "latest", network_data_package)
item_lookup: dict = {}
locations_lookup: dict = {}
for game, gamedata in data_package["games"].items():
for item_name, item_id in gamedata["item_name_to_id"].items():
item_lookup[item_id] = item_name
for location_name, location_id in gamedata["location_name_to_id"].items():
locations_lookup[location_id] = location_name
def get_item_name_from_id(code: int):
return item_lookup.get(code, f'Unknown item (ID:{code})')
self.item_name_getter = get_item_name_from_id
def get_location_name_from_address(address: int):
return locations_lookup.get(address, f'Unknown location (ID:{address})')
self.location_name_getter = get_location_name_from_address
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
async def disconnect(self):
if self.server and not self.server.socket.closed:
@@ -247,6 +256,13 @@ class CommonContext():
self.password = await self.console_input()
return self.password
async def get_username(self):
if not self.auth:
self.auth = self.username
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
async def send_connect(self, **kwargs):
payload = {
'cmd': 'Connect',
@@ -266,6 +282,13 @@ class CommonContext():
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def slot_concerns_self(self, slot) -> bool:
if slot == self.slot:
return True
if slot in self.slot_info:
return self.slot in self.slot_info[slot].group_members
return False
def on_print(self, args: dict):
logger.info(args["text"])
@@ -295,7 +318,8 @@ class CommonContext():
logger.exception(e)
async def shutdown(self):
self.server_address = None
self.server_address = ""
self.username = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
@@ -305,6 +329,54 @@ class CommonContext():
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
if self.ui_task:
await self.ui_task
if self.input_task:
self.input_task.cancel()
# DataPackage
async def prepare_datapackage(self, relevant_games: typing.Set[str],
remote_datepackage_versions: typing.Dict[str, int]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
# by documentation any game can use Archipelago locations/items -> always relevant
relevant_games.add("Archipelago")
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set()
for game in relevant_games:
remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game
needed_updates.add(game)
continue
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
# no action required if local version is new enough
if remote_version > local_version:
cache_version: int = cache_package.get(game, {}).get("version", 0)
# download remote version if cache is not new enough
if remote_version > cache_version:
needed_updates.add(game)
else:
self.update_game(cache_package[game])
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
def update_game(self, game_package: dict):
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
def update_datapackage(self, data_package: dict):
for game, gamedata in data_package["games"].items():
self.update_game(gamedata)
def consume_network_datapackage(self, data_package: dict):
self.update_datapackage(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
# DeathLink hooks
@@ -330,7 +402,7 @@ class CommonContext():
}
}])
async def update_death_link(self, death_link):
async def update_death_link(self, death_link: bool):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
@@ -339,6 +411,48 @@ class CommonContext():
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]):
"""Displays an error messagebox"""
if not self.ui:
return
title = title or "Error"
from kvui import MessageBox
if self._messagebox:
self._messagebox.dismiss()
# make "Multiple exceptions" look nice
text = str(text).replace('[Errno', '\n[Errno').strip()
# split long messages into title and text
parts = title.split('. ', 1)
if len(parts) == 1:
parts = title.split(', ', 1)
if len(parts) > 1:
text = parts[1] + '\n\n' + text
title = parts[0]
# display error
self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
self.ui = TextManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self):
if sys.stdin:
# steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
else:
self.input_task = asyncio.create_task(console_loop(self), name="Input")
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
@@ -354,7 +468,6 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
async def server_loop(ctx: CommonContext, address=None):
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
return
@@ -367,12 +480,20 @@ async def server_loop(ctx: CommonContext, address=None):
logger.info('Please connect to an Archipelago server.')
return
address = f"ws://{address}" if "://" not in address else address
port = urllib.parse.urlparse(address).port or 38281
address = f"ws://{address}" if "://" not in address \
else address.replace("archipelago://", "ws://")
server_url = urllib.parse.urlparse(address)
if server_url.username:
ctx.username = server_url.username
if server_url.password:
ctx.password = server_url.password
port = server_url.port or 38281
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
@@ -381,18 +502,22 @@ async def server_loop(ctx: CommonContext, address=None):
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError:
if cached_address:
logger.error('Unable to connect to multiworld server at cached address. '
'Please use the connect button above.')
else:
logger.exception('Connection refused by the multiworld server')
except websockets.InvalidURI:
logger.exception('Failed to connect to the multiworld server (invalid URI)')
except (OSError, websockets.InvalidURI):
logger.exception('Failed to connect to the multiworld server')
except ConnectionRefusedError as e:
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except websockets.InvalidURI as e:
msg = 'Failed to connect to the multiworld server (invalid URI)'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
finally:
await ctx.connection_closed()
if ctx.server_address:
@@ -415,7 +540,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
raise
if cmd == 'RoomInfo':
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
else:
logger.info('--------------------------------')
logger.info('Room Information:')
@@ -429,33 +556,32 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if args['password']:
logger.info('Password required')
ctx.update_permissions(args.get("permissions", {}))
if "games" in args:
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
if len(args['players']) < 1:
players = args.get("players", [])
if len(players) < 1:
logger.info('No player connected')
else:
args['players'].sort()
players.sort()
current_team = -1
logger.info('Connected Players:')
for network_player in args['players']:
for network_player in players:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
# update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")
ctx.set_getters(args['data'], network=True)
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_datapackage(args['data'])
elif cmd == 'ConnectionRefused':
errors = args["errors"]
@@ -463,8 +589,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.event_invalid_game()
elif 'SlotAlreadyTaken' in errors:
raise Exception('Player slot already in use for that team')
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible')
elif 'InvalidItemsHandling' in errors:
@@ -480,8 +604,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
ctx.username = ctx.auth
ctx.team = args["team"]
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
@@ -561,7 +688,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
async def console_loop(ctx: CommonContext):
import sys
commandprocessor = ctx.command_processor(ctx)
queue = asyncio.Queue()
stream_input(sys.stdin, queue)
@@ -596,52 +722,50 @@ if __name__ == '__main__':
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "Archipelago"
game = "" # empty matches any game since 0.3.2
items_handling = 0 # don't receive any NetworkItems
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.games.get(self.slot, None)
self.game = self.slot_info[self.slot].game
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
input_task = None
if gui_enabled:
from kvui import TextManager
ctx.ui = TextManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args()
if args.url:
url = urllib.parse.urlparse(args.url)
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
args, rest = parser.parse_known_args()
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -39,6 +39,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
@@ -48,7 +49,6 @@ class FF1Context(CommonContext):
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'Final Fantasy'
self.awaiting_rom = False
self.display_msgs = True
@@ -68,14 +68,13 @@ class FF1Context(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.game = self.games.get(self.slot, None)
asyncio.create_task(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}"
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == 'PrintJSON':
print_type = args['type']
@@ -85,23 +84,35 @@ class FF1Context(CommonContext):
sending_player_id = item.player
sending_player_name = self.player_names[item.player]
if print_type == 'Hint':
msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \
f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}"
msg = f"Hint: Your {self.item_names[item.item]} is at" \
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
self._set_message(msg, item.item)
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
if sending_player_id == self.slot:
if receiving_player_id == self.slot:
msg = f"You found your own {self.item_name_getter(item.item)}"
msg = f"You found your own {self.item_names[item.item]}"
else:
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
else:
if receiving_player_id == sending_player_id:
msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}"
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
else:
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
f"{receiving_player_name}"
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
@@ -139,13 +150,13 @@ async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bo
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_name_getter(location)}")
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_name_getter(location) for location in locations_checked])
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
@@ -175,6 +186,9 @@ async def nes_sync_task(ctx: FF1Context):
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
@@ -232,14 +246,8 @@ if __name__ == '__main__':
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
input_task = None
from kvui import FF1Manager
ctx.ui = FF1Manager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
@@ -250,20 +258,12 @@ if __name__ == '__main__':
if ctx.nes_sync_task:
await ctx.nes_sync_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
parser = get_base_parser()
args, rest = parser.parse_known_args()
args = parser.parse_args()
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -5,10 +5,12 @@ import json
import string
import copy
import subprocess
import sys
import time
import random
import ModuleUpdate
ModuleUpdate.update()
import factorio_rcon
import colorama
import asyncio
@@ -118,10 +120,24 @@ class FactorioContext(CommonContext):
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.info(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
logger.debug(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
def run_gui(self):
from kvui import GameManager
class FactorioManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
base_title = "Archipelago Factorio Client"
self.ui = FactorioManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
@@ -134,7 +150,9 @@ async def game_watcher(ctx: FactorioContext):
next_bridge = time.perf_counter() + 1
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if data["slot_name"] != ctx.auth:
if not ctx.auth:
pass # auth failed, wait for new attempt
elif data["slot_name"] != ctx.auth:
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
elif data["seed_name"] != ctx.seed_name:
bridge_logger.warning(
@@ -184,7 +202,7 @@ async def game_watcher(ctx: FactorioContext):
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.info(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
@@ -326,8 +344,10 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
await asyncio.sleep(0.01)
except Exception as e:
logger.exception(e)
logger.error("Aborted Factorio Server Bridge")
logger.exception(e, extra={"compact_gui": True})
msg = "Aborted Factorio Server Bridge"
logger.error(msg)
ctx.gui_error(msg, e)
ctx.exit_event.set()
else:
@@ -343,15 +363,11 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
input_task = None
if gui_enabled:
from kvui import FactorioManager
ctx.ui = FactorioManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ctx.run_gui()
ctx.run_cli()
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
successful_launch = await factorio_server_task
if successful_launch:
@@ -367,12 +383,6 @@ async def main(args):
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
@@ -390,6 +400,7 @@ if __name__ == '__main__':
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args()
colorama.init()
@@ -400,6 +411,9 @@ if __name__ == '__main__':
factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options()
executable = options["factorio_options"]["executable"]
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings:
server_settings = os.path.abspath(server_settings)
if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
@@ -411,9 +425,10 @@ if __name__ == '__main__':
else:
raise FileNotFoundError(f"Path {executable} is not an executable file.")
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
if server_settings and os.path.isfile(server_settings):
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
else:
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
asyncio.run(main(args))
colorama.deinit()

88
Fill.py
View File

@@ -42,8 +42,16 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
for item_to_place in items_to_place:
while items_to_place:
# if we have run out of locations to fill,break out of this loop
if not locations:
unplaced_items += items_to_place
break
item_to_place = items_to_place.pop(0)
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
@@ -54,7 +62,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
for i, location in enumerate(locations):
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
# poping by index is faster than removing by content,
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
break
@@ -144,7 +152,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
for item in itempool:
if item.advancement:
progitempool.append(item)
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player].value:
localrestitempool[item.player].append(item)
@@ -166,7 +174,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED]
fill_restrictive(world, world.state, prioritylocations, progitempool)
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
if prioritylocations:
defaultlocations = prioritylocations + defaultlocations
@@ -220,15 +228,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = [location.name for location in defaultlocations]
unfilled = defaultlocations
if unplaced or unfilled:
logging.warning(
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
items_counter = Counter([location.item.player for location in world.get_locations() if location.item])
locations_counter = Counter([location.player for location in world.get_locations()])
items_counter.update([item.player for item in unplaced])
locations_counter.update([location.player for location in unfilled])
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
locations_counter = Counter(location.player for location in world.get_locations())
items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f'Per-Player counts: {print_data})')
@@ -305,29 +313,42 @@ def flood_items(world: MultiWorld) -> None:
def balance_multiworld_progression(world: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players = {player for player in world.player_ids if world.progression_balancing[player]}
balanceable_players: typing.Dict[int, float] = {
player: world.progression_balancing[player] / 100
for player in world.player_ids
if world.progression_balancing[player] > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
state = CollectionState(world)
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
checked_locations: typing.Set[Location] = set()
unchecked_locations = set(world.get_locations())
unchecked_locations: typing.Set[Location] = set(world.get_locations())
reachable_locations_count = {
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if len(world.get_filled_locations(player)) != 0
}
total_locations_count = Counter(location.player for location in world.get_locations() if not location.locked)
balanceable_players = {player for player in balanceable_players if total_locations_count[player]}
sphere_num = 1
moved_item_count = 0
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
if not location.locked
)
balanceable_players = {
player: balanceable_players[player]
for player in balanceable_players
if total_locations_count[player]
}
sphere_num: int = 1
moved_item_count: int = 0
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
@@ -357,13 +378,19 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
sphere_num += 1
if checked_locations:
# The 10% threshold can be modified for "progression balancing strength"
# right now it approximates the old 20/216 bound.
threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
reachable_locations_count)) - 0.10
logging.debug(f"Threshold: {threshold_percentage}")
balancing_players = {player for player, reachables in reachable_locations_count.items() if
item_percentage(player, reachables) < threshold_percentage and player in balanceable_players}
max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
reachable_locations_count))
threshold_percentages = {
player: max_percentage * balanceable_players[player]
for player in balanceable_players
}
logging.debug(f"Thresholds: {threshold_percentages}")
balancing_players = {
player
for player, reachables in reachable_locations_count.items()
if (player in threshold_percentages
and item_percentage(player, reachables) < threshold_percentages[player])
}
if balancing_players:
balancing_state = state.copy()
balancing_unchecked_locations = unchecked_locations.copy()
@@ -389,8 +416,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentage
for player, reachables in balancing_reachables.items()):
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
break
elif not balancing_sphere:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
@@ -402,7 +430,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
items_to_replace: typing.List[Location] = []
for player in balancing_players:
locations_to_test = unlocked_locations[player]
items_to_test = candidate_items[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
world.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
@@ -420,7 +450,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
if p < threshold_percentage:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
replaced_items = False

View File

@@ -1,12 +1,15 @@
from __future__ import annotations
import argparse
import logging
import random
import urllib.request
import urllib.parse
import typing
from typing import Set, Dict, Tuple, Callable, Any, Union
import os
from collections import Counter
import string
import enum
import ModuleUpdate
@@ -15,7 +18,7 @@ ModuleUpdate.update()
import Utils
from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoConnection
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
@@ -25,14 +28,50 @@ from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
import copy
categories = set(AutoWorldRegister.world_types)
class PlandoSettings(enum.IntFlag):
items = 0b0001
connections = 0b0010
texts = 0b0100
bosses = 0b1000
@classmethod
def from_option_string(cls, option_string: str) -> PlandoSettings:
result = cls(0)
for part in option_string.split(","):
part = part.strip().lower()
if part:
result = cls._handle_part(part, result)
return result
@classmethod
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
result = cls(0)
for part in option_set:
result = cls._handle_part(part, result)
return result
@classmethod
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
try:
part = cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
f"Known options: {', '.join(flag.name for flag in cls)}") from e
else:
return base | part
def __str__(self) -> str:
if self.value:
return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
return "Off"
def mystery_argparse():
options = get_options()
defaults = options["generator"]
def resolve_path(path: str, resolver: typing.Callable[[str], str]) -> str:
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
return path if os.path.isabs(path) else resolver(path)
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
@@ -64,7 +103,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
return args, options
@@ -83,21 +122,21 @@ def main(args=None, callback=ERmain):
if args.race:
random.seed() # reset to time-based random source
weights_cache = {}
weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
except Exception as e:
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
if args.meta_file_path and os.path.exists(args.meta_file_path):
try:
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path]
meta_weights = weights_cache[args.meta_file_path][-1]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
del(meta_weights["meta_description"])
if args.samesettings:
@@ -108,21 +147,26 @@ def main(args=None, callback=ERmain):
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
if file.is_file() and not file.name.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
weights_cache[fname] = read_weights_yaml(path)
weights_cache[fname] = read_weights_yamls(path)
except Exception as e:
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
else:
print(f"P{player_id} Weights: {fname} >> "
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
player_files[player_id] = fname
player_id += 1
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items():
for yaml in yaml_data:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
args.multi = max(player_id-1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
f"{', '.join(args.plando)}")
f"{args.plando}")
if not weights_cache:
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
@@ -141,8 +185,9 @@ def main(args=None, callback=ERmain):
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
for k, v in weights_cache.items()}
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
@@ -153,38 +198,45 @@ def main(args=None, callback=ERmain):
option = get_choice(key, category_dict)
if option is not None:
for player, path in player_path_cache.items():
if category_name is None:
weights_cache[path][key] = option
elif category_name not in weights_cache[path]:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else:
weights_cache[path][category_name][key] = option
for yaml in weights_cache[path]:
if category_name is None:
yaml[key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else:
yaml[category_name][key] = option
name_counter = Counter()
erargs.player_settings = {}
for player in range(1, args.multi + 1):
player = 1
while player <= args.multi:
path = player_path_cache[player]
if path:
try:
settings = settings_cache[path] if settings_cache[path] else \
roll_settings(weights_cache[path], args.plando)
for k, v in vars(settings).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
@@ -214,7 +266,7 @@ def main(args=None, callback=ERmain):
callback(erargs, seed)
def read_weights_yaml(path):
def read_weights_yamls(path) -> Tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -224,7 +276,7 @@ def read_weights_yaml(path):
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return parse_yaml(yaml)
return tuple(parse_yamls(yaml))
def interpret_on_off(value) -> bool:
@@ -235,7 +287,7 @@ def convert_to_on_off(value) -> str:
return {True: "on", False: "off"}.get(value, value)
def get_choice_legacy(option, root, value=None) -> typing.Any:
def get_choice_legacy(option, root, value=None) -> Any:
if option not in root:
return value
if type(root[option]) is list:
@@ -250,7 +302,7 @@ def get_choice_legacy(option, root, value=None) -> typing.Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
def get_choice(option, root, value=None) -> typing.Any:
def get_choice(option, root, value=None) -> Any:
if option not in root:
return value
if type(root[option]) is list:
@@ -283,16 +335,16 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
def prefer_int(input_data: str) -> Union[str, int]:
try:
return int(input_data)
except:
return input_data
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none',
@@ -317,7 +369,7 @@ goals = {
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
@@ -387,10 +439,10 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
elif PlandoSettings.bosses in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
@@ -439,7 +491,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
setattr(ret, option_key, option(option.default))
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
if "linked_options" in weights:
weights = roll_linked_options(weights)
@@ -452,17 +504,11 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
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
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
if required_plando_options not in 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.")
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.")
ret = argparse.Namespace()
for option_key in Options.per_game_common_options:
@@ -491,12 +537,12 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
# skip setting this option if already set from common_options, defaulting to root option
if not (option_key in Options.common_options and option_key not in game_weights):
handle_option(ret, game_weights, option_key, option)
if "items" in plando_options:
if PlandoSettings.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if "connections" in plando_options:
if PlandoSettings.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
@@ -542,9 +588,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
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_legacy('open_pyramid', weights, 'goal')
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
@@ -616,7 +659,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {}
if "texts" in plando_options:
if PlandoSettings.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
@@ -628,7 +671,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if "connections" in plando_options:
if PlandoSettings.connections in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):

View File

@@ -15,16 +15,11 @@ import sys
from typing import Iterable, Sequence, Callable, Union, Optional
import subprocess
import itertools
from Utils import is_frozen, user_path, local_path, init_logging
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
is_windows, is_macos, is_linux
from shutil import which
import shlex
from enum import Enum, auto
import logging
is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys")
def open_host_yaml():
@@ -42,22 +37,16 @@ def open_host_yaml():
def open_patch():
suffixes = []
for c in components:
if isfile(get_exe(c)[-1]):
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) else []
try:
import tkinter
import tkinter.filedialog
filename = open_filename('Select patch', (('Patches', suffixes),))
except Exception as e:
logging.error("Could not load tkinter, which is likely not installed. "
"This attempt was made because Launcher.open_patch was used.")
raise e
messagebox('Error', str(e), error=True)
else:
root = tkinter.Tk()
root.withdraw()
suffixes = []
for c in components:
if isfile(get_exe(c)[-1]):
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) else []
filename = tkinter.filedialog.askopenfilename(filetypes=(('Patches', ' '.join(suffixes)),))
file, _, component = identify(filename)
if file and component:
launch([*get_exe(component), file], component.cli)
@@ -137,7 +126,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
@@ -152,6 +141,8 @@ components: Iterable[Component] = (
Component('FF1 Client', 'FF1Client'),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),
@@ -215,14 +206,7 @@ def launch(exe, in_terminal=False):
def run_gui():
if not sys.stdout:
from kvui import App, ContainerLayout, GridLayout, Button, Label # this kills stdout
else:
from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout as ContainerLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kvui import App, ContainerLayout, GridLayout, Button, Label
class Launcher(App):
base_title: str = "Archipelago Launcher"

View File

@@ -20,10 +20,15 @@ from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
from urllib.request import urlopen
import ModuleUpdate
ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
@@ -40,9 +45,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def main():
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--rom', default='ER_base.sfc', help='Path to an ALttP rom to adjust.')
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
help='Path to an ALttP JAP(1.0) rom to use as a base.')
help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
@@ -53,6 +58,7 @@ def main():
''')
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
@@ -127,9 +133,12 @@ def main():
def adjust(args):
start = time.perf_counter()
init_logging("LttP Adjuster")
logger = logging.getLogger('Adjuster')
logger.info('Patching ROM.')
vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch
meta, args.rom = Patch.create_rom_file(args.rom)
@@ -155,7 +164,7 @@ def adjust(args):
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
deathlink=args.deathlink)
deathlink=args.deathlink, allowcollect=args.allowcollect)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
@@ -210,6 +219,7 @@ def adjustGUI():
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
guiargs.rom = romVar2.get()
guiargs.baserom = romVar.get()
guiargs.sprite = rom_vars.sprite
@@ -246,6 +256,7 @@ def adjustGUI():
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
guiargs.baserom = romVar.get()
if isinstance(rom_vars.sprite, Sprite):
guiargs.sprite = rom_vars.sprite.name
@@ -278,7 +289,7 @@ def run_sprite_update():
else:
top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
while not done.is_set():
task.do_events()
logging.info("Done updating sprites")
@@ -289,6 +300,7 @@ def update_sprites(task, on_finish=None):
sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
def finished():
task.close_window()
if on_finish:
@@ -502,24 +514,29 @@ def get_rom_frame(parent=None):
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
defaults = {
"auto_apply": 'ask',
"music": True,
"reduceflashing": True,
"deathlink": False,
"sprite": None,
"quickswap": True,
"menuspeed": 'normal',
"heartcolor": 'red',
"heartbeep": 'normal',
"ow_palettes": 'default',
"uw_palettes": 'default',
"hud_palettes": 'default',
"sword_palettes": 'default',
"shield_palettes": 'default',
"sprite_pool": [],
"allowcollect": False,
}
if not adjuster_settings:
adjuster_settings = Namespace()
adjuster_settings.auto_apply = 'ask'
adjuster_settings.music = True
adjuster_settings.reduceflashing = True
adjuster_settings.deathlink = False
adjuster_settings.sprite = None
adjuster_settings.quickswap = True
adjuster_settings.menuspeed = 'normal'
adjuster_settings.heartcolor = 'red'
adjuster_settings.heartbeep = 'normal'
adjuster_settings.ow_palettes = 'default'
adjuster_settings.uw_palettes = 'default'
adjuster_settings.hud_palettes = 'default'
adjuster_settings.sword_palettes = 'default'
adjuster_settings.shield_palettes = 'default'
if not hasattr(adjuster_settings, 'sprite_pool'):
adjuster_settings.sprite_pool = []
for key, defaultvalue in defaults.items():
if not hasattr(adjuster_settings, key):
setattr(adjuster_settings, key, defaultvalue)
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
@@ -542,6 +559,10 @@ def get_rom_options_frame(parent=None):
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
vars.AllowCollectVar = IntVar(value=adjuster_settings.allowcollect)
AllowCollectCheckbutton = Checkbutton(romOptionsFrame, text="Allow Collect", variable=vars.AllowCollectVar)
AllowCollectCheckbutton.grid(row=8, column=0, sticky=W)
spriteDialogFrame = Frame(romOptionsFrame)
spriteDialogFrame.grid(row=0, column=1)
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
@@ -703,7 +724,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=8, column=0, columnspan=2, sticky=W)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
@@ -1243,4 +1264,4 @@ class ToolTips(object):
if __name__ == '__main__':
main()
main()

45
Main.py
View File

@@ -1,4 +1,3 @@
import copy
import collections
from itertools import zip_longest, chain
import logging
@@ -17,7 +16,7 @@ from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
from worlds import AutoWorld
ordered_areas = (
@@ -48,7 +47,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.goal = args.goal.copy()
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy()
@@ -86,12 +84,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
numlength = 8
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
f"{len(cls.location_names):3} Locations")
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}} | "
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}}")
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}})")
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(world, "generate_early")
@@ -125,6 +125,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if world.players > 1:
for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else:
world.non_local_items[1].value = set()
world.local_items[1].value = set()
@@ -142,13 +143,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]):
advancement = set()
classifications = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
if item.advancement:
advancement.add(item.name)
classifications[item.name] |= item.classification
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
@@ -166,18 +166,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
for player in players:
del(counters[player][item])
return counters, advancement
return counters, classifications
common_item_count, common_advancement_items = find_common_pool(group["players"], group["item_pool"])
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items():
advancement = item_name in common_advancement_items
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
new_item.advancement = advancement
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
@@ -262,7 +262,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# collect ER hint info
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
world.shuffle[player] != "vanilla" or world.retro[player]}
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
for region in world.regions:
if region.player in er_hint_data and region.locations:
@@ -302,7 +302,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in
world.get_game_players("A Link to the Past") if world.retro[player]]:
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
item = world.create_item(
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
region.player)
@@ -326,11 +326,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
slot_data = {}
client_versions = {}
games = {}
minimum_versions = {"server": (0, 2, 4), "clients": client_versions}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
names = [[name for player, name in sorted(world.player_name.items())]]
for slot in world.player_ids:
client_versions[slot] = world.worlds[slot].get_required_client_version()
player_world: AutoWorld.World = world.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
world.player_types[slot])
@@ -361,7 +363,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for location in world.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None"
"location.address should then also be None. Location: " \
f" {location}"
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.name in world.start_location_hints[location.player]:

View File

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

View File

@@ -62,6 +62,9 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Install archipelago requirements')
parser.add_argument('-y', '--yes', dest='yes', action='store_true', help='answer "yes" to all questions')
parser.add_argument('-f', '--force', dest='force', action='store_true', help='force update')
parser.add_argument('-a', '--append', nargs="*", dest='additional_requirements',
help='List paths to additional requirement files.')
args = parser.parse_args()
if args.additional_requirements:
requirements_files.update(args.additional_requirements)
update(args.yes, args.force)

View File

@@ -23,8 +23,11 @@ ModuleUpdate.update()
import websockets
import colorama
from thefuzz import process as fuzzy_process
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
except ImportError:
OperationalError = ConnectionError
import NetUtils
from worlds.AutoWorld import AutoWorldRegister
@@ -37,6 +40,7 @@ from Utils import get_item_name_from_id, get_location_name_from_id, \
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType
min_client_version = Version(0, 1, 6)
colorama.init()
# functions callable on storable data on the server by clients
@@ -181,6 +185,7 @@ class Context:
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
self.groups = {}
self.group_collected: typing.Dict[int, typing.Set[int]] = {}
self.random = random.Random()
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
@@ -301,7 +306,7 @@ class Context:
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for player, version in clients_ver.items():
self.minimum_client_versions[player] = Utils.Version(*version)
self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version)
self.clients = {}
for team, names in enumerate(decoded_obj['names']):
@@ -404,12 +409,16 @@ class Context:
def save_regularly():
import time
while not self.exit_event.is_set():
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving via thread.")
try:
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving via thread.")
self._save()
except OperationalError as e:
logging.exception(e)
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
self._save()
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
@@ -432,6 +441,7 @@ class Context:
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
"random_state": self.random.getstate(),
"group_collected": dict(self.group_collected),
"stored_data": self.stored_data,
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
"server_password": self.server_password, "password": self.password, "forfeit_mode":
@@ -445,22 +455,9 @@ class Context:
def set_save(self, savedata: dict):
if self.connect_names != savedata["connect_names"]:
raise Exception("This savegame does not appear to match the loaded multiworld.")
if "version" not in savedata:
# upgrade from version 1
# this is not perfect but good enough for old games to continue
for old, items in savedata["received_items"].items():
self.received_items[(*old, True)] = items
self.received_items[(*old, False)] = items.copy()
for (team, slot, remote) in self.received_items:
# remove start inventory from items, since this is separate now
start_inventory = get_start_inventory(self, slot, slot in self.remote_start_inventory)
if start_inventory:
del self.received_items[team, slot, remote][:len(start_inventory)]
logging.info("Upgraded save data")
elif savedata["version"] > self.save_version:
if savedata["version"] > self.save_version:
raise Exception("This savegame is newer than the server.")
else:
self.received_items = savedata["received_items"]
self.received_items = savedata["received_items"]
self.hints_used.update(savedata["hints_used"])
self.hints.update(savedata["hints"])
@@ -486,6 +483,9 @@ class Context:
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
if "group_collected" in savedata:
self.group_collected = savedata["group_collected"]
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for item_handling = remote
@@ -510,6 +510,11 @@ class Context:
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
def slot_set(self, slot) -> typing.Set[int]:
"""Returns the slot IDs that concern that slot,
as in expands groups out and returns back the input for solo."""
return self.groups.get(slot, {slot})
def _set_options(self, server_options: dict):
for key, value in server_options.items():
data_type = self.simple_options.get(key, None)
@@ -547,35 +552,37 @@ class Context:
collect_player(self, client.team, client.slot)
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
"""Send and remember hints"""
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in ctx.hints[team, hint.finding_player]]
if not hints:
return
concerns = collections.defaultdict(list)
for hint in hints:
net_msg = hint.as_network_message()
if hint.receiving_player in ctx.groups:
for player in ctx.groups[hint.receiving_player]:
concerns[player].append(net_msg)
else:
concerns[hint.receiving_player].append(net_msg)
if not hint.local and net_msg not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(net_msg)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message())
for player in ctx.slot_set(hint.receiving_player):
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
if not hint.found:
ctx.hints[team, hint.finding_player].add(hint)
if hint.receiving_player in ctx.groups:
for player in ctx.groups[hint.receiving_player]:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in ctx.hints[team, hint.finding_player]:
ctx.hints[team, hint.finding_player].add(hint)
for player in ctx.slot_set(hint.receiving_player):
ctx.hints[team, player].add(hint)
else:
ctx.hints[team, hint.receiving_player].add(hint)
for text in (format_hint(ctx, team, hint) for hint in hints):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
if hints:
for slot, clients in ctx.clients[team].items():
client_hints = concerns[slot]
if client_hints:
for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints))
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(ctx, team, hint)))
for slot, hint_data in concerns.items():
clients = ctx.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int):
@@ -624,9 +631,9 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
# TODO remove around 0.4
'players': players,
# TODO remove around 0.2.5 in favor of slot_info ?
# Maybe convert into a list of games that are present to fetch relevant datapackage entries before Connect?
# TODO convert to list of games present in 0.4
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
@@ -713,16 +720,16 @@ def get_players_string(ctx: Context):
return f'{len(auth_clients)} players of {total} connected ' + text[:-1]
def get_status_string(ctx: Context, team: int):
text = "Player Status on your team:"
def get_status_string(ctx: Context, team: int, tag: str):
text = f"Player Status on team {team}:"
for slot in ctx.locations:
connected = len(ctx.clients[team][slot])
death_link = len([client for client in ctx.clients[team][slot] if "DeathLink" in client.tags])
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
death_text = f" {death_link} of which are death link" if connected else ""
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{death_text}{goal_text} {completion_text}"
f"{tag_text}{goal_text} {completion_text}"
return text
@@ -759,12 +766,12 @@ def update_checked_locations(ctx: Context, team: int, slot: int):
def forfeit_player(ctx: Context, team: int, slot: int):
"""register any locations that are in the multidata"""
all_locations = set(ctx.locations[slot])
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
ctx.notify_all("%s (Team #%d) has released all remaining items from their world." % (ctx.player_names[(team, slot)], team + 1))
register_location_checks(ctx, team, slot, all_locations)
update_checked_locations(ctx, team, slot)
def collect_player(ctx: Context, team: int, slot: int):
def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
"""register any locations that are in the multidata, pointing towards this player"""
all_locations = collections.defaultdict(set)
for source_slot, location_data in ctx.locations.items():
@@ -772,11 +779,19 @@ def collect_player(ctx: Context, team: int, slot: int):
if values[1] == slot:
all_locations[source_slot].add(location_id)
ctx.notify_all("%s (Team #%d) has collected" % (ctx.player_names[(team, slot)], team + 1))
ctx.notify_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1))
for source_player, location_ids in all_locations.items():
register_location_checks(ctx, team, source_player, location_ids, count_activity=False)
update_checked_locations(ctx, team, source_player)
if not is_group:
for group, group_players in ctx.groups.items():
if slot in group_players:
group_collected_players = ctx.group_collected.setdefault(group, set())
group_collected_players.add(slot)
if set(group_players) == group_collected_players:
collect_player(ctx, team, group, True)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
items = []
@@ -787,8 +802,7 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
targets = ctx.groups.get(target_slot, [target_slot])
for target in targets:
for target in ctx.slot_set(target_slot):
for item in items:
if item.player != target_slot:
get_received_items(ctx, team, target, False).append(item)
@@ -826,16 +840,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
slots = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
if slot in group:
slots.append(group_id)
slots.add(group_id)
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
item_id, receiving_player, item_flags = result
if (receiving_player == slot or receiving_player in slots) and item_id == seeked_item_id:
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
@@ -893,7 +905,7 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
@@ -1046,7 +1058,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_admin(self, command: str = ""):
"""Allow remote administration of the multiworld server"""
"""Allow remote administration of the multiworld server
Usage: "!admin login <password>" in order to log in to the remote interface.
Once logged in, you can then use "!admin <command>" to issue commands.
If you need further help once logged in. use "!admin /help" """
output = f"!admin {command}"
if output.lower().startswith(
@@ -1091,20 +1106,26 @@ class ClientMessageProcessor(CommonCommandProcessor):
return self.ctx.commandprocessor(command)
def _cmd_players(self) -> bool:
"""Get information about connected and missing players"""
"""Get information about connected and missing players."""
if len(self.ctx.player_names) < 10:
self.ctx.notify_all(get_players_string(self.ctx))
else:
self.output(get_players_string(self.ctx))
return True
def _cmd_status(self) -> bool:
"""Get status information about your team."""
self.output(get_status_string(self.ctx, self.client.team))
def _cmd_status(self, tag:str="") -> bool:
"""Get status information about your team.
Optionally mention a Tag name and get information on who has that Tag.
For example: DeathLink or EnergyLink."""
self.output(get_status_string(self.ctx, self.client.team, tag))
return True
def _cmd_release(self) -> bool:
"""Sends remaining items in your world to their recipients."""
return self._cmd_forfeit()
def _cmd_forfeit(self) -> bool:
"""Surrender and send your remaining items out to their recipients"""
"""Surrender and send your remaining items out to their recipients. Use release in the future."""
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
forfeit_player(self.ctx, self.client.team, self.client.slot)
return True
@@ -1113,7 +1134,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
return True
elif "disabled" in self.ctx.forfeit_mode:
self.output(
"Sorry, client forfeiting has been disabled on this server. You can ask the server admin for a /forfeit")
"Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release")
return False
else: # is auto or goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
@@ -1121,8 +1142,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
return True
else:
self.output(
"Sorry, client forfeiting requires you to have beaten the game on this server."
" You can ask the server admin for a /forfeit")
"Sorry, client item releasing requires you to have beaten the game on this server."
" You can ask the server admin for a /release")
return False
def _cmd_collect(self) -> bool:
@@ -1261,7 +1282,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
elif not for_location and hint_name in world.item_name_groups: # item group name
hints = []
for item in world.item_name_groups[hint_name]:
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
if item in world.item_name_to_id: # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
elif not for_location and hint_name in world.item_names: # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name
@@ -1286,6 +1308,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
@@ -1388,9 +1412,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
if "IgnoreGame" not in args["tags"] and args['game'] != game:
ignore_game = "IgnoreGame" in args["tags"] or ( # IgnoreGame is deprecated. TODO: remove after 0.3.3?
("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game"))
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = ctx.minimum_client_versions[slot]
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
if args.get('items_handling', None) is None:
@@ -1450,7 +1476,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == "GetDataPackage":
exclusions = args.get("exclusions", [])
if exclusions:
if "games" in args:
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name in set(args.get("games", []))}
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": games}}])
# TODO: remove exclusions behaviour around 0.5.0
elif exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions}
@@ -1458,6 +1490,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
package["games"] = games
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}])
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
@@ -1513,7 +1546,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == 'LocationScouts':
locs = []
create_as_hint = args.get("create_as_hint", False)
create_as_hint: int = int(args.get("create_as_hint", 0))
hints = []
for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name:
@@ -1526,7 +1559,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
notify_hints(ctx, client.team, hints)
notify_hints(ctx, client.team, hints, only_new=create_as_hint == 2)
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@@ -1628,6 +1661,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(get_players_string(self.ctx))
return True
def _cmd_status(self, tag: str = "") -> bool:
"""Get status information about teams.
Optionally mention a Tag name and get information on who has that Tag.
For example: DeathLink or EnergyLink."""
for team in self.ctx.clients:
self.output(get_status_string(self.ctx, team, tag))
return True
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
asyncio.create_task(self.ctx.server.ws_server._close())
@@ -1673,43 +1714,48 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to collect")
return False
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
return self._cmd_forfeit(player_name)
@mark_raw
def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients"""
"""Send out the remaining items from a player to their intended recipients."""
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
forfeit_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to forfeit")
self.output(f"Could not find player {player_name} to release")
return False
@mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool:
"""Allow the specified player to use the !forfeit command"""
"""Allow the specified player to use the !release command."""
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {player_name} is now allowed to use the !forfeit command at any time.")
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
return True
self.output(f"Could not find player {player_name} to allow the !forfeit command for.")
self.output(f"Could not find player {player_name} to allow the !release command for.")
return False
@mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
""""Disallow the specified player from using the !forfeit command"""
""""Disallow the specified player from using the !release command."""
seeked_player = player_name.lower()
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.")
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
return True
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
self.output(f"Could not find player {player_name} to forbid the !release command for.")
return False
def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *item_name: str) -> bool:
@@ -1753,7 +1799,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
if item in world.item_name_groups:
hints = []
for item in world.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item))
if item in world.item_name_to_id: # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item))
else: # item name
hints = collect_hints(self.ctx, team, slot, item)
@@ -1795,7 +1842,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
return False
def _cmd_option(self, option_name: str, option: str):
"""Set options for the server. Warning: expires on restart"""
"""Set options for the server."""
attrtype = self.ctx.simple_options.get(option_name, None)
if attrtype:
@@ -1932,18 +1979,8 @@ async def main(args: argparse.Namespace):
try:
if not data_filename:
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error("Could not load tkinter, which is likely not installed. "
"This attempt was made because no .archipelago file was provided as argument. "
"Either provide a file or ensure the tkinter package is installed.")
raise e
else:
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
data_filename = Utils.open_filename("Select multiworld data", filetypes)
ctx.load(data_filename, args.use_embedded_options)

View File

@@ -96,6 +96,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
separators=(',', ':'),
).encode
@@ -111,6 +112,7 @@ def get_any_version(data: dict) -> Version:
whitelist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
}
custom_hooks = {
@@ -234,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
node["color"] = 'cyan'
elif flags & 0b001: # advancement
node["color"] = 'plum'
elif flags & 0b010: # never_exclude
elif flags & 0b010: # useful
node["color"] = 'slateblue'
elif flags & 0b100: # trap
node["color"] = 'salmon'
@@ -244,7 +246,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.item_name_getter(item_id)
node["text"] = self.ctx.item_names[item_id]
return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart):
@@ -253,7 +255,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_location_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.location_name_getter(item_id)
node["text"] = self.ctx.location_names[item_id]
return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):

View File

@@ -48,9 +48,12 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
script_version: int = 2
def get_item_value(ap_id):
return ap_id - 66000
class OoTCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
@@ -60,6 +63,13 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext):
logger.info(f"N64 Status: {self.ctx.n64_status}")
def _cmd_deathlink(self):
"""Toggle deathlink from client. Overrides default setting."""
if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
class OoTContext(CommonContext):
command_processor = OoTCommandProcessor
@@ -76,6 +86,8 @@ class OoTContext(CommonContext):
self.deathlink_enabled = False
self.deathlink_pending = False
self.deathlink_sent_this_death = False
self.deathlink_client_override = False
self.version_warning = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -91,6 +103,18 @@ class OoTContext(CommonContext):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class OoTManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Ocarina of Time Client"
self.ui = OoTManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: OoTContext):
if ctx.deathlink_enabled and ctx.deathlink_pending:
@@ -108,8 +132,8 @@ def get_payload(ctx: OoTContext):
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Turn on deathlink if it is on
if payload['deathlinkActive'] and not ctx.deathlink_enabled:
# Turn on deathlink if it is on, and if the client hasn't overriden it
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True)
ctx.deathlink_enabled = True
@@ -152,21 +176,30 @@ async def n64_sync_task(ctx: OoTContext):
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to five fields:
# Data will return a dict with up to six fields:
# 1. str: player name (always)
# 2. bool: deathlink active (always)
# 3. dict[str, bool]: checked locations
# 4. bool: whether Link is currently at 0 HP
# 5. bool: whether the game currently registers as complete
# 2. int: script version (always)
# 3. bool: deathlink active (always)
# 4. dict[str, bool]: checked locations
# 5. bool: whether Link is currently at 0 HP
# 6. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode())
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth:
ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom:
await ctx.server_auth(False)
reported_version = data_decoded.get('scriptVersion', 0)
if reported_version >= script_version:
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth:
ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom:
await ctx.server_auth(False)
else:
if not ctx.version_warning:
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
"Please update to the latest version. "
"Your connection to the Archipelago server will not be accepted.")
ctx.version_warning = True
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
@@ -253,13 +286,8 @@ if __name__ == '__main__':
ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
if gui_enabled:
input_task = None
from kvui import OoTManager
ctx.ui = OoTManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
ctx.run_gui()
ctx.run_cli()
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
@@ -271,17 +299,9 @@ if __name__ == '__main__':
if ctx.n64_sync_task:
await ctx.n64_sync_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,11 +1,15 @@
from __future__ import annotations
import abc
import math
import numbers
import typing
import random
from schema import Schema, And, Or
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
class AssembleOptions(type):
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
name_lookup = attrs["name_lookup"] = {}
@@ -24,8 +28,12 @@ class AssembleOptions(type):
options.update(new_options)
# apply aliases, without name_lookup
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")})
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
options.update(aliases)
# auto-validate schema on __init__
if "schema" in attrs.keys():
@@ -76,7 +84,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.get_current_option_name()})"
def __hash__(self):
def __hash__(self) -> int:
return hash(self.value)
@property
@@ -101,11 +109,173 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return bool(self.value)
@classmethod
def from_any(cls, data: typing.Any):
def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError
class Toggle(Option[int]):
class NumericOption(Option[int], numbers.Integral):
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True)
# https://github.com/python/typing/issues/272
# https://github.com/python/mypy/issues/3186
# https://github.com/microsoft/pyright/issues/1575
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, NumericOption):
return self.value == other.value
else:
return typing.cast(bool, self.value == other)
def __lt__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value < other.value
else:
return self.value < other
def __le__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value <= other.value
else:
return self.value <= other
def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value > other.value
else:
return self.value > other
def __bool__(self) -> bool:
return bool(self.value)
def __int__(self) -> int:
return self.value
def __mul__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value * other.value
else:
return self.value * other
def __rmul__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return other.value * self.value
else:
return other * self.value
def __sub__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value - other.value
else:
return self.value - other
def __rsub__(self, left: typing.Any) -> typing.Any:
if isinstance(left, NumericOption):
return left.value - self.value
else:
return left - self.value
def __add__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value + other.value
else:
return self.value + other
def __radd__(self, left: typing.Any) -> typing.Any:
if isinstance(left, NumericOption):
return left.value + self.value
else:
return left + self.value
def __truediv__(self, other: typing.Any) -> typing.Any:
if isinstance(other, NumericOption):
return self.value / other.value
else:
return self.value / other
def __rtruediv__(self, left: typing.Any) -> typing.Any:
if isinstance(left, NumericOption):
return left.value / self.value
else:
return left / self.value
def __abs__(self) -> typing.Any:
return abs(self.value)
def __and__(self, other: typing.Any) -> int:
return self.value & int(other)
def __ceil__(self) -> int:
return math.ceil(self.value)
def __floor__(self) -> int:
return math.floor(self.value)
def __floordiv__(self, other: typing.Any) -> int:
return self.value // int(other)
def __invert__(self) -> int:
return ~(self.value)
def __lshift__(self, other: typing.Any) -> int:
return self.value << int(other)
def __mod__(self, other: typing.Any) -> int:
return self.value % int(other)
def __neg__(self) -> int:
return -(self.value)
def __or__(self, other: typing.Any) -> int:
return self.value | int(other)
def __pos__(self) -> int:
return +(self.value)
def __pow__(self, exponent: numbers.Complex, modulus: typing.Optional[numbers.Integral] = None) -> int:
if not (modulus is None):
assert isinstance(exponent, numbers.Integral)
return pow(self.value, exponent, modulus) # type: ignore
return self.value ** exponent # type: ignore
def __rand__(self, other: typing.Any) -> int:
return int(other) & self.value
def __rfloordiv__(self, other: typing.Any) -> int:
return int(other) // self.value
def __rlshift__(self, other: typing.Any) -> int:
return int(other) << self.value
def __rmod__(self, other: typing.Any) -> int:
return int(other) % self.value
def __ror__(self, other: typing.Any) -> int:
return int(other) | self.value
def __round__(self, ndigits: typing.Optional[int] = None) -> int:
return round(self.value, ndigits)
def __rpow__(self, base: typing.Any) -> typing.Any:
return base ** self.value
def __rrshift__(self, other: typing.Any) -> int:
return int(other) >> self.value
def __rshift__(self, other: typing.Any) -> int:
return self.value >> int(other)
def __rxor__(self, other: typing.Any) -> int:
return int(other) ^ self.value
def __trunc__(self) -> int:
return math.trunc(self.value)
def __xor__(self, other: typing.Any) -> int:
return self.value ^ int(other)
class Toggle(NumericOption):
option_false = 0
option_true = 1
default = 0
@@ -130,24 +300,6 @@ class Toggle(Option[int]):
else:
return cls(data)
def __eq__(self, other):
if isinstance(other, Toggle):
return self.value == other.value
else:
return self.value == other
def __gt__(self, other):
if isinstance(other, Toggle):
return self.value > other.value
else:
return self.value > other
def __bool__(self):
return bool(self.value)
def __int__(self):
return int(self.value)
@classmethod
def get_option_name(cls, value):
return ["No", "Yes"][int(value)]
@@ -159,7 +311,7 @@ class DefaultOnToggle(Toggle):
default = 1
class Choice(Option[int]):
class Choice(NumericOption):
auto_display_name = True
def __init__(self, value: int):
@@ -216,7 +368,7 @@ class Choice(Option[int]):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class Range(Option[int], int):
class Range(NumericOption):
range_start = 0
range_end = 1
@@ -231,49 +383,111 @@ class Range(Option[int], int):
def from_text(cls, text: str) -> Range:
text = text.lower()
if text.startswith("random"):
if text == "random-low":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
elif text == "random-high":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
elif text == "random-middle":
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
elif text.startswith("random-range-"):
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
elif text.startswith("random-range-middle"):
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
elif text.startswith("random-range-high"):
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
else:
return cls(int(round(random.randint(random_range[0], random_range[1]))))
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"):
return cls(cls.default)
elif text == "high":
return cls(cls.range_end)
elif text == "low":
return cls(cls.range_start)
elif cls.range_start == 0 \
and hasattr(cls, "default") \
and cls.default != 0 \
and text in ("true", "false"):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls(cls.default)
else: # "false"
return cls(0)
return cls(int(text))
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
@classmethod
def custom_range(cls, text) -> Range:
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
else:
return cls(random.randint(random_range[0], random_range[1]))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
if type(data) == int:
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
@classmethod
def get_option_name(cls, value: int) -> str:
return str(value)
def __str__(self):
def __str__(self) -> str:
return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
return int(round(random.triangular(lower, end, tri), 0))
class SpecialRange(Range):
special_range_cutoff = 0
special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
if text in cls.special_range_names:
return cls(cls.special_range_names[text])
return super().from_text(text)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
elif text == "random-high":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class VerifyKeys:
valid_keys = frozenset()
@@ -302,13 +516,17 @@ class VerifyKeys:
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from {world.game}")
f"is not a valid item name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
elif self.verify_location_name:
for location_name in self.value:
if location_name not in world.location_names:
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
raise Exception(f"Location {location_name} from option {self} "
f"is not a valid location name from {world.game}")
f"is not a valid location name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
@@ -413,9 +631,18 @@ class Accessibility(Choice):
default = 1
class ProgressionBalancing(DefaultOnToggle):
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
range_start = 0
range_end = 99
display_name = "Progression Balancing"
special_range_names = {
"disabled": 0,
"normal": 50,
"extreme": 99,
}
common_options = {
@@ -453,6 +680,7 @@ class StartHints(ItemSet):
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
verify_location_name = True
class ExcludeLocations(OptionSet):
@@ -479,10 +707,31 @@ class ItemLinks(OptionList):
{
"name": And(str, len),
"item_pool": [And(str, len)],
"replacement_item": Or(And(str, len), None)
Optional("exclude"): [And(str, len)],
"replacement_item": Or(And(str, len), None),
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)]
}
])
@staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
pool = set()
for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
raise Exception(f"Item {item_name} from item link {item_link} "
f"is not a valid item from {world.game} for {pool_name}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
if allow_item_groups:
pool |= world.item_name_groups.get(item_name, {item_name})
else:
pool |= {item_name}
return pool
def verify(self, world):
super(ItemLinks, self).verify(world)
existing_links = set()
@@ -490,13 +739,25 @@ class ItemLinks(OptionList):
if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.")
existing_links.add(link["name"])
for item_name in link["item_pool"]:
if item_name not in world.item_names and item_name not in world.item_name_groups:
raise Exception(f"Item {item_name} from item link {link} "
f"is not a valid item name from {world.game}")
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
raise Exception(f"Item {link['replacement_item']} from item link {link} "
f"is not a valid item name from {world.game}")
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
local_items = set()
non_local_items = set()
if "exclude" in link:
pool -= self.verify_items(link["exclude"], link["name"], "exclude", world)
if link["replacement_item"]:
self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False)
if "local_items" in link:
local_items = self.verify_items(link["local_items"], link["name"], "local_items", world)
local_items &= pool
if "non_local_items" in link:
non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world)
non_local_items &= pool
intersection = local_items.intersection(non_local_items)
if intersection:
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
per_game_common_options = {

View File

@@ -12,6 +12,9 @@ import zipfile
import sys
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
import ModuleUpdate
ModuleUpdate.update()
import Utils
current_patch_version = 4
@@ -163,27 +166,31 @@ GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
GAME_SMZ3 = "SMZ3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
GAME_DKC3 = "Donkey Kong Country 3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz"
GAME_SMZ3: "apsmz",
GAME_DKC3: "apdkc3"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import JAP10HASH as HASH
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import JAP10HASH as HASH
from worlds.sm.Rom import SMJUHASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import JAP10HASH as ALTTPHASH
from worlds.sm.Rom import JAP10HASH as SMHASH
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH
elif game == GAME_DKC3:
from worlds.dkc3.Rom import USHASH as HASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
@@ -213,7 +220,10 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
".apbp" if game == GAME_ALTTP
else ".apsmz" if game == GAME_SMZ3
else ".apdkc3" if game == GAME_DKC3
else ".apm3")
write_lzma(bytes, target)
return target
@@ -242,6 +252,8 @@ def get_base_rom_data(game: str):
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes
elif game == GAME_DKC3:
from worlds.dkc3.Rom import get_base_rom_bytes
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
@@ -386,6 +398,13 @@ if __name__ == "__main__":
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apdkc3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}")
@@ -393,7 +412,9 @@ if __name__ == "__main__":
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
if zfinfo.filename.endswith(".apbp") or \
zfinfo.filename.endswith(".apm3") or \
zfinfo.filename.endswith(".apdkc3"):
data = update_patch_data(data, server)
with ziplock:
zfw.writestr(zfinfo, data)

View File

@@ -23,6 +23,11 @@ Currently, the following games are supported:
* ChecksFinder
* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2: Wings of Liberty
* Donkey Kong Country 3
* Dark Souls 3
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -46,7 +51,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
@@ -63,7 +68,10 @@ Contributions are welcome. We have a few asks of any new contributors.
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
For adding a new game to Archipelago please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
## FAQ
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
## Code of Conduct
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:

View File

@@ -10,23 +10,30 @@ import base64
import shutil
import logging
import asyncio
import enum
import typing
from json import loads, dumps
from Utils import init_logging
import ModuleUpdate
ModuleUpdate.update()
from Utils import init_logging, messagebox
if __name__ == "__main__":
init_logging("SNIClient", exception_logger="Client")
import colorama
import websockets
from NetUtils import *
from NetUtils import ClientStatus, color
from worlds.alttp import Regions, Shops
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
import Utils
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3
snes_logger = logging.getLogger("SNES")
@@ -55,7 +62,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
def _cmd_snes(self, snes_options: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to,
otherwise show available devices; and a SNES device number if more than one SNES is detected.
Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
snes_address = self.ctx.snes_address
snes_device_number = -1
@@ -71,7 +78,10 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
snes_device_number = int(options[1])
self.ctx.snes_reconnect_address = None
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
if self.ctx.snes_connect_task:
self.ctx.snes_connect_task.cancel()
self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number),
name="SNES Connect")
return True
def _cmd_snes_close(self) -> bool:
@@ -108,6 +118,7 @@ class Context(CommonContext):
command_processor = SNIClientCommandProcessor
game = "A Link to the Past"
items_handling = None # set in game_watcher
snes_connect_task: typing.Optional[asyncio.Task] = None
def __init__(self, snes_address, server_address, password):
super(Context, self).__init__(server_address, password)
@@ -124,6 +135,8 @@ class Context(CommonContext):
self.snes_connector_lock = threading.Lock()
self.death_state = DeathState.alive # for death link flop behaviour
self.killing_player_task = None
self.allow_collect = False
self.slow_mode = False
self.awaiting_rom = False
self.rom = None
@@ -172,6 +185,14 @@ class Context(CommonContext):
if not currently_dead:
self.death_state = DeathState.alive
async def shutdown(self):
await super(Context, self).shutdown()
if self.snes_connect_task:
try:
await asyncio.wait_for(self.snes_connect_task, 1)
except asyncio.TimeoutError:
self.snes_connect_task.cancel()
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}:
if "checked_locations" in args and args["checked_locations"]:
@@ -182,6 +203,19 @@ class Context(CommonContext):
# Once the games handled by SNIClient gets made to be remote items, this will no longer be needed.
asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
def run_gui(self):
from kvui import GameManager
class SNIManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("SNES", "SNES"),
]
base_title = "Archipelago SNI Client"
self.ui = SNIManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def deathlink_kill_player(ctx: Context):
ctx.death_state = DeathState.killing_player
@@ -220,6 +254,9 @@ async def deathlink_kill_player(ctx: Context):
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
ctx.death_link_allow_survive and health is not None and health > 0):
ctx.death_state = DeathState.dead
elif ctx.game == GAME_DKC3:
from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player
await dkc3_deathlink_kill_player(ctx)
ctx.last_death_link = time.time()
@@ -513,7 +550,8 @@ boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace -
"Thieves' Town - Boss",
'Ice Palace - Boss',
'Misery Mire - Boss',
'Turtle Rock - Boss'}}
'Turtle Rock - Boss',
'Sahasrahla'}}
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
@@ -563,7 +601,7 @@ class SNESState(enum.IntEnum):
SNES_ATTACHED = 3
def launch_sni(ctx: Context):
def launch_sni():
sni_path = Utils.get_options()["lttp_options"]["sni"]
if not os.path.isdir(sni_path):
@@ -601,11 +639,9 @@ async def _snes_connect(ctx: Context, address: str):
address = f"ws://{address}" if "://" not in address else address
snes_logger.info("Connecting to SNI at %s ..." % address)
seen_problems = set()
succesful = False
while not succesful:
while 1:
try:
snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None)
succesful = True
except Exception as e:
problem = "%s" % e
# only tell the user about new problems, otherwise silently lay in wait for a working connection
@@ -615,14 +651,14 @@ async def _snes_connect(ctx: Context, address: str):
if len(seen_problems) == 1:
# this is the first problem. Let's try launching SNI if it isn't already running
launch_sni(ctx)
launch_sni()
await asyncio.sleep(1)
else:
return snes_socket
async def get_snes_devices(ctx: Context):
async def get_snes_devices(ctx: Context) -> typing.List[str]:
socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll
DeviceList_Request = {
"Opcode": "DeviceList",
@@ -630,19 +666,20 @@ async def get_snes_devices(ctx: Context):
}
await socket.send(dumps(DeviceList_Request))
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
reply: dict = loads(await socket.recv())
devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
if not devices:
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
while not devices:
await asyncio.sleep(1)
while not devices and not ctx.exit_event.is_set():
await asyncio.sleep(0.1)
await socket.send(dumps(DeviceList_Request))
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
await verify_snes_app(socket)
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
if devices:
await verify_snes_app(socket)
await socket.close()
return devices
return sorted(devices)
async def verify_snes_app(socket):
@@ -860,7 +897,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
def new_check(location_id):
new_locations.append(location_id)
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
location = ctx.location_names[location_id]
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
@@ -872,7 +909,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
location = Shops.SHOP_ID_START + cnt
if int(b) and location not in ctx.locations_checked:
new_check(location)
if location in ctx.checked_locations and location not in ctx.locations_checked \
if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \
and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot:
if not int(b):
shop_data[cnt] += 1
@@ -900,9 +937,9 @@ async def track_locations(ctx: Context, roomid, roomdata):
uw_unchecked[location_id] = (roomid, mask)
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked and \
location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot and \
location_id not in boss_locations:
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
uw_checked[location_id] = (roomid, mask)
@@ -933,7 +970,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
ow_unchecked[location_id] = screenid
ow_begin = min(ow_begin, screenid)
ow_end = max(ow_end, screenid + 1)
if location_id in ctx.checked_locations and location_id in ctx.locations_info \
if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
ow_checked[location_id] = screenid
@@ -957,8 +994,9 @@ async def track_locations(ctx: Context, roomid, roomdata):
for location_id, mask in location_table_npc_id.items():
if npc_value & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
npc_value |= mask
npc_value_changed = True
if npc_value_changed:
@@ -974,7 +1012,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
assert (0x3c6 <= offset <= 0x3c9)
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
misc_data_changed = True
misc_data[offset - 0x3c6] |= mask
@@ -1000,43 +1038,48 @@ async def game_watcher(ctx: Context):
if not ctx.rom:
ctx.finished_game = False
ctx.death_link_allow_survive = False
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
if game_name is None:
continue
elif game_name[:2] == b"SM":
ctx.game = GAME_SM
# versions lower than 0.3.0 dont have item handling flag nor remote item support
romVersion = int(game_name[2:5].decode('UTF-8'))
if romVersion < 30:
ctx.items_handling = 0b001 # full local
else:
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
else:
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
if game_name == b"ZSM":
ctx.game = GAME_SMZ3
ctx.items_handling = 0b101 # local items and remote start inventory
else:
ctx.game = GAME_ALTTP
ctx.items_handling = 0b001 # full local
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
continue
from worlds.dkc3.Client import dkc3_rom_init
init_handled = await dkc3_rom_init(ctx)
if not init_handled:
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
if game_name is None:
continue
elif game_name[:2] == b"SM":
ctx.game = GAME_SM
# versions lower than 0.3.0 dont have item handling flag nor remote item support
romVersion = int(game_name[2:5].decode('UTF-8'))
if romVersion < 30:
ctx.items_handling = 0b001 # full local
else:
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
else:
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
if game_name == b"ZSM":
ctx.game = GAME_SMZ3
ctx.items_handling = 0b101 # local items and remote start inventory
else:
ctx.game = GAME_ALTTP
ctx.items_handling = 0b001 # full local
ctx.rom = rom
if ctx.game != GAME_SMZ3:
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
ctx.locations_info = {}
ctx.prev_rom = ctx.rom
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
continue
ctx.rom = rom
if ctx.game != GAME_SMZ3:
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
ctx.allow_collect = bool(death_link[0] & 0b100)
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
ctx.locations_info = {}
ctx.prev_rom = ctx.rom
if ctx.awaiting_rom:
await ctx.server_auth(False)
@@ -1091,9 +1134,9 @@ async def game_watcher(ctx: Context):
item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'),
color(ctx.item_names[item.item], 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
@@ -1148,7 +1191,7 @@ async def game_watcher(ctx: Context):
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
location = ctx.location_names[location_id]
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
@@ -1165,7 +1208,10 @@ async def game_watcher(ctx: Context):
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
locationId = (item.location - locations_start_id) if item.location >= 0 and bool(ctx.items_handling & 0b010) else 0x00
if bool(ctx.items_handling & 0b010):
locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF
else:
locationId = 0x00 #backward compat
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
@@ -1174,9 +1220,9 @@ async def game_watcher(ctx: Context):
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'),
color(ctx.item_names[item.item], 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
elif ctx.game == GAME_SMZ3:
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
@@ -1217,7 +1263,7 @@ async def game_watcher(ctx: Context):
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
location = ctx.location_names[location_id]
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
@@ -1238,9 +1284,12 @@ async def game_watcher(ctx: Context):
itemOutPtr += 1
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
elif ctx.game == GAME_DKC3:
from worlds.dkc3.Client import dkc3_game_watcher
await dkc3_game_watcher(ctx)
async def run_game(romfile):
@@ -1258,14 +1307,18 @@ async def main():
parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
parser.add_argument('--snes', default='localhost:23074', help='Address of the SNI server.')
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
args = parser.parse_args()
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating sfc rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
try:
meta, romfile = Patch.create_rom_file(args.diff_file)
except Exception as e:
messagebox('Error', str(e), True)
raise
if "server" in meta:
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
@@ -1277,7 +1330,7 @@ async def main():
import time
time.sleep(3)
sys.exit()
elif args.diff_file.endswith((".apbp", "apz3")):
elif args.diff_file.endswith((".apbp", ".apz3", ".aplttp")):
adjustedromfile, adjusted = get_alttp_settings(romfile)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
else:
@@ -1286,17 +1339,12 @@ async def main():
ctx = Context(args.snes, args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
input_task = None
if gui_enabled:
from kvui import SNIManager
ctx.ui = SNIManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait()
@@ -1305,21 +1353,12 @@ async def main():
ctx.snes_reconnect_address = None
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
if snes_connect_task:
snes_connect_task.cancel()
await watcher_task
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
def get_alttp_settings(romfile: str):
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
adjustedromfile = ''
if lastSettings:
choice = 'no'
@@ -1327,7 +1366,7 @@ def get_alttp_settings(romfile: str):
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
"reduceflashing", "deathlink", "allowcollect"}
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
if hasattr(lastSettings, "sprite_pool"):
sprite_pool = {}
@@ -1342,8 +1381,13 @@ def get_alttp_settings(romfile: str):
if gui_enabled:
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
applyPromptWindow = Tk()
try:
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
applyPromptWindow = Tk()
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed.')
return '', False
applyPromptWindow.resizable(False, False)
applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick())
logo = PhotoImage(file=Utils.local_path('data', 'icon.png'))
@@ -1427,7 +1471,7 @@ def get_alttp_settings(romfile: str):
if hasattr(lastSettings, "world"):
delattr(lastSettings, "world")
else:
adjusted = False;
adjusted = False
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
@@ -1441,7 +1485,5 @@ def get_alttp_settings(romfile: str):
if __name__ == '__main__':
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
asyncio.run(main())
colorama.deinit()

920
Starcraft2Client.py Normal file
View File

@@ -0,0 +1,920 @@
from __future__ import annotations
import multiprocessing
import logging
import asyncio
import os.path
import nest_asyncio
import sc2
from sc2.main import run_game
from sc2.data import Race
from sc2.bot_ai import BotAI
from sc2.player import Bot
from worlds.sc2wol.Regions import MissionInfo
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Items import lookup_id_to_name, item_table
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol import SC2WoLWorld
from pathlib import Path
import re
from MultiServer import mark_raw
import ctypes
import sys
from Utils import init_logging, is_windows
if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client")
logger = logging.getLogger("Client")
sc2_logger = logging.getLogger("Starcraft2")
import colorama
from NetUtils import *
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply()
class StarcraftClientProcessor(ClientCommandProcessor):
ctx: SC2Context
def _cmd_disable_mission_check(self) -> bool:
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
the next mission in a chain the other player is doing."""
self.ctx.missions_unlocked = True
sc2_logger.info("Mission check has been disabled")
def _cmd_play(self, mission_id: str = "") -> bool:
"""Start a Starcraft 2 mission"""
options = mission_id.split()
num_options = len(options)
if num_options > 0:
mission_number = int(options[0])
self.ctx.play_mission(mission_number)
else:
sc2_logger.info(
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
return True
def _cmd_available(self) -> bool:
"""Get what missions are currently available to play"""
request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
return True
def _cmd_unfinished(self) -> bool:
"""Get what missions are currently available to play and have not had all locations checked"""
request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
return True
@mark_raw
def _cmd_set_path(self, path: str = '') -> bool:
"""Manually set the SC2 install directory (if the automatic detection fails)."""
if path:
os.environ["SC2PATH"] = path
check_mod_install()
return True
else:
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
return False
class SC2Context(CommonContext):
command_processor = StarcraftClientProcessor
game = "Starcraft 2 Wings of Liberty"
items_handling = 0b111
difficulty = -1
all_in_choice = 0
mission_req_table = None
items_rec_to_announce = []
rec_announce_pos = 0
items_sent_to_announce = []
sent_announce_pos = 0
announcements = []
announcement_pos = 0
sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked = False
current_tooltip = None
last_loc_list = None
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(SC2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"]
self.mission_req_table = {}
# Compatibility for 0.3.2 server data.
if "category" not in next(iter(slot_req_table)):
for i, mission_data in enumerate(slot_req_table.values()):
mission_data["category"] = wol_default_categories[i]
for mission in slot_req_table:
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
# Look for and set SC2PATH.
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
if "SC2PATH" not in os.environ and check_game_install_path():
check_mod_install()
if cmd in {"PrintJSON"}:
if "receiving" in args:
if self.slot_concerns_self(args["receiving"]):
self.announcements.append(args["data"])
return
if "item" in args:
if self.slot_concerns_self(args["item"].player):
self.announcements.append(args["data"])
def run_gui(self):
from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.uix.gridlayout import GridLayout
from kivy.lang import Builder
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
from kivy.properties import StringProperty
import Utils
class HoverableButton(HoverBehavior, Button):
pass
class MissionButton(HoverableButton):
tooltip_text = StringProperty("Test")
def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
self.popuplabel = ServerToolTip(text=self.text)
self.layout.add_widget(self.popuplabel)
def on_enter(self):
self.popuplabel.text = self.tooltip_text
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
if self.tooltip_text == "":
self.ctx.current_tooltip = None
else:
App.get_running_app().root.add_widget(self.layout)
self.ctx.current_tooltip = self.layout
def on_leave(self):
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
@property
def ctx(self) -> CommonContext:
return App.get_running_app().ctx
class MissionLayout(GridLayout):
pass
class MissionCategory(GridLayout):
pass
class SC2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("Starcraft2", "Starcraft2"),
]
base_title = "Archipelago Starcraft 2 Client"
mission_panel = None
last_checked_locations = {}
mission_id_to_button = {}
launching = False
refresh_from_launching = True
first_check = True
def __init__(self, ctx):
super().__init__(ctx)
def build(self):
container = super().build()
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
self.mission_panel = panel.content = MissionLayout()
self.tabs.add_widget(panel)
Clock.schedule_interval(self.build_mission_table, 0.5)
return container
def build_mission_table(self, dt):
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
not self.refresh_from_launching)) or self.first_check:
self.refresh_from_launching = True
self.mission_panel.clear_widgets()
if self.ctx.mission_req_table:
self.last_checked_locations = self.ctx.checked_locations.copy()
self.first_check = False
self.mission_id_to_button = {}
categories = {}
available_missions = []
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
self.ctx.mission_req_table,
self.ctx, available_missions=available_missions,
unfinished_locations=unfinished_locations)
# separate missions into categories
for mission in self.ctx.mission_req_table:
if not self.ctx.mission_req_table[mission].category in categories:
categories[self.ctx.mission_req_table[mission].category] = []
categories[self.ctx.mission_req_table[mission].category].append(mission)
for category in categories:
category_panel = MissionCategory()
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
# Map is completed
for mission in categories[category]:
text = mission
tooltip = ""
# Map has uncollected locations
if mission in unfinished_missions:
text = f"[color=6495ED]{text}[/color]"
tooltip = f"Uncollected locations:\n"
tooltip += "\n".join(location for location in unfinished_locations[mission])
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
else:
text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: "
if len(self.ctx.mission_req_table[mission].required_world) > 0:
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
req_mission in
self.ctx.mission_req_table[mission].required_world)
if self.ctx.mission_req_table[mission].number > 0:
tooltip += " and "
if self.ctx.mission_req_table[mission].number > 0:
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
mission_button.tooltip_text = tooltip
mission_button.bind(on_press=self.mission_callback)
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
category_panel.add_widget(mission_button)
category_panel.add_widget(Label(text=""))
self.mission_panel.add_widget(category_panel)
elif self.launching:
self.refresh_from_launching = False
self.mission_panel.clear_widgets()
self.mission_panel.add_widget(Label(text="Launching Mission"))
def mission_callback(self, button):
if not self.launching:
self.ctx.play_mission(list(self.mission_id_to_button.keys())
[list(self.mission_id_to_button.values()).index(button)])
self.launching = True
Clock.schedule_once(self.finish_launching, 10)
def finish_launching(self, dt):
self.launching = False
self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
async def shutdown(self):
await super(SC2Context, self).shutdown()
if self.sc2_run_task:
self.sc2_run_task.cancel()
def play_mission(self, mission_id):
if self.missions_unlocked or \
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
if self.sc2_run_task:
if not self.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!")
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
if self.slot is None:
sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.")
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
name="Starcraft 2 Launch")
else:
sc2_logger.info(
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
f"Use /unfinished or /available to see what is available.")
async def main():
multiprocessing.freeze_support()
parser = get_base_parser()
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
args = parser.parse_args()
ctx = SC2Context(args.connect, args.password)
ctx.auth = args.name
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
maps_table = [
"ap_traynor01", "ap_traynor02", "ap_traynor03",
"ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
"ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
"ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
"ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
"ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
"ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
]
wol_default_categories = [
"Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
"Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
"Char", "Char", "Char", "Char"
]
def calculate_items(items):
unit_unlocks = 0
armory1_unlocks = 0
armory2_unlocks = 0
upgrade_unlocks = 0
building_unlocks = 0
merc_unlocks = 0
lab_unlocks = 0
protoss_unlock = 0
minerals = 0
vespene = 0
supply = 0
for item in items:
data = lookup_id_to_name[item.item]
if item_table[data].type == "Unit":
unit_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Upgrade":
upgrade_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 1":
armory1_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 2":
armory2_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Building":
building_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Mercenary":
merc_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Laboratory":
lab_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Protoss":
protoss_unlock += (1 << item_table[data].number)
elif item_table[data].type == "Minerals":
minerals += item_table[data].number
elif item_table[data].type == "Vespene":
vespene += item_table[data].number
elif item_table[data].type == "Supply":
supply += item_table[data].number
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
lab_unlocks, protoss_unlock, minerals, vespene, supply]
def calc_difficulty(difficulty):
if difficulty == 0:
return 'C'
elif difficulty == 1:
return 'N'
elif difficulty == 2:
return 'H'
elif difficulty == 3:
return 'B'
return 'X'
async def starcraft_launch(ctx: SC2Context, mission_id):
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
ctx.announcements_pos = len(ctx.announcements)
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
with DllDirectory(None):
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
name="Archipelago", fullscreen=True)], realtime=True)
class ArchipelagoBot(sc2.bot_ai.BotAI):
game_running = False
mission_completed = False
first_bonus = False
second_bonus = False
third_bonus = False
fourth_bonus = False
fifth_bonus = False
sixth_bonus = False
seventh_bonus = False
eight_bonus = False
ctx: SC2Context = None
mission_id = 0
can_read_game = False
last_received_update = 0
def __init__(self, ctx: SC2Context, mission_id):
self.ctx = ctx
self.mission_id = mission_id
super(ArchipelagoBot, self).__init__()
async def on_step(self, iteration: int):
game_state = 0
if iteration == 0:
start_items = calculate_items(self.ctx.items_received)
difficulty = calc_difficulty(self.ctx.difficulty)
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
difficulty,
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
self.ctx.all_in_choice, start_items[10]))
self.last_received_update = len(self.ctx.items_received)
else:
if self.ctx.announcement_pos < len(self.ctx.announcements):
index = 0
message = ""
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
index += 1
index = 0
start_rem_pos = -1
# Remove unneeded [Color] tags
while index < len(message):
if message[index] == '[':
start_rem_pos = index
index += 1
elif message[index] == ']' and start_rem_pos > -1:
temp_msg = ""
if start_rem_pos > 0:
temp_msg = message[:start_rem_pos]
if index < len(message) - 1:
temp_msg += message[index + 1:]
message = temp_msg
index += start_rem_pos - index
start_rem_pos = -1
else:
index += 1
await self.chat_send("SendMessage " + message)
self.ctx.announcement_pos += 1
# Archipelago reads the health
for unit in self.all_own_units():
if unit.health_max == 38281:
game_state = int(38281 - unit.health)
self.can_read_game = True
if iteration == 160 and not game_state & 1:
await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
"Starcraft 2 (This is likely a map issue)")
if self.last_received_update < len(self.ctx.items_received):
current_items = calculate_items(self.ctx.items_received)
await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
current_items[5], current_items[6], current_items[7]))
self.last_received_update = len(self.ctx.items_received)
if game_state & 1:
if not self.game_running:
print("Archipelago Connected")
self.game_running = True
if self.can_read_game:
if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != 29:
print("Mission Completed")
await self.ctx.send_msgs([
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
self.mission_completed = True
else:
print("Game Complete")
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
self.mission_completed = True
if game_state & (1 << 2) and not self.first_bonus:
print("1st Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
self.first_bonus = True
if not self.second_bonus and game_state & (1 << 3):
print("2nd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
self.second_bonus = True
if not self.third_bonus and game_state & (1 << 4):
print("3rd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
self.third_bonus = True
if not self.fourth_bonus and game_state & (1 << 5):
print("4th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
self.fourth_bonus = True
if not self.fifth_bonus and game_state & (1 << 6):
print("5th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
self.fifth_bonus = True
if not self.sixth_bonus and game_state & (1 << 7):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
self.sixth_bonus = True
if not self.seventh_bonus and game_state & (1 << 8):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
self.seventh_bonus = True
if not self.eight_bonus and game_state & (1 << 9):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
self.eight_bonus = True
else:
await self.chat_send("LostConnection - Lost connection to game.")
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
objectives_complete = 0
if missions_info[mission].extra_locations > 0:
for i in range(missions_info[mission].extra_locations):
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
objectives_complete += 1
else:
unfinished_locations[mission].append(ctx.location_names[
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
return objectives_complete
else:
return -1
def request_unfinished_missions(locations_done, location_table, ui, ctx):
if location_table:
message = "Unfinished Missions: "
unlocks = initialize_blank_mission_dict(location_table)
unfinished_locations = initialize_blank_mission_dict(location_table)
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
unfinished_locations=unfinished_locations)
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
mark_up_objectives(
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
ctx, unfinished_locations, mission)
for mission in unfinished_missions)
if ui:
ui.log_panels['All'].on_message_markup(message)
ui.log_panels['Starcraft2'].on_message_markup(message)
else:
sc2_logger.info(message)
else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
available_missions=[]):
unfinished_missions = []
locations_completed = []
if not unlocks:
unlocks = initialize_blank_mission_dict(locations)
if not unfinished_locations:
unfinished_locations = initialize_blank_mission_dict(locations)
if len(available_missions) > 0:
available_missions = []
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
for name in available_missions:
if not locations[name].extra_locations == -1:
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
if objectives_completed < locations[name].extra_locations:
unfinished_missions.append(name)
locations_completed.append(objectives_completed)
else:
unfinished_missions.append(name)
locations_completed.append(-1)
return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
def is_mission_available(mission_id_to_check, locations_done, locations):
unfinished_missions = calc_available_missions(locations_done, locations)
return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
def mark_up_mission_name(mission, location_table, ui, unlock_table):
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
if location_table[mission].completion_critical:
if ui:
message = "[color=AF99EF]" + mission + "[/color]"
else:
message = "*" + mission + "*"
else:
message = mission
if ui:
unlocks = unlock_table[mission]
if len(unlocks) > 0:
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
pre_message += f"]"
message = pre_message + message + "[/ref]"
return message
def mark_up_objectives(message, ctx, unfinished_locations, mission):
formatted_message = message
if ctx.ui:
locations = unfinished_locations[mission]
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
pre_message += "<br>".join(location for location in locations)
pre_message += f"]"
formatted_message = pre_message + message + "[/ref]"
return formatted_message
def request_available_missions(locations_done, location_table, ui):
if location_table:
message = "Available Missions: "
# Initialize mission unlock table
unlocks = initialize_blank_mission_dict(location_table)
missions = calc_available_missions(locations_done, location_table, unlocks)
message += \
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
for mission in missions)
if ui:
ui.log_panels['All'].on_message_markup(message)
ui.log_panels['Starcraft2'].on_message_markup(message)
else:
sc2_logger.info(message)
else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_available_missions(locations_done, locations, unlocks=None):
available_missions = []
missions_complete = 0
# Get number of missions completed
for loc in locations_done:
if loc % 100 == 0:
missions_complete += 1
for name in locations:
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
if unlocks:
for unlock in locations[name].required_world:
unlocks[list(locations)[unlock-1]].append(name)
if mission_reqs_completed(name, missions_complete, locations_done, locations):
available_missions.append(name)
return available_missions
def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
"""Returns a bool signifying if the mission has all requirements complete and can be done
Keyword arguments:
locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed
locations_done -- a list of the location ids that have been complete
locations -- a dict of MissionInfo for mission requirements for this world"""
if len(locations[location_to_check].required_world) >= 1:
# A check for when the requirements are being or'd
or_success = False
# Loop through required missions
for req_mission in locations[location_to_check].required_world:
req_success = True
# Check if required mission has been completed
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
if not locations[location_to_check].or_requirements:
return False
else:
req_success = False
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
locations):
if not locations[location_to_check].or_requirements:
return False
else:
req_success = False
# If requirement check succeeded mark or as satisfied
if locations[location_to_check].or_requirements and req_success:
or_success = True
if locations[location_to_check].or_requirements:
# Return false if or requirements not met
if not or_success:
return False
# Check number of missions
if missions_complete >= locations[location_to_check].number:
return True
else:
return False
else:
return True
def initialize_blank_mission_dict(location_table):
unlocks = {}
for mission in list(location_table):
unlocks[mission] = []
return unlocks
def check_game_install_path() -> bool:
# First thing: go to the default location for ExecuteInfo.
# An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
if is_windows:
# The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
# https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
import ctypes.wintypes
CSIDL_PERSONAL = 5 # My Documents
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
documentspath = buf.value
einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
else:
einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF]))
# Check if the file exists.
if os.path.isfile(einfo):
# Open the file and read it, picking out the latest executable's path.
with open(einfo) as f:
content = f.read()
if content:
base = re.search(r" = (.*)Versions", content).group(1)
if os.path.exists(base):
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
# Finally, check the path for an actual executable.
# If we find one, great. Set up the SC2PATH.
if os.path.isfile(executable):
sc2_logger.info(f"Found an SC2 install at {base}!")
sc2_logger.debug(f"Latest executable at {executable}.")
os.environ["SC2PATH"] = base
sc2_logger.debug(f"SC2PATH set to {base}.")
return True
else:
sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
else:
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
else:
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
return False
def check_mod_install() -> bool:
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
try:
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
return True
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
except KeyError:
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
return False
class DllDirectory:
# Credit to Black Sliver for this code.
# More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
_old: typing.Optional[str] = None
_new: typing.Optional[str] = None
def __init__(self, new: typing.Optional[str]):
self._new = new
def __enter__(self):
old = self.get()
if self.set(self._new):
self._old = old
def __exit__(self, *args):
if self._old is not None:
self.set(self._old)
@staticmethod
def get() -> str:
if sys.platform == "win32":
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
buf = ctypes.create_unicode_buffer(n)
ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
return buf.value
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
return None
@staticmethod
def set(s: typing.Optional[str]) -> bool:
if sys.platform == "win32":
return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
return False
if __name__ == '__main__':
colorama.init()
asyncio.run(main())
colorama.deinit()

180
Utils.py
View File

@@ -12,7 +12,12 @@ import io
import collections
import importlib
import logging
from tkinter import Tk
import decimal
if typing.TYPE_CHECKING:
from tkinter import Tk
else:
Tk = typing.Any
def tuplize_version(version: str) -> Version:
@@ -25,10 +30,15 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.1"
__version__ = "0.3.4"
version_tuple = tuplize_version(__version__)
from yaml import load, dump, SafeLoader
is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys")
import jellyfish
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as Loader
@@ -36,41 +46,44 @@ except ImportError:
from yaml import Loader
def int16_as_bytes(value):
def int16_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFF
return [value & 0xFF, (value >> 8) & 0xFF]
def int32_as_bytes(value):
def int32_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFFFFFF
return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF]
def pc_to_snes(value):
def pc_to_snes(value: int) -> int:
return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000
def snes_to_pc(value):
def snes_to_pc(value: int) -> int:
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
def cache_argsless(function):
if function.__code__.co_argcount:
raise Exception("Can only cache 0 argument functions with this cache.")
RetType = typing.TypeVar("RetType")
result = sentinel = object()
def _wrap():
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
assert not function.__code__.co_argcount, "Can only cache 0 argument functions with this cache."
sentinel = object()
result: typing.Union[object, RetType] = sentinel
def _wrap() -> RetType:
nonlocal result
if result is sentinel:
result = function()
return result
return typing.cast(RetType, result)
return _wrap
def is_frozen() -> bool:
return getattr(sys, 'frozen', False)
return typing.cast(bool, getattr(sys, 'frozen', False))
def local_path(*path: str) -> str:
@@ -159,6 +172,7 @@ class UniqueKeyLoader(SafeLoader):
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
@@ -246,7 +260,7 @@ def get_default_options() -> dict:
},
"generator": {
"teams": 1,
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
@@ -258,11 +272,17 @@ def get_default_options() -> dict:
},
"minecraft_options": {
"forge_directory": "Minecraft Forge server",
"max_heap_size": "2G"
"max_heap_size": "2G",
"release_channel": "release"
},
"oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
}
},
"dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
"sni": "SNI",
"rom_start": True,
},
}
return options
@@ -416,7 +436,8 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
exception_logger: typing.Optional[str] = None):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
@@ -452,13 +473,19 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
sys.excepthook = handle_exception
logging.info(f"Archipelago ({__version__}) logging initialized.")
def stream_input(stream, queue):
def queuer():
while 1:
text = stream.readline().strip()
if text:
queue.put_nowait(text)
try:
text = stream.readline().strip()
except UnicodeDecodeError as e:
logging.exception(e)
else:
if text:
queue.put_nowait(text)
from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -477,13 +504,112 @@ class VersionException(Exception):
pass
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")):
n = 0
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
text = ""
max_label = len(labels) - 1
while index > max_label:
text += labels[-1]
index -= max_label
return labels[index] + text
while value > power:
# noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
n = 0
value = decimal.Decimal(value)
while value >= power:
value /= power
n += 1
if type(value) == int:
return f"{value} {power_labels[n]}"
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]:
limit: int = limit if limit else len(wordlist)
return list(
map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted(
map(lambda candidate:
(candidate, get_fuzzy_ratio(input_word, candidate)),
wordlist),
key=lambda element: element[1],
reverse=True)[0:limit]
)
)
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
if is_linux:
# prefer native dialog
kdialog = shutil.which('kdialog')
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
zenity = shutil.which('zenity')
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
return run(zenity, f'--title={title}', '--file-selection', *z_filters)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because open_filename was used for "{title}".')
raise e
else:
return f"{value:0.3f} {power_labels[n]}"
root = tkinter.Tk()
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
def is_kivy_running():
if 'kivy' in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
return
if is_linux and not 'tkinter' in sys.modules:
# prefer native dialog
kdialog = shutil.which('kdialog')
if kdialog:
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
zenity = shutil.which('zenity')
if zenity:
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
# fall back to tk
try:
import tkinter
from tkinter.messagebox import showerror, showinfo
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because messagebox was used for "{title}".')
raise e
else:
root = tkinter.Tk()
root.withdraw()
showerror(title, text) if error else showinfo(title, text)
root.update()

View File

@@ -1,6 +1,8 @@
import os
import sys
import multiprocessing
import logging
import typing
import ModuleUpdate
@@ -20,6 +22,8 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
@@ -36,13 +40,54 @@ def get_app():
return app
def create_ordered_tutorials_file():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
with open(os.path.join("WebHostLib", "static", "assets", "tutorial", "tutorials.json")) as source:
data = json.load(source)
data = sorted(data, key=lambda entry: entry["gameTitle"].lower())
with open(os.path.join("WebHostLib", "static", "generated", "tutorials.json"), "w") as target:
json.dump(data, target)
import shutil
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
files = os.listdir(source_path)
for file in files:
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
if __name__ == "__main__":
@@ -63,7 +108,6 @@ if __name__ == "__main__":
autogen(app.config)
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
if app.config["DEBUG"]:
autohost(app.config)
app.run(debug=True, port=app.config["PORT"])
else:
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

@@ -46,7 +46,7 @@ app.config["PONY"] = {
'create_db': True
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "simple"
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
@@ -69,6 +69,10 @@ class B64UUIDConverter(BaseConverter):
app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
# has automatic patch integration
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
@@ -129,6 +133,10 @@ def tutorial(game, file, lang):
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@@ -137,6 +145,11 @@ def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
@@ -166,7 +179,12 @@ def _read_log(path: str):
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
@@ -207,7 +225,17 @@ def get_datapackge():
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@@ -45,7 +45,7 @@ def generate_api():
"detail": app.config["MAX_ROLL"]}, 409
meta = get_meta(meta_options_source)
meta["race"] = race
results, gen_options = roll_options(options)
results, gen_options = roll_options(options, meta["plando_options"])
if any(type(result) == str for result in results.values()):
return {"text": str(results),
"detail": results}, 400

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import functools
import logging
import websockets
import asyncio
import socket
@@ -9,6 +8,7 @@ import threading
import time
import random
import pickle
import logging
import Utils
from .models import *
@@ -128,15 +128,21 @@ def run_server_process(room_id, ponyconfig: dict):
ping_interval=None)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = socketname[1]
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
port = socketname[1]
if port:
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
@@ -146,6 +152,3 @@ def run_server_process(room_id, ponyconfig: dict):
from .autolauncher import Locker
with Locker(room_id):
asyncio.run(main())
from WebHostLib import LOGS_FOLDER

View File

@@ -25,7 +25,7 @@ def download_patch(room_id, patch_id):
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}"
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
@@ -55,7 +55,7 @@ def download_spoiler(seed_id):
def download_slot_file(room_id, player_id: int):
room = Room.get(id=room_id)
slot_data: Slot = select(patch for patch in room.seed.slots if
patch.player_id == player_id).first()
patch.player_id == player_id).first()
if not slot_data:
return "Slot Data not found"
@@ -71,17 +71,20 @@ def download_slot_file(room_id, player_id: int):
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip"
fname = name.rsplit("/", 1)[0] + ".zip"
elif slot_data.game == "Ocarina of Time":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
@app.route("/templates")
@cache.cached()
def list_yaml_templates():
@@ -90,4 +93,4 @@ def list_yaml_templates():
for world_name, world in AutoWorldRegister.world_types.items():
if not world.hidden:
files.append(world_name)
return render_template("templates.html", files=files)
return render_template("templates.html", files=files)

View File

@@ -4,7 +4,7 @@ import random
import json
import zipfile
from collections import Counter
from typing import Dict, Optional as TypeOptional
from typing import Dict, Optional, Any
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template
@@ -12,23 +12,33 @@ from flask import request, flash, redirect, url_for, session, render_template
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
from Generate import handle_name
from Generate import handle_name, PlandoSettings
import pickle
from .models import *
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app
from .check import get_yaml_data, roll_options
from .upload import upload_zip_to_db
def get_meta(options_source: dict) -> dict:
meta = {
plando_options = {
options_source.get("plando_bosses", ""),
options_source.get("plando_items", ""),
options_source.get("plando_connections", ""),
options_source.get("plando_texts", "")
}
plando_options -= {""}
server_options = {
"hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
"remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
"server_password": options_source.get("server_password", None),
}
return meta
return {"server_options": server_options, "plando_options": list(plando_options)}
@app.route('/generate', methods=['GET', 'POST'])
@@ -44,19 +54,18 @@ def generate(race=False):
if type(options) == str:
flash(options)
else:
results, gen_options = roll_options(options)
# get form data -> server settings
meta = get_meta(request.form)
meta["race"] = race
results, gen_options = roll_options(options, meta["plando_options"])
if race:
meta["item_cheat"] = False
meta["remaining"] = False
meta["server_options"]["item_cheat"] = False
meta["server_options"]["remaining_mode"] = "disabled"
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
@@ -82,32 +91,35 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__)
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, object] = {}
meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False)
meta.setdefault("hint_cost", 10)
race = meta.get("race", False)
del (meta["race"])
try:
target = tempfile.TemporaryDirectory()
playercount = len(gen_options)
seed = get_seed()
random.seed(seed)
if race:
random.seed() # reset to time-based random source
random.seed() # use time-based random source
else:
random.seed(seed)
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = 0 if race else 2
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
@@ -123,7 +135,7 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta)
ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
except BaseException as e:
@@ -135,7 +147,6 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
raise

View File

@@ -27,7 +27,7 @@ class Room(db.Entity):
seed = Required('Seed', index=True)
multisave = Optional(buffer, lazy=True)
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True)
last_port = Optional(int, default=lambda: 0)

View File

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

View File

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

View File

@@ -49,6 +49,12 @@ If you are ready to start randomizing games, or want to start playing your favor
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
# Risk of Rain 2
## Where is the settings page?
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
config file.
## What does randomization do to this game?
Risk of Rain is already a random game, by virtue of being a roguelite. The Archipelago mod implements pure multiworld
functionality in which certain chests (made clear via a location check progress bar) will send an item out to the
multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants by
other players in other worlds.
## What Risk of Rain items can appear in other players' worlds?
The Risk of Rain items are:
* `Common Item` (White items)
* `Uncommon Item` (Green items)
* `Boss Item` (Yellow items)
* `Legendary Item` (Red items)
* `Lunar Item` (Blue items)
* `Equipment` (Orange items)
* `Dio's Best Friend` (Used if you set the YAML setting `total_revives_available` above `0`)
Each item grants you a random in-game item from the category it belongs to.
When an item is granted by another world to the Risk of Rain player (one of the items listed above) then a random
in-game item of that tier will appear in the Risk of Rain player's inventory. If the item grant is an `Equipment` and
the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground and _
the new equipment_ will take it's place. (If you want the old one back, pick it up.)
## What does another world's item look like in Risk of Rain?
When the Risk of Rain player fills up their location check bar then the next spawned item will become an item grant for
another player's world. The item in Risk of Rain will disappear in a poof of smoke and the grant will automatically go
out to the multiworld.
## What is the item pickup step?
The item pickup step is a YAML setting which allows you to set how many items you need to spawn before the _next_ item
that is spawned disappears (in a poof of smoke) and goes out to the multiworld.

View File

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

View File

@@ -6,24 +6,24 @@ window.addEventListener('load', () => {
// Update game name on page
document.getElementById('game-name').innerText = gameName;
Promise.all([fetchSettingData()]).then((results) => {
fetchSettingData().then((results) => {
let settingHash = localStorage.getItem(`${gameName}-hash`);
if (!settingHash) {
// If no hash data has been set before, set it now
localStorage.setItem(`${gameName}-hash`, md5(results[0]));
settingHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, settingHash);
localStorage.removeItem(gameName);
settingHash = md5(results[0]);
}
if (settingHash !== md5(results[0])) {
if (settingHash !== md5(JSON.stringify(results))) {
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.");
document.getElementById('user-message').addEventListener('click', resetSettings);
}
// Page setup
createDefaultSettings(results[0]);
buildUI(results[0]);
createDefaultSettings(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
@@ -36,7 +36,8 @@ window.addEventListener('load', () => {
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((error) => {
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
@@ -158,9 +159,72 @@ const buildOptionsTable = (settings, romOpts = false) => {
element.appendChild(rangeVal);
break;
case 'special_range':
element = document.createElement('div');
element.classList.add('special-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', setting);
specialRange.setAttribute('min', settings[setting].min);
specialRange.setAttribute('max', settings[setting].max);
specialRange.value = currentSettings[gameName][setting];
// Build rage value element
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
break;
default:
console.error(`Unknown setting type: ${settings[setting].type}`);
console.error(setting);
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;
}

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
# VVVVVV MultiWorld Setup Guide
## Required Software
- VVVVVV (Bought from the [Steam Store](https://store.steampowered.com/app/70300/VVVVVV/) or [GOG Store](https://www.gog.com/game/vvvvvv) Page, NOT Make and Play Edition!)
- [V6AP](https://github.com/N00byKing/VVVVVV/releases)
## Installation and Game Start Procedures
1. Install VVVVVV through either Steam or GOG
2. Go to the page linked for V6AP, and download the latest release
3. Unpack the zip file where you have VVVVVV installed.
# Joining a MultiWorld Game
To join, set the following launch options: `-v6ap_name YourName -v6ap_ip ServerIP:Port`.
Optionally, add `-v6ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
The Name in this case is the one specified in your generated .yaml file.
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
# Playing offline
To play offline, first generate a seed on the game's settings page.
Create a room and download the `.apv6` file, and start the game with the `-v6ap_file FileName` launch argument.
## Installation Troubleshooting
Start the game from the command line to view helpful messages regarding V6AP. These will look something like "V6AP: Message"
### Game no longer starts after copying the .exe
Most likely you forgot to set the launch options. `-v6ap_name YourName` and `-v6ap_ip ServerIP:Port` are required for startup for Multiworlds, and
`-v6ap_file FileName` is required for (offline) singleplayer.
If your Name or Password have spaces in them, surround them in quotes.
## Game Troubleshooting
### What happens if I lose connection?
V6AP tries to reconnect a few times, so be patient.
Should the problem still be there after about a minute or two, just save and restart the game.

View File

@@ -1,578 +0,0 @@
[
{
"gameTitle": "Archipelago",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
"files": [
{
"language": "English",
"filename": "Archipelago/setup_en.md",
"link": "Archipelago/setup/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Website User Guide",
"description": "A guide to using the Archipelago website to generate multiworlds or host pre-generated multiworlds.",
"files": [
{
"language": "English",
"filename": "Archipelago/using_website.md",
"link": "Archipelago/using_website/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Server and Client Commands",
"description": "A guide detailing the commands available to the user when participating in an Archipelago session.",
"files": [
{
"language": "English",
"filename": "Archipelago/commands_en.md",
"link": "Archipelago/commands/en",
"authors": [
"jat2980",
"Ijwu"
]
}
]
},
{
"name": "Advanced YAML Guide",
"description": "A guide to reading yaml files and editing them to fully customize your game.",
"files": [
{
"language": "English",
"filename": "Archipelago/advanced_settings_en.md",
"link": "Archipelago/advanced_settings/en",
"authors": [
"alwaysintreble",
"Alchav"
]
}
]
},
{
"name": "Archipelago Triggers Guide",
"description": "A guide to setting up and using triggers in your game settings.",
"files": [
{
"language": "English",
"filename": "Archipelago/triggers_en.md",
"link": "Archipelago/triggers/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Plando Guide",
"description": "A guide to understanding and using plando for your game.",
"files": [
{
"language": "English",
"filename": "Archipelago/plando_en.md",
"link": "Archipelago/plando/en",
"authors": [
"alwaysintreble",
"Alchav"
]
}
]
}
]
},
{
"gameTitle": "The Legend of Zelda: A Link to the Past",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago ALttP software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "A Link to the Past/multiworld_en.md",
"link": "A Link to the Past/multiworld/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Deutsch",
"filename": "A Link to the Past/multiworld_de.md",
"link": "A Link to the Past/multiworld/de",
"authors": [
"Fischfilet"
]
},
{
"language": "Español",
"filename": "A Link to the Past/multiworld_es.md",
"link": "A Link to the Past/multiworld/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "A Link to the Past/multiworld_fr.md",
"link": "A Link to the Past/multiworld/fr",
"authors": [
"Coxla"
]
}
]
},
{
"name": "MSU-1 Setup Tutorial",
"description": "A guide to setting up MSU-1, which allows for custom in-game music.",
"files": [
{
"language": "English",
"filename": "A Link to the Past/msu1_en.md",
"link": "A Link to the Past/msu1/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Español",
"filename": "A Link to the Past/msu1_es.md",
"link": "A Link to the Past/msu1/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "A Link to the Past/msu1_fr.md",
"link": "A Link to the Past/msu1/fr",
"authors": [
"Coxla"
]
}
]
},
{
"name": "Plando Tutorial",
"description": "A guide to creating Multiworld Plandos",
"files": [
{
"language": "English",
"filename": "A Link to the Past/plando_en.md",
"link": "A Link to the Past/plando/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "The Legend of Zelda: Ocarina of Time",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Ocarina of Time software on your computer.",
"files": [
{
"language": "English",
"filename": "Ocarina of Time/setup_en.md",
"link": "Ocarina of Time/setup/en",
"authors": [
"Edos"
]
},
{
"language": "Spanish",
"filename": "Ocarina of Time/setup_es.md",
"link": "Ocarina of Time/setup/es",
"authors": [
"Edos"
]
}
]
}
]
},
{
"gameTitle": "Factorio",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
"files": [
{
"language": "English",
"filename": "Factorio/setup_en.md",
"link": "Factorio/setup/en",
"authors": [
"Berserker",
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Meritous",
"tutorials": [
{
"name": "Meritous Setup Tutorial",
"description": "A guide to setting up the Archipelago Meritous software on your computer.",
"files": [
{
"language": "English",
"filename": "Meritous/setup_en.md",
"link": "Meritous/setup/en",
"authors": [
"KewlioMZX"
]
}
]
}
]
},
{
"gameTitle": "Minecraft",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "Minecraft/minecraft_en.md",
"link": "Minecraft/minecraft/en",
"authors": [
"Kono Tyran"
]
},
{
"language": "Spanish",
"filename": "Minecraft/minecraft_es.md",
"link": "Minecraft/minecraft/es",
"authors": [
"Edos"
]
},
{
"language": "Swedish",
"filename": "Minecraft/minecraft_sv.md",
"link": "Minecraft/minecraft/sv",
"authors": [
"Albinum"
]
}
]
}
]
},
{
"gameTitle": "Risk of Rain 2",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.",
"files": [
{
"language": "English",
"filename": "Risk of Rain 2/setup_en.md",
"link": "Risk of Rain 2/setup/en",
"authors": [
"Ijwu"
]
}
]
}
]
},
{
"gameTitle": "Raft",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up Raft integration for Archipelago multiworld games.",
"files": [
{
"language": "English",
"filename": "Raft/setup_en.md",
"link": "Raft/setup/en",
"authors": [
"SunnyBat",
"Awareqwx"
]
}
]
}
]
},
{
"gameTitle": "Timespinner",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Timespinner randomizer connected to an Archipelago Multiworld",
"files": [
{
"language": "English",
"filename": "Timespinner/setup_en.md",
"link": "Timespinner/setup/en",
"authors": [
"Jarno"
]
},
{
"language": "German",
"filename": "Timespinner/setup_de.md",
"link": "Timespinner/setup/de",
"authors": [
"Grrmo",
"Fynxes",
"Blaze0168"
]
}
]
}
]
},
{
"gameTitle": "SMZ3",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Archipelago Super Metroid and A Link to the Past Crossover randomizer on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "SMZ3/multiworld_en.md",
"link": "SMZ3/multiworld/en",
"authors": [
"lordlou"
]
}
]
}
]
},
{
"gameTitle": "Subnautica",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Subnautica randomizer connected to an Archipelago Multiworld",
"files": [
{
"language": "English",
"filename": "Subnautica/setup_en.md",
"link": "Subnautica/setup/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "Super Metroid",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Super Metroid Client on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "Super Metroid/multiworld_en.md",
"link": "Super Metroid/multiworld/en",
"authors": [
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Secret of Evermore",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related software.",
"files": [
{
"language": "English",
"filename": "Secret of Evermore/multiworld_en.md",
"link": "Secret of Evermore/multiworld/en",
"authors": [
"Black Sliver"
]
}
]
}
]
},
{
"gameTitle": "Final Fantasy",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.",
"files": [
{
"language": "English",
"filename": "Final Fantasy/multiworld_en.md",
"link": "Final Fantasy/multiworld/en",
"authors": [
"jat2980"
]
}
]
}
]
},
{
"gameTitle": "Rogue Legacy",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Rogue Legacy Randomizer software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "Rogue Legacy/rogue-legacy_en.md",
"link": "Rogue Legacy/rogue-legacy/en",
"authors": [
"Phar"
]
}
]
}
]
},
{
"gameTitle": "Slay the Spire",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up Slay the Spire for Archipelago. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "Slay the Spire/slay-the-spire_en.md",
"link": "Slay the Spire/slay-the-spire/en",
"authors": [
"Phar"
]
}
]
}
]
},
{
"gameTitle": "Super Mario 64 EX",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up SM64EX for MultiWorld.",
"files": [
{
"language": "English",
"filename": "Super Mario 64/setup_en.md",
"link": "Super Mario 64/setup/en",
"authors": [
"N00byKing"
]
}
]
}
]
},
{
"gameTitle": "VVVVVV",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up VVVVVV for MultiWorld.",
"files": [
{
"language": "English",
"filename": "VVVVVV/setup_en.md",
"link": "VVVVVV/setup/en",
"authors": [
"N00byKing"
]
}
]
}
]
},
{
"gameTitle": "ChecksFinder",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "ChecksFinder/checksfinder_en.md",
"link": "ChecksFinder/checksfinder/en",
"authors": [
"Mewlif"
]
}
]
}
]
},
{
"gameTitle": "ArchipIDLE",
"tutorials": [
{
"name": "Setup Guide",
"description": "A guide to playing ArchipIDLE",
"files": [
{
"language": "English",
"filename": "ArchipIDLE/guide_en.md",
"link": "ArchipIDLE/guide/en",
"authors": [
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Hollow Knight",
"tutorials": [
{
"name": "Mod Setup and Use Guide",
"description": "A guide to playing Hollow Knight with Archipelago.",
"files": [
{
"language": "English",
"filename": "Hollow Knight/setup_en.md",
"link": "Hollow Knight/setup/en",
"authors": [
"ijwu"
]
}
]
}
]
}
]

View File

@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
games.forEach((game) => {
const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => {
@@ -65,6 +66,15 @@ window.addEventListener('load', () => {
showError();
console.error(error);
}
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
};
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

126
WebHostLib/stats.py Normal file
View File

@@ -0,0 +1,126 @@
from collections import Counter, defaultdict
from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date
from math import tau
import typing
from bokeh.embed import components
from bokeh.models import HoverTool
from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE
from bokeh.colors import RGB
from flask import render_template
from pony.orm import select
from . import app, cache
from .models import Room
PLOT_WIDTH = 600
def get_db_data() -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today()-timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
for x in range(0, 361, 360 // colors_needed):
# a bit of noise on value to add some luminosity difference
colors.append(RGB(*(val * 255 for val in hsv_to_rgb(x / 360, 0.8, 0.8 + (x / 1800)))))
# splice colors for maximum hue contrast.
colors = colors[::2] + colors[1::2]
return colors
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
occurences.append(all_games_data[day][game])
data = {
"days": [datetime.combine(day, datetime.min.time()) for day in days],
"played": occurences
}
plot = figure(
title=f"{game} Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500,
toolbar_location=None, tools="",
# setting legend to False seems broken in bokeh currently?
# legend=False
)
hover = HoverTool(tooltips=[("Date:", "@days{%F}"), ("Played:", "@played")], formatters={"@days": "datetime"})
plot.add_tools(hover)
plot.vbar(x="days", top="played", legend_label=game, color=color, source=ColumnDataSource(data=data), width=1)
return plot
@app.route('/stats')
@cache.memoize(timeout=60 * 60) # regen once per hour should be plenty
def stats():
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
total_games, games_played = get_db_data()
days = sorted(games_played)
color_palette = get_color_palette(len(total_games))
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games):
occurences = []
for day in days:
occurences.append(games_played[day][game])
plot.line([datetime.combine(day, datetime.min.time()) for day in days],
occurences, legend_label=game, line_width=2, color=game_to_color[game])
total = sum(total_games.values())
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
pie.axis.visible = False
pie.xgrid.visible = False
pie.ygrid.visible = False
data = {
"games": [],
"count": [],
"start_angles": [],
"end_angles": [],
}
current_angle = 0
for i, (game, count) in enumerate(total_games.most_common()):
data["games"].append(game)
data["count"].append(count)
data["start_angles"].append(current_angle)
angle = count / total * tau
current_angle += angle
data["end_angles"].append(current_angle)
data["colors"] = [game_to_color[game] for game in data["games"]]
pie.wedge(x=0, y=0, radius=0.5,
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games")
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts))
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
chart_data=script, charts=charts)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Glossary</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.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="{{ url_for('static', filename="assets/glossary.js") }}"></script>
{% endblock %}
{% block body %}
<div id="glossary-wrapper" data-lang="{{ lang }}" class="markdown">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

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

View File

@@ -16,11 +16,14 @@
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br />
{% endif %}
This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue
later,
you can simply refresh this page and the server will be started again.<br>
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port %}
You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr>
<td>{{ patch.player_id }}</td>
<td>{{ patch.player_name }}</td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.game == "Minecraft" %}
@@ -40,9 +40,12 @@
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game in ["A Link to the Past", "Secret of Evermore", "Super Metroid", "SMZ3"] %}
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% else %}
No file to download for this game.
{% endif %}

View File

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

View File

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

View File

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

View File

@@ -15,12 +15,21 @@
<p>
{{ world.__doc__ | default("No description provided.", true) }}<br />
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
{% if world.web.tutorials %}
<span class="link-spacer">|</span>
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
{% endif %}
{% if world.web.settings_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.settings_page }}">Settings Page</a>
{% elif world.web.settings_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
{% endif %}
{% if world.web.bug_report_page %}
<span class="link-spacer">|</span>
<a href="{{ world.web.bug_report_page }}">Report a Bug</a>
{% endif %}
</p>
{% endfor %}
</div>

View File

@@ -316,6 +316,11 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
else:
multisave: Dict[str, Any] = {}
slots_aimed_at_player = {tracked_player}
for group_id, group_members in groups.items():
if tracked_player in group_members:
slots_aimed_at_player.add(group_id)
# Add items to player inventory
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
# Skip teams and players not matching the request
@@ -325,7 +330,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
for location in locations_checked:
if location in player_locations:
item, recipient, flags = player_locations[location]
if recipient == tracked_player: # a check done for the tracked player
if recipient in slots_aimed_at_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
@@ -424,7 +429,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fa/Brewing_Stand.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
@@ -634,7 +639,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
"Shadow Temple": (67485, 67532),
"Spirit Temple": (67533, 67582),
"Ice Cavern": (67583, 67596),
"Gerudo Training Grounds": (67597, 67635),
"Gerudo Training Ground": (67597, 67635),
"Thieves' Hideout": (67259, 67263),
"Ganon's Castle": (67636, 67673),
}
@@ -642,7 +648,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
full_name = lookup_any_location_id_to_name[id]
if id == 67673:
return full_name[13:] # Ganons Tower Boss Key Chest
if area != 'Overworld':
if area not in ["Overworld", "Thieves' Hideout"]:
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
return full_name[len(area):]
return full_name
@@ -653,6 +659,13 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
for area, (min_id, max_id) in area_id_ranges.items()}
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
for loc in location_info["Thieves' Hideout"]:
del location_info["Overworld"][loc]
checks_done['Total'] = sum(checks_done.values())
checks_in_area['Total'] = sum(checks_in_area.values())
@@ -670,7 +683,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
"Spirit Temple": inventory[66178],
"Shadow Temple": inventory[66179],
"Bottom of the Well": inventory[66180],
"Gerudo Training Grounds": inventory[66181],
"Gerudo Training Ground": inventory[66181],
"Thieves' Hideout": inventory[66182],
"Ganon's Castle": inventory[66183],
}
boss_key_counts = {
@@ -875,7 +889,6 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
for item_name, item_id in multi_items.items():
base_name = item_name.split()[0].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id]
# Victory condition

View File

@@ -80,6 +80,11 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Ocarina of Time"))
elif file.filename.endswith(".json"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Dark Souls III"))
elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")

View File

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

View File

@@ -2,14 +2,14 @@ local socket = require("socket")
local json = require('json')
local math = require('math')
local script_version = '2022-03-22' -- Should be the last modified date
local last_modified_date = '2022-07-24' -- Should be the last modified date
local script_version = 2
--------------------------------------------------
-- Heavily modified form of RiptideSage's tracker
--------------------------------------------------
-- TODO: read this from the ROM
local NUM_BIG_POES_REQUIRED = 1
local NUM_BIG_POES_REQUIRED = 10
-- The offset constants are all from N64 RAM start. Offsets in the check statements are relative.
local save_context_offset = 0x11A5D0
@@ -464,7 +464,7 @@ local read_graveyard_checks = function()
local checks = {}
checks["Graveyard Shield Grave Chest"] = chest_check(0x40, 0x00)
checks["Graveyard Heart Piece Grave Chest"] = chest_check(0x3F, 0x00)
checks["Graveyard Composers Grave Chest"] = chest_check(0x41, 0x00)
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
@@ -899,11 +899,11 @@ end
local read_gerudo_fortress_checks = function()
local checks = {}
checks["GF North F1 Carpenter"] = on_the_ground_check(0xC, 0xC)
checks["GF North F2 Carpenter"] = on_the_ground_check(0xC, 0xA)
checks["GF South F1 Carpenter"] = on_the_ground_check(0xC, 0xE)
checks["GF South F2 Carpenter"] = on_the_ground_check(0xC, 0xF)
checks["GF Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
checks["GF Chest"] = chest_check(0x5D, 0x0)
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
checks["GF HBA 1500 Points"] = item_get_info_check(0x0, 0x7)
@@ -915,46 +915,46 @@ end
local read_gerudo_training_ground_checks = function(mq_table_address)
local checks = {}
if not is_master_quest_dungeon(mq_table_address, 0xB) then
checks["Gerudo Training Grounds Lobby Left Chest"] = chest_check(0x0B, 0x13)
checks["Gerudo Training Grounds Lobby Right Chest"] = chest_check(0x0B, 0x07)
checks["Gerudo Training Grounds Stalfos Chest"] = chest_check(0x0B, 0x00)
checks["Gerudo Training Grounds Before Heavy Block Chest"] = chest_check(0x0B, 0x11)
checks["Gerudo Training Grounds Heavy Block First Chest"] = chest_check(0x0B, 0x0F)
checks["Gerudo Training Grounds Heavy Block Second Chest"] = chest_check(0x0B, 0x0E)
checks["Gerudo Training Grounds Heavy Block Third Chest"] = chest_check(0x0B, 0x14)
checks["Gerudo Training Grounds Heavy Block Fourth Chest"] = chest_check(0x0B, 0x02)
checks["Gerudo Training Grounds Eye Statue Chest"] = chest_check(0x0B, 0x03)
checks["Gerudo Training Grounds Near Scarecrow Chest"] = chest_check(0x0B, 0x04)
checks["Gerudo Training Grounds Hammer Room Clear Chest"] = chest_check(0x0B, 0x12)
checks["Gerudo Training Grounds Hammer Room Switch Chest"] = chest_check(0x0B, 0x10)
checks["Gerudo Training Grounds Freestanding Key"] = on_the_ground_check(0x0B, 0x1)
checks["Gerudo Training Grounds Maze Right Central Chest"] = chest_check(0x0B, 0x05)
checks["Gerudo Training Grounds Maze Right Side Chest"] = chest_check(0x0B, 0x08)
checks["Gerudo Training Grounds Underwater Silver Rupee Chest"] = chest_check(0x0B, 0x0D)
checks["Gerudo Training Grounds Beamos Chest"] = chest_check(0x0B, 0x01)
checks["Gerudo Training Grounds Hidden Ceiling Chest"] = chest_check(0x0B, 0x0B)
checks["Gerudo Training Grounds Maze Path First Chest"] = chest_check(0x0B, 0x06)
checks["Gerudo Training Grounds Maze Path Second Chest"] = chest_check(0x0B, 0x0A)
checks["Gerudo Training Grounds Maze Path Third Chest"] = chest_check(0x0B, 0x09)
checks["Gerudo Training Grounds Maze Path Final Chest"] = chest_check(0x0B, 0x0C)
checks["Gerudo Training Ground Lobby Left Chest"] = chest_check(0x0B, 0x13)
checks["Gerudo Training Ground Lobby Right Chest"] = chest_check(0x0B, 0x07)
checks["Gerudo Training Ground Stalfos Chest"] = chest_check(0x0B, 0x00)
checks["Gerudo Training Ground Before Heavy Block Chest"] = chest_check(0x0B, 0x11)
checks["Gerudo Training Ground Heavy Block First Chest"] = chest_check(0x0B, 0x0F)
checks["Gerudo Training Ground Heavy Block Second Chest"] = chest_check(0x0B, 0x0E)
checks["Gerudo Training Ground Heavy Block Third Chest"] = chest_check(0x0B, 0x14)
checks["Gerudo Training Ground Heavy Block Fourth Chest"] = chest_check(0x0B, 0x02)
checks["Gerudo Training Ground Eye Statue Chest"] = chest_check(0x0B, 0x03)
checks["Gerudo Training Ground Near Scarecrow Chest"] = chest_check(0x0B, 0x04)
checks["Gerudo Training Ground Hammer Room Clear Chest"] = chest_check(0x0B, 0x12)
checks["Gerudo Training Ground Hammer Room Switch Chest"] = chest_check(0x0B, 0x10)
checks["Gerudo Training Ground Freestanding Key"] = on_the_ground_check(0x0B, 0x1)
checks["Gerudo Training Ground Maze Right Central Chest"] = chest_check(0x0B, 0x05)
checks["Gerudo Training Ground Maze Right Side Chest"] = chest_check(0x0B, 0x08)
checks["Gerudo Training Ground Underwater Silver Rupee Chest"] = chest_check(0x0B, 0x0D)
checks["Gerudo Training Ground Beamos Chest"] = chest_check(0x0B, 0x01)
checks["Gerudo Training Ground Hidden Ceiling Chest"] = chest_check(0x0B, 0x0B)
checks["Gerudo Training Ground Maze Path First Chest"] = chest_check(0x0B, 0x06)
checks["Gerudo Training Ground Maze Path Second Chest"] = chest_check(0x0B, 0x0A)
checks["Gerudo Training Ground Maze Path Third Chest"] = chest_check(0x0B, 0x09)
checks["Gerudo Training Ground Maze Path Final Chest"] = chest_check(0x0B, 0x0C)
else
checks["Gerudo Training Grounds MQ Lobby Left Chest"] = chest_check(0xB, 0x13)
checks["Gerudo Training Grounds MQ Lobby Right Chest"] = chest_check(0xB, 0x7)
checks["Gerudo Training Grounds MQ First Iron Knuckle Chest"] = chest_check(0xB, 0x0)
checks["Gerudo Training Grounds MQ Before Heavy Block Chest"] = chest_check(0xB, 0x11)
checks["Gerudo Training Grounds MQ Heavy Block Chest"] = chest_check(0xB, 0x2)
checks["Gerudo Training Grounds MQ Eye Statue Chest"] = chest_check(0xB, 0x3)
checks["Gerudo Training Grounds MQ Ice Arrows Chest"] = chest_check(0xB, 0x4)
checks["Gerudo Training Grounds MQ Second Iron Knuckle Chest"] = chest_check(0xB, 0x12)
checks["Gerudo Training Grounds MQ Flame Circle Chest"] = chest_check(0xB, 0xE)
checks["Gerudo Training Grounds MQ Maze Right Central Chest"] = chest_check(0xB, 0x5)
checks["Gerudo Training Grounds MQ Maze Right Side Chest"] = chest_check(0xB, 0x8)
checks["Gerudo Training Grounds MQ Underwater Silver Rupee Chest"] = chest_check(0xB, 0xD)
checks["Gerudo Training Grounds MQ Dinolfos Chest"] = chest_check(0xB, 0x1)
checks["Gerudo Training Grounds MQ Hidden Ceiling Chest"] = chest_check(0xB, 0xB)
checks["Gerudo Training Grounds MQ Maze Path First Chest"] = chest_check(0xB, 0x6)
checks["Gerudo Training Grounds MQ Maze Path Third Chest"] = chest_check(0xB, 0x9)
checks["Gerudo Training Grounds MQ Maze Path Second Chest"] = chest_check(0xB, 0xA)
checks["Gerudo Training Ground MQ Lobby Left Chest"] = chest_check(0xB, 0x13)
checks["Gerudo Training Ground MQ Lobby Right Chest"] = chest_check(0xB, 0x7)
checks["Gerudo Training Ground MQ First Iron Knuckle Chest"] = chest_check(0xB, 0x0)
checks["Gerudo Training Ground MQ Before Heavy Block Chest"] = chest_check(0xB, 0x11)
checks["Gerudo Training Ground MQ Heavy Block Chest"] = chest_check(0xB, 0x2)
checks["Gerudo Training Ground MQ Eye Statue Chest"] = chest_check(0xB, 0x3)
checks["Gerudo Training Ground MQ Ice Arrows Chest"] = chest_check(0xB, 0x4)
checks["Gerudo Training Ground MQ Second Iron Knuckle Chest"] = chest_check(0xB, 0x12)
checks["Gerudo Training Ground MQ Flame Circle Chest"] = chest_check(0xB, 0xE)
checks["Gerudo Training Ground MQ Maze Right Central Chest"] = chest_check(0xB, 0x5)
checks["Gerudo Training Ground MQ Maze Right Side Chest"] = chest_check(0xB, 0x8)
checks["Gerudo Training Ground MQ Underwater Silver Rupee Chest"] = chest_check(0xB, 0xD)
checks["Gerudo Training Ground MQ Dinolfos Chest"] = chest_check(0xB, 0x1)
checks["Gerudo Training Ground MQ Hidden Ceiling Chest"] = chest_check(0xB, 0xB)
checks["Gerudo Training Ground MQ Maze Path First Chest"] = chest_check(0xB, 0x6)
checks["Gerudo Training Ground MQ Maze Path Third Chest"] = chest_check(0xB, 0x9)
checks["Gerudo Training Ground MQ Maze Path Second Chest"] = chest_check(0xB, 0xA)
end
return checks
end
@@ -1107,7 +1107,7 @@ local read_song_checks = function()
checks["Song from Impa"] = event_check(0x5, 0x9) -- Zelda's Lullaby
checks["Song from Malon"] = event_check(0x5, 0x8) -- Epona's Song
checks["Song from Saria"] = event_check(0x5, 0x7) -- Saria's Song
checks["Song from Composers Grave"] = event_check(0x5, 0xA) -- Sun's Song
checks["Song from Royal Familys Tomb"] = event_check(0x5, 0xA) -- Sun's Song
checks["Song from Ocarina of Time"] = event_check(0xA, 0x9) -- Song of Time
checks["Song from Windmill"] = event_check(0x5, 0xB) -- Song of Storms
checks["Sheik in Forest"] = event_check(0x5, 0x0) -- Minuet of Forest
@@ -1546,7 +1546,7 @@ local player_names_address = coop_context + 20
local player_name_length = 8 -- 8 bytes
local rom_name_location = player_names_address + 0x800
local master_quest_table_address = rando_context + 0xB220
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
local save_context_addr = 0x11A5D0
local internal_count_addr = save_context_addr + 0x90
@@ -1555,6 +1555,8 @@ local item_queue = {}
local first_connect = true
local game_complete = false
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
local bytes_to_string = function(bytes)
local string = ''
for i=0,#(bytes) do
@@ -1721,6 +1723,11 @@ function get_death_state()
end
function kill_link()
-- market entrance: 27/28/29
-- outside ToT: 35/36/37.
-- if killed on these scenes the game crashes, so we wait until not on this screen.
local scene = global_context:rawget('cur_scene'):rawget()
if scene == 27 or scene == 28 or scene == 29 or scene == 35 or scene == 36 or scene == 37 then return end
mainmemory.write_u16_be(0x11A600, 0)
end
@@ -1781,6 +1788,7 @@ function receive()
-- Determine message to send back
local retTable = {}
retTable["playerName"] = get_player_name()
retTable["scriptVersion"] = script_version
retTable["deathlinkActive"] = deathlink_enabled()
if InSafeState() then
retTable["locations"] = check_all_locations(master_quest_table_address)
@@ -1821,13 +1829,15 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ootSocket = client
ootSocket:settimeout(0)
else
print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua')
return
end
end
end

View File

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

BIN
docs/img/theme_stone.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
docs/network diagram.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

168
docs/network diagram.md Normal file
View File

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

1
docs/network diagram.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -63,10 +63,9 @@ Sent to clients when they connect to an Archipelago server.
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". |
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Sent only if the client is properly authenticated (see [Archipelago Connection Handshake](#Archipelago-Connection-Handshake)). Information on the players currently connected to the server. |
| games | list\[str\] | sorted list of game names for the players, so first player's game will be games\[0\]. Matches game names in datapackage. |
| datapackage_version | int | Data version of the [data package](#Data-Package-Contents) the server will send. Used to update the client's (optional) local cache. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. |
| games | list\[str\] | List of games present in this multiworld. |
| datapackage_version | int | Sum of individual games' datapackage version. Deprecated. Use `datapackage_versions` instead. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
| seed_name | str | uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
@@ -101,11 +100,10 @@ Sent to clients when the server refuses connection. This is sent during the init
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `SlotAlreadyTaken`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
InvalidSlot indicates that the sent 'name' field did not match any auth entry on the server.
InvalidGame indicates that a correctly named slot was found, but the game for it mismatched.
SlotAlreadyTaken indicates a connection with a different uuid is already established.
IncompatibleVersion indicates a version mismatch.
InvalidPassword indicates the wrong, or no password when it was required, was sent.
InvalidItemsHandling indicates a wrong value type or flag combination was sent.
@@ -121,7 +119,7 @@ Sent to clients when the connection handshake is successfully completed.
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
| slot_info | dict\[int, NetworkSlot\] | maps each slot to a NetworkSlot information |
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
### ReceivedItems
Sent to clients when they receive an item.
@@ -147,7 +145,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
| Name | Type | Notes |
| ---- | ---- | ----- |
| hint_points | int | New argument. The client's current hint points. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Changed argument. Always sends all players, whether connected or not. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Send in the event of an alias rename. Always sends all players, whether connected or not. |
| checked_locations | list\[int\] | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. |
| missing_locations | list\[int\] | Should never be sent as an update, if needed is the inverse of checked_locations. |
@@ -193,8 +191,23 @@ Sent to clients after a client requested this message be sent to them, more info
### InvalidPacket
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. |
##### PacketProblemType
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
| Type | Notes |
| ---- | ----- |
| cmd | `cmd` argument of the faulty packet that could not be parsed correctly. |
| arguments | Arguments of the faulty packet which were not correct. |
### Retrieved
Sent to clients as a response the a [Get](#Get) package
Sent to clients as a response the a [Get](#Get) package.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
@@ -239,7 +252,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags.
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
#### items_handling flags
@@ -260,7 +273,7 @@ Update arguments from the Connect package, currently only updating tags and item
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| items_handling | int | Flags configuring which items should be sent by the server.
| items_handling | int | Flags configuring which items should be sent by the server. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
### Sync
@@ -283,7 +296,7 @@ Sent to the server to inform it of locations the client has seen, but not checke
| Name | Type | Notes |
| ---- | ---- | ----- |
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
| create_as_hint | bool | If True, the scouted locations get created and broadcasted as a player-visible hint. |
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
### StatusUpdate
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
@@ -305,9 +318,9 @@ Basic chat command which sends text to the server to be distributed to other cli
Requests the data package from the server. Does not require client authentication.
#### Arguments
| Name | Type | Notes |
| ------ | ----- | ------ |
| exclusions | list\[str\] | Optional. If specified, will not send back the specified data. Such as, \["Factorio"\] -> Datapackage without Factorio data.|
| Name | Type | Notes |
|-------| ----- |---------------------------------------------------------------------------------------------------------------------------------|
| games | list\[str\] | Optional. If specified, will only send back the specified data. Such as, \["Factorio"\] -> Datapackage with only Factorio data. |
### Bounce
Send this message to the server, tell it which clients should receive the message and
@@ -345,7 +358,7 @@ Additional arguments sent in this package will also be added to the [SetReply](#
#### DataStorageOperation
A DataStorageOperation manipulates or alters the value of a key in the data storage. If the operation transforms the value from one state to another then the current value of the key is used as the starting point otherwise the [Set](#Set)'s package `default` is used if the key does not exist on the server already.
DataStorageOperations consist of an object containing both the operation to be applied, provided in the form of a string, as well as the value to be used for that operation, Example:
```js
```json
{"operation": "add", "value": 12}
```
@@ -400,7 +413,7 @@ class NetworkPlayer(NamedTuple):
```
Example:
```js
```json
[
{"team": 0, "slot": 1, "alias": "Lord MeowsiePuss", "name": "Meow"},
{"team": 0, "slot": 2, "alias": "Doggo", "name": "Bork"},
@@ -420,7 +433,7 @@ class NetworkItem(NamedTuple):
flags: int
```
In JSON this may look like:
```js
```json
[
{"item": 1, "location": 1, "player": 1, "flags": 1},
{"item": 2, "location": 2, "player": 2, "flags": 2},
@@ -569,7 +582,6 @@ Note:
| Name | Type | Notes |
| ------ | ----- | ------ |
| games | dict[str, GameData] | Mapping of all Games and their respective data |
| version | int | Sum of all per-game version numbers, for clients that don't bother with per-game caching/updating. |
#### GameData
GameData is a **dict** but contains these keys and values. It's broken out into another "type" for ease of documentation.
@@ -583,13 +595,13 @@ GameData is a **dict** but contains these keys and values. It's broken out into
### Tags
Tags are represented as a list of strings, the common Client tags follow:
| Name | Notes |
| ----- | ---- |
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| IgnoreGame | Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client is actually a Tracker, will refuse new locations from this client and send all items as if they were remote items. |
| TextOnly | Tells the server that this client will not send locations and does not want to receive items. |
| Name | Notes |
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| IgnoreGame | Deprecated. See Tracker and TextOnly. Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -0,0 +1,58 @@
# Running From Source
If you just want to play and there is a compiled version available on the
[Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases),
use that version. These steps are for developers or platforms without compiled releases available.
## General
What you'll need:
* Python 3.8.7 or newer
* pip (Depending on platform may come included)
* A C compiler
* possibly optional, read OS-specific sections
Then run any of the starting point scripts, like Generate.py, and the included ModuleUpdater should prompt to install or update the
required modules and after pressing enter proceed to install everything automatically.
After this, you should be able to run the programs.
## Windows
Recommended steps
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
* Download and install full Visual Studio from
[Visual Studio Downloads](https://visualstudio.microsoft.com/downloads/)
or an older "Build Tools for Visual Studio" from
[Visual Studio Older Downloads](https://visualstudio.microsoft.com/vs/older-downloads/).
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details
* This step is optional. Pre-compiled modules are pinned on
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
## macOS
Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md).
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
You can get the latest Enemizer release at [Enemizer Github releases](https://github.com/Ijwu/Enemizer/releases).
It should be dropped as "EnemizerCLI" into the root folder of the project. Alternatively, you can point the Enemizer
setting in host.yaml at your Enemizer executable.
## Optional: SNI
SNI is required to use SNIClient. If not integrated into the project, it has to be started manually.
You can get the latest SNI release at [SNI Github releases](https://github.com/alttpo/sni/releases).
It should be dropped as "SNI" into the root folder of the project. Alternatively, you can point the sni setting in
host.yaml at your SNI folder.

49
docs/style.md Normal file
View File

@@ -0,0 +1,49 @@
# Style Guide
## Generic
* This guide can be ignored for data files that are not to be viewed in an editor.
* 120 character per line for all source files.
* Avoid white space errors like trailing spaces.
## Python Code
* We mostly follow [PEP8](https://peps.python.org/pep-0008/). Read below to see the differences.
* 120 characters per line. PyCharm does this automatically, other editors can be configured for it.
* Strings in core code will be `"strings"`. In other words: double quote your strings.
* Strings in worlds should use double quotes as well, but imported code may differ.
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotation where possible.
## Markdown
* We almost follow [Google's styleguide](https://google.github.io/styleguide/docguide/style.html).
Read below for differences.
* For existing documents, try to follow its style or ask to completely reformat it.
* 120 characters per line.
* One space between bullet/number and text.
* No lazy numbering.
## HTML
* Indent with 2 spaces for new code.
* kebab-case for ids and classes.
## CSS
* Indent with 2 spaces for new code.
* `{` on the same line as the selector.
* No space between selector and `{`.
## JS
* Indent with 2 spaces.
* Indent `case` inside `switch ` with 2 spaces.
* Use single quotes.
* Semicolons are required after every statement.

View File

@@ -56,11 +56,21 @@ game.
### WebWorld Class
A `WebWorld` class contains specific attributes and methods that can be modified
for your world specifically on the webhost. At the moment this comprises of `settings_page`
which can be changed to a link instead of an AP generated settings page, and a `theme` to be used for your game specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime |
|---|---|---|---|---|---|---|
| <img src="img/theme_dirt.JPG" width="100"> | <img src="img/theme_grass.JPG" width="100"> | <img src="img/theme_grassFlowers.JPG" width="100"> | <img src="img/theme_ice.JPG" width="100"> | <img src="img/theme_jungle.JPG" width="100"> | <img src="img/theme_ocean.JPG" width="100"> | <img src="img/theme_partyTime.JPG" width="100"> |
for your world specifically on the webhost.
`settings_page` which can be changed to a link instead of an AP generated settings page.
`theme` to be used for your game specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
|---|---|---|---|---|---|---|---|
| <img src="img/theme_dirt.JPG" width="100"> | <img src="img/theme_grass.JPG" width="100"> | <img src="img/theme_grassFlowers.JPG" width="100"> | <img src="img/theme_ice.JPG" width="100"> | <img src="img/theme_jungle.JPG" width="100"> | <img src="img/theme_ocean.JPG" width="100"> | <img src="img/theme_partyTime.JPG" width="100"> | <img src="img/theme_stone.JPG" width="100"> |
`bug_report_page` (optional) can be a link to a bug reporting page, most likely a GitHub issue page, that will be placed by the site to help direct users to report bugs.
`tutorials` list of `Tutorial` classes where each class represents a guide to be generated on the webhost.
`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be
prefixed with the same string as defined here. Default already has 'en'.
### MultiWorld Object
@@ -104,14 +114,21 @@ Special locations with ID `None` can hold events.
Items are all things that can "drop" for your game. This may be RPG items like
weapons, could as well be technologies you normally research in a research tree.
Each item has a `name`, an `id` (can be known as "code"), and an `advancement`
flag. An advancement item is an item which a player may require to advance in
their world. Advancement items will be assigned to locations with higher
Each item has a `name`, an `id` (can be known as "code"), and a classification.
The most important classification is `progression` (formerly advancement).
Progression items are items which a player may require to progress in
their world. Progression items will be assigned to locations with higher
priority and moved around to meet defined rules and accomplish progression
balancing.
Special items with ID `None` can mark events (read below).
Other classifications include
* filler: a regular item or trash item
* useful: generally quite useful, but not required for anything logical
* trap: negative impact on the player
* skip_balancing: add to progression to skip balancing; e.g. currency or tokens
### Events
Events will mark some progress. You define an event location, an
@@ -219,7 +236,7 @@ class MyGameLocation(Location):
game: str = "My Game"
# override constructor to automatically mark event locations as such
def __init__(self, player: int, name = '', code = None, parent = None):
def __init__(self, player: int, name = "", code = None, parent = None):
super(MyGameLocation, self).__init__(player, name, code, parent)
self.event = code is None
```
@@ -336,7 +353,7 @@ from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from ..AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
from Utils import get_options, output_path
class MyGameItem(Item): # or from Items import MyGameItem
@@ -398,7 +415,7 @@ The world has to provide the following things for generation
`self.world.get_filled_locations(self.player)` will filter for this world.
`item.player` can be used to see if it's a local item.
In addition the following methods can be implemented
In addition, the following methods can be implemented and attributes can be set
* `def generate_early(self)`
called per player before any items or locations are created. You can set
@@ -418,16 +435,17 @@ In addition the following methods can be implemented
before, during and after the regular fill process, before `generate_output`.
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
will be used by the server to host the MultiWorld.
* `def get_required_client_version(self)`
can return a tuple of 3 ints to make sure the client is compatible to this
world (e.g. item IDs) when connecting.
Always use `return max((x,y,z), super().get_required_client_version())`
to catch updates in the lower layers that break compatibility.
* `required_client_version: Tuple(int, int, int)`
Client version as tuple of 3 ints to make sure the client is compatible to
this world (e.g. implements all required features) when connecting.
* `assert_generate(cls, world)` is a class method called at the start of
generation to check the existence of prerequisite files, usually a ROM for
games which require one.
#### generate_early
```python
def generate_early(self):
def generate_early(self) -> None:
# read player settings to world instance
self.final_boss_hp = self.world.final_boss_hp[self.player].value
```
@@ -442,7 +460,9 @@ from .Items import is_progression # this is just a dummy
def create_item(self, item: str):
# This is called when AP wants to create an item by name (for plando) or
# when you call it from your own code.
return MyGameItem(item, is_progression(item), self.item_name_to_id[item],
classification = ItemClassification.progression if is_progression(item) else \
ItemClassification.filler
return MyGameItem(item, classification, self.item_name_to_id[item],
self.player)
def create_event(self, event: str):
@@ -453,7 +473,7 @@ def create_event(self, event: str):
#### create_items
```python
def create_items(self):
def create_items(self) -> None:
# Add items to the Multiworld.
# If there are two of the same item, the item has to be twice in the pool.
# Which items are added to the pool may depend on player settings,
@@ -467,36 +487,36 @@ def create_items(self):
for item in map(self.create_item, mygame_items):
if item in exclude:
exclude.remove(item) # this is destructive. create unique list above
self.world.itempool.append(self.create_item('nothing'))
self.world.itempool.append(self.create_item("nothing"))
else:
self.world.itempool.append(item)
# itempool and number of locations should match up.
# If this is not the case we want to fill the itempool with junk.
junk = 0 # calculate this based on player settings
self.world.itempool += [self.create_item('nothing') for _ in range(junk)]
self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
```
#### create_regions
```python
def create_regions(self):
def create_regions(self) -> None:
# Add regions to the multiworld. "Menu" is the required starting point.
# Arguments to Region() are name, type, human_readable_name, player, world
r = Region("Menu", None, "Menu", self.player, self.world)
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
# Set Region.exits to a list of entrances that are reachable from region
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
# Append region to MultiWorld's regions
self.world.regions.append(r) # or use += [r...]
r = Region("Main Area", None, "Main Area", self.player, self.world)
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
# Add main area's locations to main area (all but final boss)
r.locations = [MyGameLocation(self.player, location.name,
self.location_name_to_id[location.name], r)]
r.exits = [Entrance(self.player, "Boss Door", r)]
self.world.regions.append(r)
r = Region("Boss Room", None, "Boss Room", self.player, self.world)
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
# add event to Boss Room
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
self.world.regions.append(r)
@@ -515,7 +535,7 @@ def create_regions(self):
#### generate_basic
```python
def generate_basic(self):
def generate_basic(self) -> None:
# place "Victory" at "Final Boss" and set collection as win condition
self.world.get_location("Final Boss", self.player)\
.place_locked_item(self.create_event("Victory"))
@@ -536,7 +556,7 @@ def generate_basic(self):
from ..generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type
def set_rules(self):
def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic
@@ -608,7 +628,7 @@ class MyGameLogic(LogicMixin):
def _mygame_has_key(self, world: MultiWorld, player: int):
# Arguments above are free to choose
# it may make sense to use World as argument instead of MultiWorld
return self.has('key', player) # or whatever
return self.has("key", player) # or whatever
```
```python
# __init__.py
@@ -620,7 +640,7 @@ class MyGameWorld(World):
# ...
def set_rules(self):
set_rule(self.world.get_location("A Door", self.player),
lamda state: state._myworld_has_key(self.world, self.player))
lamda state: state._mygame_has_key(self.world, self.player))
```
### Generate Output

View File

@@ -56,7 +56,7 @@ server_options:
# Options for Generation
generator:
# Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases
enemizer_path: "EnemizerCLI/EnemizerCLI.Core.exe"
enemizer_path: "EnemizerCLI/EnemizerCLI.Core" # + ".exe" is implied on Windows
# Folder from which the player yaml files are pulled from
player_files_path: "Players"
#amount of players, 0 to infer from player files
@@ -101,11 +101,16 @@ sm_options:
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
factorio_options:
executable: "factorio\\bin\\x64\\factorio"
executable: "factorio/bin/x64/factorio"
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
# server_settings: "factorio\\data\\server-settings.json"
minecraft_options:
forge_directory: "Minecraft Forge server"
max_heap_size: "2G"
oot_options:
# release channel, currently "release", or "beta"
# any games played on the "beta" channel have a high likelihood of no longer working on the "release" channel.
release_channel: "release"
oot_options:
# File name of the OoT v1.0 ROM
rom_file: "The Legend of Zelda - Ocarina of Time.z64"
# Set this to false to never autostart a rom (such as after patching)
@@ -123,4 +128,13 @@ smz3_options:
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
rom_start: true
dkc3_options:
# File name of the DKC3 US rom
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true

View File

@@ -5,7 +5,7 @@
#define MyAppExeName "ArchipelagoServer.exe"
#define MyAppIcon "data/icon.ico"
#dim VersionTuple[4]
#define MyAppVersion ParseVersion(source_path + '\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
#define MyAppVersion GetVersionComponents(source_path + '\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
#define MyAppVersionText Str(VersionTuple[0])+"."+Str(VersionTuple[1])+"."+Str(VersionTuple[2])
@@ -54,6 +54,7 @@ Name: "custom"; Description: "Custom installation"; Flags: iscustom
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
@@ -62,11 +63,13 @@ Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -75,6 +78,7 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
[Files]
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
@@ -92,6 +96,7 @@ Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: igno
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
[Icons]
@@ -104,6 +109,7 @@ Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinec
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
@@ -113,6 +119,7 @@ Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\Archipel
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
[Run]
@@ -125,6 +132,7 @@ Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
[Registry]
@@ -138,15 +146,20 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
@@ -163,6 +176,10 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text
[Code]
const
@@ -197,6 +214,9 @@ var LttPROMFilePage: TInputFileWizardPage;
var smrom: string;
var SMRomFilePage: TInputFileWizardPage;
var dkc3rom: string;
var DKC3RomFilePage: TInputFileWizardPage;
var soerom: string;
var SoERomFilePage: TInputFileWizardPage;
@@ -286,6 +306,8 @@ begin
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
Result := not (DKC3ROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
@@ -326,6 +348,22 @@ begin
Result := '';
end;
function GetDKC3ROMPath(Param: string): string;
begin
if Length(dkc3rom) > 0 then
Result := dkc3rom
else if Assigned(DKC3RomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(DKC3ROMFilePage.Values[0]), '120abf304f0c40fe059f6a192ed4f947')
if R <> 0 then
MsgBox('Donkey Kong Country 3 ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := DKC3ROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSoEROMPath(Param: string): string;
begin
if Length(soerom) > 0 then
@@ -370,6 +408,10 @@ begin
if Length(smrom) = 0 then
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
dkc3rom := CheckRom('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc', '120abf304f0c40fe059f6a192ed4f947');
if Length(dkc3rom) = 0 then
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
@@ -383,6 +425,8 @@ begin
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then

221
kvui.py
View File

@@ -8,7 +8,11 @@ os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
from kivy.base import Config
import Utils
if Utils.is_frozen():
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set('kivy', 'exit_on_escape', '0')
@@ -18,7 +22,8 @@ from kivy.app import App
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel
from kivy.base import ExceptionHandler, ExceptionManager, Clock
from kivy.base import ExceptionHandler, ExceptionManager
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
from kivy.uix.button import Button
@@ -36,9 +41,13 @@ from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.animation import Animation
from kivy.uix.popup import Popup
import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
if typing.TYPE_CHECKING:
import CommonClient
@@ -50,7 +59,7 @@ else:
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
"""from https://stackoverflow.com/a/605348110"""
"""originally from https://stackoverflow.com/a/605348110"""
hovered = BooleanProperty(False)
border_point = ObjectProperty(None)
@@ -61,11 +70,11 @@ class HoverBehavior(object):
Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs)
def on_mouse_pos(self, *args):
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # do proceed if I'm not displayed <=> If have no parent
pos = args[1]
# Next line to_widget allow to compensate for relative layout
return # Abort if not displayed
# to_widget translates window pos to within widget pos
inside = self.collide_point(*self.to_widget(*pos))
if self.hovered == inside:
return # We have already done what was needed
@@ -87,11 +96,19 @@ class HoverBehavior(object):
Factory.register('HoverBehavior', HoverBehavior)
class ServerToolTip(Label):
class ToolTip(Label):
pass
class ServerToolTip(ToolTip):
pass
class HovererableLabel(HoverBehavior, Label):
pass
class ServerLabel(HovererableLabel):
def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
@@ -101,6 +118,7 @@ class HovererableLabel(HoverBehavior, Label):
def on_enter(self):
self.popuplabel.text = self.get_text()
App.get_running_app().root.add_widget(self.layout)
fade_in_animation.start(self.layout)
def on_leave(self):
App.get_running_app().root.remove_widget(self.layout)
@@ -109,8 +127,6 @@ class HovererableLabel(HoverBehavior, Label):
def ctx(self) -> context_type:
return App.get_running_app().ctx
class ServerLabel(HovererableLabel):
def get_text(self):
if self.ctx.server:
ctx = self.ctx
@@ -158,10 +174,11 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
""" Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, Label):
class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
""" Add selection support to the Label """
index = None
selected = BooleanProperty(False)
tooltip = None
def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """
@@ -169,6 +186,56 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']')
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:
tx, ty = self.to_widget(*pos, relative=True)
# Why TF is Y flipped *within* the texture?
ty = self.texture_size[1] - ty
hit = False
for uid, zones in self.refs.items():
for zone in zones:
x, y, w, h = zone
if x <= tx <= w and y <= ty <= h:
self.create_tooltip(uid.split("|", 1)[1], *pos)
hit = True
break
if not hit:
self.remove_tooltip()
def on_enter(self):
pass
def on_leave(self):
self.remove_tooltip()
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
@@ -179,7 +246,7 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
else:
# Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]")))
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
cmdinput = App.get_running_app().textinput
if not cmdinput.text and " did you mean " in text:
for question in ("Didn't find something that closely matches, did you mean ",
@@ -192,7 +259,7 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
elif not cmdinput.text and text.startswith("Missing: "):
cmdinput.text = text.replace("Missing: ", "!hint_location ")
Clipboard.copy(text)
Clipboard.copy(text.replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']'))
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
@@ -206,6 +273,25 @@ class ConnectBarTextInput(TextInput):
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
class MessageBox(Popup):
class MessageBoxLabel(Label):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
self.size = self._label.texture.size
if self.width + 50 > Window.width:
self.text_size[0] = Window.width - 50
self._label.refresh()
self.size = self._label.texture.size
def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width)+40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class GameManager(App):
logging_pairs = [
("Client", "Archipelago"),
@@ -248,8 +334,8 @@ class GameManager(App):
# top part
server_label = ServerLabel()
self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False,
write_tab=False)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.server_address or "archipelago.gg", size_hint_y=None,
height=30, multiline=False, write_tab=False)
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
@@ -302,7 +388,8 @@ class GameManager(App):
return self.container
def update_texts(self, dt):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if hasattr(self.tabs.content.children[0], 'fix_heights'):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \
@@ -325,6 +412,7 @@ class GameManager(App):
def connect_button_action(self, button):
if self.ctx.server:
self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect())
else:
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
@@ -358,6 +446,12 @@ class GameManager(App):
self.log_panels["Archipelago"].on_message_markup(text)
self.log_panels["All"].on_message_markup(text)
def update_address_bar(self, text: str):
if hasattr(self, "server_connect_bar"):
self.server_connect_bar.text = text
else:
logging.getLogger("Client").info("Could not update address bar as the GUI is not yet initialized.")
def enable_energy_link(self):
if not hasattr(self, "energy_link_label"):
self.energy_link_label = Label(text="Energy Link: Standby",
@@ -369,57 +463,24 @@ class GameManager(App):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
class FactorioManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
base_title = "Archipelago Factorio Client"
class ChecksFinderManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago ChecksFinder Client"
class SNIManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("SNES", "SNES"),
]
base_title = "Archipelago SNI Client"
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
class OoTManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Ocarina of Time Client"
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.INFO)
self.on_log = on_log
@staticmethod
def format_compact(record: logging.LogRecord) -> str:
if isinstance(record.msg, Exception):
return str(record.msg)
return (f'{record.exc_info[1]}\n' if record.exc_info else '') + str(record.msg).split("\n")[0]
def handle(self, record: logging.LogRecord) -> None:
self.on_log(self.format(record))
if getattr(record, 'skip_gui', False):
pass # skip output
elif getattr(record, 'compact_gui', False):
self.on_log(self.format_compact(record))
else:
self.on_log(self.format(record))
class UILog(RecycleView):
@@ -453,6 +514,34 @@ class E(ExceptionHandler):
class KivyJSONtoTextParser(JSONtoTextParser):
def __call__(self, *args, **kwargs):
self.ref_count = 0
return super(KivyJSONtoTextParser, self).__call__(*args, **kwargs)
def _handle_item_name(self, node: JSONMessagePart):
flags = node.get("flags", 0)
if flags & 0b001: # advancement
itemtype = "progression"
elif flags & 0b010: # useful
itemtype = "useful"
elif flags & 0b100: # trap
itemtype = "trap"
else:
itemtype = "normal"
node.setdefault("refs", []).append("Item Class: " + itemtype)
return super(KivyJSONtoTextParser, self)._handle_item_name(node)
def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"])
slot_info = self.ctx.slot_info.get(player, None)
if slot_info:
text = f"Game: {slot_info.game}<br>" \
f"Type: {SlotType(slot_info.type).name}"
if slot_info.group_members:
text += f"<br>Members:<br> " + \
'<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members)
node.setdefault("refs", []).append(text)
return super(KivyJSONtoTextParser, self)._handle_player_id(node)
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
@@ -464,6 +553,12 @@ class KivyJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node)
return self._handle_text(node)
def _handle_text(self, node: JSONMessagePart):
for ref in node.get("refs", []):
node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]"
self.ref_count += 1
return super(KivyJSONtoTextParser, self)._handle_text(node)
ExceptionManager.add_handler(E())

View File

@@ -26,15 +26,17 @@ name: YourName{number} # Your name in-game. Spaces will be replaced with undersc
game: # Pick a game to play
A Link to the Past: 1
requires:
version: 0.2.3 # Version of Archipelago required for this yaml to work as expected.
version: 0.3.3 # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
accessibility:
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
progression_balancing:
on: 50 # A system to reduce BK, as in times during which you can't do anything by moving your items into an earlier access sphere to make it likely you have stuff to do
off: 0 # Turn this off if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
progression_balancing: # A system to reduce BK, as in times during which you can't do anything, by moving your items into an earlier access sphere
0: 0 # Choose a lower number if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
25: 0
50: 50 # Make it likely you have stuff to do.
99: 0 # Get important items early, and stay at the front of the progression.
A Link to the Past:
### Logic Section ###
glitches_required: # Determine the logic required to complete the seed
@@ -167,15 +169,21 @@ A Link to the Past:
standard: 0 # Begin the game by rescuing Zelda from her cell and escorting her to the Sanctuary
open: 50 # Begin the game from your choice of Link's House or the Sanctuary
inverted: 0 # Begin in the Dark World. The Moon Pearl is required to avoid bunny-state in Light World, and the Light World game map is altered
retro:
on: 0 # you must buy a quiver to use the bow, take-any caves and an old-man cave are added to the world. You may need to find your sword from the old man's cave
retro_bow:
on: 0 # Zelda-1 like mode. You have to purchase a quiver to shoot arrows using rupees.
off: 50
hints: # Vendors: King Zora and Bottle Merchant say what they're selling.
# On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints.
retro_caves:
on: 0 # Zelda-1 like mode. There are randomly placed take-any caves that contain one Sword and choices of Heart Container/Blue Potion.
off: 50
hints: # On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints.
'on': 50
vendors: 0
'off': 0
full: 0
scams: # If on, these Merchants will no longer tell you what they're selling.
'off': 50
'king_zora': 0
'bottle_merchant': 0
'all': 0
swordless:
on: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
off: 1
@@ -268,6 +276,7 @@ A Link to the Past:
p: 0 # Randomize the prices of the items in shop inventories
u: 0 # Shuffle capacity upgrades into the item pool (and allow them to traverse the multiworld)
w: 0 # Consider witch's hut like any other shop and shuffle/randomize it too
P: 0 # Prices of the items in shop inventories cost hearts, arrow, or bombs instead of rupees
ip: 0 # Shuffle inventories and randomize prices
fpu: 0 # Generate new inventories, randomize prices and shuffle capacity upgrades into item pool
uip: 0 # Shuffle inventories, randomize prices and shuffle capacity upgrades into the item pool
@@ -444,6 +453,11 @@ A Link to the Past:
death_link:
false: 50
true: 0
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
# Off by default, because it currently crashes on real hardware.
false: 50
true: 0
linked_options:
- name: crosskeys
@@ -526,4 +540,4 @@ triggers:
percentage: 0 # AND has a 0 percent chance (meaning this is default disabled, just to show how it works)
options: # then inserts these options
A Link to the Past:
swordless: off
swordless: off

View File

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

105
setup.py
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
from typing import List
from typing import List, Iterable
import unittest
from worlds.AutoWorld import World
from Fill import FillError, balance_multiworld_progression, fill_restrictive, distribute_items_restrictive
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
ItemClassification
from worlds.generic.Rules import CollectionRule, locality_rules, set_rule
@@ -108,14 +109,16 @@ def generate_locations(count: int, player_id: int, address: int = None, region:
def generate_items(count: int, player_id: int, advancement: bool = False, code: int = None) -> List[Item]:
items = []
type = "prog" if advancement else ""
item_type = "prog" if advancement else ""
for i in range(count):
name = "player" + str(player_id) + "_" + type + "item" + str(i)
items.append(Item(name, advancement, code, player_id))
name = "player" + str(player_id) + "_" + item_type + "item" + str(i)
items.append(Item(name,
ItemClassification.progression if advancement else ItemClassification.filler,
code, player_id))
return items
def names(objs: list) -> List[str]:
def names(objs: list) -> Iterable[str]:
return map(lambda o: o.name, objs)
@@ -185,7 +188,7 @@ class TestFillRestrictive(unittest.TestCase):
items = player1.prog_items
locations = player1.locations
multi_world.accessibility[player1.id] = 'minimal'
multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal
multi_world.completion_condition[player1.id] = lambda state: state.has(
items[1].name, player1.id)
set_rule(locations[1], lambda state: state.has(
@@ -400,7 +403,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
basic_items = player1.basic_items
locations[1].progress_type = LocationProgressType.EXCLUDED
basic_items[1].never_exclude = True
basic_items[1].classification = ItemClassification.useful
distribute_items_restrictive(multi_world)
@@ -427,8 +430,8 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
locations[1].progress_type = LocationProgressType.EXCLUDED
locations[2].progress_type = LocationProgressType.EXCLUDED
basic_items[0].never_exclude = True
basic_items[1].never_exclude = True
basic_items[0].classification = ItemClassification.useful
basic_items[1].classification = ItemClassification.useful
self.assertRaises(FillError, distribute_items_restrictive, multi_world)
@@ -569,7 +572,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
multi_world, 2, location_count=5, basic_item_count=5)
for item in multi_world.get_items():
item.never_exclude = True
item.classification = ItemClassification.useful
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
@@ -584,7 +587,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
class TestBalanceMultiworldProgression(unittest.TestCase):
def assertRegionContains(self, region: Region, item: Item):
def assertRegionContains(self, region: Region, item: Item) -> bool:
for location in region.locations:
if location.item and location.item == item:
return True
@@ -592,7 +595,7 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
self.fail("Expected " + region.name + " to contain " + item.name +
"\n Contains" + str(list(map(lambda location: location.item, region.locations))))
def setUp(self):
def setUp(self) -> None:
multi_world = generate_multi_world(2)
self.multi_world = multi_world
player1 = generate_player_data(
@@ -625,13 +628,12 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
# Sphere 3
region = player2.generate_region(
player2.menu, 20, lambda state: state.has(player2.prog_items[0].name, player2.id))
items = fillRegion(multi_world, region, [
player2.prog_items[1]] + items)
fillRegion(multi_world, region, [player2.prog_items[1]] + items)
multi_world.progression_balancing[player1.id] = True
multi_world.progression_balancing[player2.id] = True
def test_balances_progression(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 50
self.multi_world.progression_balancing[self.player2.id].value = 50
def test_balances_progression(self):
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
@@ -640,7 +642,48 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
self.assertRegionContains(
self.player1.regions[1], self.player2.prog_items[0])
def test_ignores_priority_locations(self):
def test_balances_progression_light(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 1
self.multi_world.progression_balancing[self.player2.id].value = 1
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
balance_multiworld_progression(self.multi_world)
# TODO: arrange for a result that's different from the default
self.assertRegionContains(
self.player1.regions[1], self.player2.prog_items[0])
def test_balances_progression_heavy(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 99
self.multi_world.progression_balancing[self.player2.id].value = 99
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
balance_multiworld_progression(self.multi_world)
# TODO: arrange for a result that's different from the default
self.assertRegionContains(
self.player1.regions[1], self.player2.prog_items[0])
def test_skips_balancing_progression(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 0
self.multi_world.progression_balancing[self.player2.id].value = 0
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
balance_multiworld_progression(self.multi_world)
self.assertRegionContains(
self.player1.regions[2], self.player2.prog_items[0])
def test_ignores_priority_locations(self) -> None:
self.multi_world.progression_balancing[self.player1.id].value = 50
self.multi_world.progression_balancing[self.player2.id].value = 50
self.player2.prog_items[0].location.progress_type = LocationProgressType.PRIORITY
balance_multiworld_progression(self.multi_world)

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