Compare commits

...

94 Commits

Author SHA1 Message Date
Fabian Dill
1db6b67953 Tests: load custom tests from apworld 2023-07-01 02:41:51 +02:00
Trevor L
fa3c132304 Hylics 2: Add missing location (#1917)
* Hylics 2: Add missing location

* Hylics 2: Change data_version
2023-06-30 17:46:32 -05:00
digiholic
6226713c4d MMBN3: Press program now has proper color index when received remotely (#1918) 2023-06-30 17:34:53 -05:00
Justus Lind
b56da79890 Muse Dash: Add 2023 Anniversary songs and remove a hidden song (#1916)
* Remove CHAOS Glitch. Add test to check for removed songs.

* Add to game list

* Fix oversight with 0 difficulty songs. Fix naming of test.

* Add new songs and update other data.

* Fix accidental copy paste
2023-06-30 08:10:58 -05:00
PoryGone
1d6345d3a2 SA2: Add troubleshooting note about Skip Intro and Cutscene Traps (#1915) 2023-06-29 22:28:08 -05:00
el-u
51a639ceaf lufia2ac: use an appropriate dungeon sprite and battle theme for each boss (#1914) 2023-06-29 22:21:46 -05:00
digiholic
7ecb1e6d6c Docs: Adds MMBN3 to Readme.md (#1912) 2023-06-29 15:01:37 -05:00
Zach Parks
c9fb443c64 OriBF: Move Ori and the Blind Forest to worlds_disabled. (#1906)
* OriBF: Move Ori and the Blind Forest to `worlds_disabled/`

* Add readme for `worlds_disabled` folder

* fix link

* fix link 2

* Remove useless comment

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-06-29 13:36:48 -05:00
digiholic
325299286b Mega Man Battle Network 3: Implement New Game (#1198)
* Initializes MMBN3 world with empty files

* Adds item names to item dict

* Adds locations and names

* Adds skeleton of MMBN3Client. Mostly copy pasta from OOT

* Fixed some style and formatting

* More incremental Lua tests

* Adds all locations and checking to Lua connector

* Made class definitions for TextPet Parser

* Begun connecting item delivery system through lua and textpet

* Lua Connection can now send test items

* Item Delivery is now parameterized. Test command can send any chip

* Adds the ability to send non-chip items

* Fixes name errors in python client

* Fixes count for zenny, attempts to fix bugfrags

* Fixes an issue where you always received 255 bugfrags

* Converts zenny and bugfrag amounts to little endian bytecode

* Checks game state before sending chips

Adds debug option to display information overlayed on rom
Fixes chip indexing issue for chips with ids over 255
Minor text fixes

* Adds in some animation reset instructions during item get message

* Stores previously collected item index in save, re-sends missing items

* Adds title screen check before sending locations

Loading items from save could not be done via RAM. Had to be added in
assembly

* Adds progressive undernet check

* Added library for lzss decoding bits of rom

* More progress on parsing text events from ROM

* Adds a way to inject messages into ScriptArchive data structure and generate bytecode

* Adds Item definitions, passes to client

* Adds regions and item collection rules

* Touched up a few names and values that have changed in preparation for the final patching

* Modifying messages via item is now successful

* Added generate_output hook to generate ROM data

* Generates ROM successfully

* Fixes navi cust give index

* Whoops forgot to wrap this in brackets

* Injects extra scripts for undernet rankings

* Programs had ammount and color swapped

* Prompts the user for their username when connecting

* Adds flagClear to the list of commands to avoid overwriting

* Fixes message box crashes and several other multiworld issues

* Fixes IDs and names of several items and locations

* Added .gba to gitignore

* Fixes compatibility after recent rebase

* Fixes some locations and items that are otherwise unobtainable

* Attempts to make a working launcher in the installer

* Creates installer and fixes several inaccessible locations

* Many minor changes to items, locations, and requirements made during testing

* Adds an info page for MMBN3

* Fixes failing tests by removing duplicate IDs and properly marking progression items

* Accidentally forgot to un-remove the thing

* Whoops, changed this by accident

* Updates self.world references to self.multiworld

* Fixes imports to use from imports instead of using the namespace

* Removed some leftover merge artifacts from inno setup

* Puts back that darned signtool line again

* Adds Overworld Metro keys as items

* Adds TamaCode and puts shortcuts behind cyber passes

* Fixes Numberman code 16 check

* Fixes metro access logic and adds text to metro

* Reworks Lua to fix crashing when many items are queued

* Items for other BN3 games for different players are no longer given in the main player's ROM as well

* Fixes incorrect Item ID for ACDC Metro

* Fixes multi-box text messages

* Adds timer before sending an item

* Forgot to remove the second box of SubMems

* Updates patch and lua to prevent softlocks and crashes

* Adds options for extra undernet ranks, exclude jobs

* Extra GigFreez now gives 20 bugfrags

* Additional Progressive Undernets can no longer appear on the WWW Base

* Moves item signal byte to empty area of flags instead of end of RAM

* Adds Chocolate Shop locations and navi chips to fill them

* Fixes save crash, and added chocolates to lua

* Fixes chocolate stand selling out text, removes DrillMan cube in Undernet

* Replaces old messaging system with direct memory manipulation for receiving items

* Removes NDSPY requirements from MMBN3 by manually adapting the GBA's lz10 algorithm

* Fixes the names of Hospital-1 Locations

* Adds Canary Bit to avoid sending checks when title screen check fails

* Gaining a cybermetro pass will now open the shortcut immediately

* Randomizes the two accessible areas of Undernet 7, adds Hammer as item

* Adds new locations to connector lua

* Injects the name of the item into trade quests

* Fixes copy-paste error in docs

* Fixes merge artifacts and depracated code

* Nut-wafer stand now faces Lan the right way after buying

* Removes unused Goal Option and updates the readme to include most recent changes

* Touch-ups and formatting changes

* The Great Fillerization update. Dozens of items changed to Filler

* Replaces instances of Mega Man with MegaMan

* Update worlds/mmbn3/docs/en_MegaMan Battle Network 3.md

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* Update worlds/mmbn3/__init__.py

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Changes code ordering to suit base class's

* assert_generate now checks for roms. Minor text fixes

* Makes player specific frequency and excluded location options

* Apply suggestions from code review

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* Addresses suggested changes from PR review

* Replaces ndspy lz10 with MIT-compliant nlzss lz10

* apworld compatibility fix for mmbn3_options from utils

* Addressing more comments by el-u

* APworld will now pull patch from zip folder

* Apply suggestions from code review

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* Cleaned up comments for progressive undernet ROM function, moved index list to field to avoid re-initializing

* Removes improper player-indexed location/item dicts, replaces with world member variables

* Avoids redefining list in progressive undernet ROM function

* Filler items can no longer be generated beyond their specified amounts

* Fixes list copying issue with item frequencies

* Adds BN3 Client Generation back into Launcher settings

* Fixes typos causing huge problems

* Fixed non-relative import for apworld

* Removes custom enum implementation that broke pickle

* Displays message when attempting to load an incorrect ROM, will not attempt to patch it

* Filler items can now only be placed once

* Changes path in setup doc to match Lua path changes

* Fixes file extension for MMBN3 file

* Replaces magic number with reference to value in NetUtils

* Moves victory rules to set_rules. Removes commented out code

* Rewrites Lua script to send block of memory

* Fixes off-by-one error in sending bytes for locations

* Fixes issue with invalid characters in text parsing, and WWW monitor text box parsing

* Moves trade text injection to init so it has access to options

* Attempts to split the text boxes for hinted items

* Trade checks now provide hints if the option is set for them

* Fixes escape character issue for BizHawk 2.9.1

Something in Bizhawk lua parsing changed to dislike the escaped tilde.
I'm not even entirely sure why it was escaped in the first place, but
this should fix the compatibility of it.

* Re-adds desk check that it turns out actually does exist

* Updates requirements to mention bizhawk 2.7 instead of 2.3.1

* Fixes off-by-one error in command byte counts

* Fixes program color indices

* Fixes newline PEP violations

* Reverts an accidental whitespace change made to launcher.py

* Fixes URL formatting on link to settings from setup guide

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

* Splits several lines in the readme to avoid excessive length

* Fixes formatting and (hopefully) reduces cringe of joke in setup doc

* Removes unnecessary constructor

* Changes item frequency generation to avoid reusing the same references

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

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
2023-06-29 13:36:01 -05:00
Alchav
776b5fab7c LTTP/SM/SMZ3: Show correct item icon for cross-game items (#1112)
Co-authored-by: lordlou <87331798+lordlou@users.noreply.github.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2023-06-29 17:47:21 +02:00
Fabian Dill
18e0d25051 Factorio: fix resync not reconciling divergent history 2023-06-29 15:15:12 +02:00
el-u
dfb3df4a8f lufia2ac: coop support + update AP version number to 0.4.2 (#1868)
* Core: typing for async_start

* CommonClient: add a framework for clients to subscribe to data storage key notifications

* Core: update version to 0.4.2

* lufia2ac: coop support
2023-06-29 08:06:58 -05:00
lordlou
d0db728850 SM: 0.4.1 Fixes and Additional Objective Options (#1859)
* 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

* fixed broken Item links

* fixed failing generation that could happen with Disabled Tourian

fixed shared Location list that could be modified for each world

* added missing force disable of EscapeRando if an escape solution cant be found

* fixed broken animal surprise patches

* prevent receiving items when in the first room of Ceres (message box in mode7 is broken)

* fixed generating with "activate chozo robots" Objective

* added soft reset that saves to initial starting location

reverted code change applied to fix softlocks from comeback checks
reverted forcing all beam local when using door rando

* replaced "save and reset" with "save and fast reload" (using same Start+Select+L+R)

* added documentation about Save and Reload

removed forgotten docstring about forcing beams as local items when using door rando

* fixed frequent failing generation on WebHost (KeyError: 'Kraid')

* added "objectiveRandom", "nbObjective", objectiveList and adapted Objective selection options to better reflect VARIA's.

fixed "collect 100% items" not being excluded when objectiveRandom is used
added Exception when VARIA initial layout fails

* fixed broken non-AP items

fixed determinism caused by the use of a set

* fixed generation failing on Webhost with string as a OptionSet (replaced default with a list of string)

cleaned doc and naming of Objective related Options
2023-06-29 07:51:09 -05:00
Justus Lind
77b0852dca Muse Dash: Add New Game (#1723)
* Alpha 1 Muse dash stuff.

* Add in an option to limit to only base game songs.

* Make all items progression instead of progression_skip_balancing.

* Add in extra_goal_song_items to help make runs less about completing every song.

* Change ID range to be in a more open area, and add some comments.

* Add in Streamer Mode and difficulty range options. Rearrange data files so its easier to get all data at once.

* Fix generation issues.

* Fix up the maximum and remove old option.

* Remove empty items and the option to make filler songs empty.

* Support emerald hunt mode. Make difficulties an option rather than 2 sliders.

* Fix DLC Song option being inverted.

* Fix item counting being broken if there was more than 1 world.

* Make compatible with .apworld specification.

* Make All item names ASCII compatible.

* Add in the additional_item_percentage option.

* Add a test to ensure the item names are within the normal ascii range.

* Add in death link.

* Remove the album from the item name. Not really needed anymore.

* Add the 2 budget is burning albums under the free songs heading. Adds a couple more songs without dlc.

* Sanitise Album names.

* Added the grade needed choice.

* Update songs to v3.1.0

* Adjust difficulty ranges. Add Expert and Master.

* Fix setup_en.md being out of date.

* Add a manual override.

* Add testing for diff ranges. Fix bugs introduced there. Limit option to 11 to not generate an impossible seed.

* Remove regions from Muse Dash.

* Some Oops...

* Attempt to make tests happy.

* Remove supports weighting false to stop webhost test failing.

* Adjusted settings

* Adjust music sheets to use percentages. Various cleanups.

* Fixes to new code.

* Add Ola Dash Album. Add support for overriding song difficulty. Other stylisation changes.

* Attempt fix tests.

* Ooops missed one.

* flake8 suggestions.

* Remove FM 17314 SUGAR RADIO as that song is a bit weird.

* Update document pages.

* Add trap support

* Lower additional song count by 10.

* Tests broke on my end. Using github to test this.

* Looks like I was accidentally adding ~.

* Fix the one song that crashes OoT hint generation

* Various documentation changes.

* Website documents fixup.

* Doc updates part 2.

* Oops. Doc updates part 3.

* Add Muse Dash to the apworld list.

* Add trailing comma.

* Add a couple plando options.

* Set data_version to 1.

* Add in some handling incase someone decides a song is both starter and included.

* Remove brackets around ifs.

* Oops. Accidentally removed a necessary bracket.

* Fix filtering crash due to me mixing up c# and python .remove().

* Add Happy Otaku Pack Vol.17. Also increment data version.

* Update links to melon loader to be the latest.

* Clean up song selection code by shuffling once then popping.

* Add UID to the Data text file, so the same file can be used client and server.

* Increment Data Version because some names have changed.

* Correct some names.

* Update data to v3.4.0 (Addition of Muse Radio FM104)

* Add support for SFX traps. Adjusted how traps were setup a bit.

* Update the docs to include a troubleshooting section.

* Small fixes.

* Remove unnecessary brackets.

* Add .net downloads to docs.

* Avoid failing generation if strict difficulty settings are applied with no dlc songs and streamer mode.

* Forgot to add the worst starting song count.

* Make minimum song count be Starting Songs + 11 instead of Starting Songs * 2 + 1.

* Fix up several issues where song count could mismatch the requested amount.

* Add a test to ensure world size doesn't grow.

* Fix some oversights.

* Remove unnecessary brackets.

* Fix up passing the tuple out when just the key would suffice.

* Adjust typing based on Phar's suggestions.

* Apply the rest of Phar's suggestions with minor tweaks to other parts to suit suggestions.

* Adjust some more stuff to fit 120 characters.

* Some more pep8 stuff and fix tests.

* Some pep8 in tests.
2023-06-29 07:36:39 -05:00
Aaron Wagener
3fba94f000 The Messenger: strip generated filler items for a sufficiently small pool (#1907)
* The Messenger: strip generated filler items for a sufficiently small remaining item pool

* rewrite the test for the small chance there's no large currency shards
2023-06-29 07:33:37 -05:00
William Quelho Ferreira
85582b9458 TLoZ: fix LauncherComponents entry (#1908) 2023-06-28 19:06:45 -05:00
Aaron Wagener
122d404145 Docs: rework main ap setup guide (#1853)
* rework main ap setup guide

* review updates

* add blurb about re-opening rooms and user-content

* more review suggestions

* remove dead links. Windows blurb
2023-06-28 19:06:18 -05:00
Aaron Wagener
07e3fbe845 Docs, LTTP: clarify not using qusb and remove redundancies (#1373)
* clarify not using qusb and remove redundancies

* SNES mini note

* review suggestions

* remove remaining repetitive text

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-28 00:11:06 -05:00
Kory Dondzila
76cace725b WebHost: Fixes multi-tracker checks sorting. (#1893) 2023-06-27 20:40:29 -05:00
NewSoupVi
99656bf059 The Witness: Utils.cache_argsless -> functools.lru_cache (#1897)
* Changed Utils.cache_argsless to functools.lru_cache

* Revmoed unused variable

* Removed remaining direct reference to a .txt outside utils

* Update worlds/witness/utils.py

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-06-28 02:23:50 +02:00
Aaron Wagener
332eab9569 The Messenger: Add Shop Rando (#1834)
* add shop shuffle options and items

* add logic for the shop slots

* write cost tests

* start on shop item logic

* make strike and second wind early items

* some cleanup

* remove 5 shards

* double cost requirement for really expensive items and raise the rates

* add test for shop shuffle with minimum other locations

* put power seal in front of shards

* rename locations and items

* update rules, regions, and shop

* update tests and misc fixes

* minor cleanup

* implement money wrench and figurines

* clean out now unneeded info from slot_data

* docs update and fix a failure when not shuffling shops

* remove shop shuffle option

* Finish out shop rules

* make seals generation easier to read and fix tests

* rule adjustments

* oop

* adjust the prices to be a bit more generous

* add max price to slot data for tracker

* update the hard rules a bit

* remove unnecessary test

* update data_version

* bump version and remove info for fixed issues

* remove now unneeded assert

* review updates

* minor bug fix

* add a test for minimum locations shop costing

* minor optimizations and cleanup

* remove whitespace
2023-06-28 01:39:52 +02:00
zig-for
8c2584f872 LADX: 16 bits for the check ID (#1903) 2023-06-27 16:39:57 -05:00
PoryGone
1ced726d31 SA2B: v2.2 Content Update (#1904)
* Ice Trap Support

* Support Animalsanity

* Add option for controlling number of emblems in pool

* Support Slow Trap

* Support Cutscene Traps

* Support Voice Shuffle

* Handle Boss Rush goals

* Fix create item reference to self.multiworld

* Support Ringlink

* Reduce beep frequency to 20

* Add Boss Rush Chaos Emerald Hunt Goal

* Fix Eternal Engine - Pipe 1 logic

* Add Chao voice shuffle

* Remove unused option

* Adjust wording of Required Cannon's Core Missions

* Fix incorrect region assignment

* Fix incorrect animal logics

* Fix Chao Race tooltip

* Remove Green Hill Animal Location

* Add Location Count info to tooltips

* Don't allow M4 first if animalsanity is active

* Add Iron Boots to Standard Logic Egg Quarters 5

* Make Vanilla Boss Rush actually Vanilla

* Increment Mod Version

* Increment Data Package Version

---------

Co-authored-by: RaspberrySpaceJam <tyler.summers@gmail.com>
2023-06-27 16:38:58 -05:00
Freya Arbjerg
d51e0ec0ab WebHost: Align multitracker status column to the left (#1645)
* Align multitracker status column to the left

* Move 'Status' column to after 'Game' column
2023-06-27 17:37:01 -04:00
Felix R
36b5b1207c Add Bumper Stickers (#811)
* bumpstik: initial commit

* bumpstik: fix game name in location obj

* bumpstik: specified offset

* bumpstik: forgot to call create_regions

* bumpstik: fix entrance generation

* bumpstik: fix completion definition

* bumpstik: treasure bumper, LttP text

* bumpstik: add more score-based locations

* bumpstik: adjust regions

* bumpstik: fill with Treasure Bumpers

* bumpstik: force Treasure Bumper on last location

* bumpstik: don't require Hazard Bumpers for level 4

* bumpstik: treasure bumper locations

* bumpstik: formatting

* bumpstik: refactor to 0.3.5

* bumpstik: Treasure bumpers are now progression

* bumpstik: complete reimplementation of locations

* bumpstik: implement Nothing as item

* bumpstik: level 3 and 4 locations

* bumpstik: correct a goal value

* bumpstik: region defs need one extra treasure

* bumpstik: add more starting paint cans

* bumpstik: toned down final score goal

* bumpstik: changing items, Hazards no longer traps

* bumpstik: remove item groups

* bumpstik: update self.world to self.multiworld

* bumpstik: clean up item types and classes

* bumpstik: add options
also add traps to item pool

* bumpstik: update docs

* bumpstik: oops

* bumpstik: add to master game list on readme

* bumpstik: renaming Task Skip to Task Advance
because "Task Skip" is surprisingly hard to say

* bumpstik: fill with score on item gen
instead of nothing (nothing is still the default filler)

* bumpstik: add 18 checks

* bumpstik: bump ap ver

* bumpstik: add item groups

* bumpstik: make helper items and traps configurable

* bumpstik: make Hazard Bumper progression

* bumpstik: tone final score goal down to 50K

* bumpstik: 0.4.0 region update

* bumpstik: clean up docs
also final goal is now 50K or your score + 5000, whichever is higher

* bumpstik: take datapackage out of testing mode

* bumpstik: Apply suggestions from code review

code changes for .apworld support

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

---------

Co-authored-by: Zach Parks <zach@alliware.com>
2023-06-27 15:37:17 -05:00
Fabian Dill
a4e485e297 Launcher: keep alive (#1894) 2023-06-27 09:30:54 +02:00
kindasneaki
a7bc8846cd RoR2: bug fixes (#1891)
* adding back parens that got deleted by accident.

* Void Locus and The Planetarium ids backwards

* change required client version

* beads of fealty was missing for A Moment, whole victory

* found another logic bug

* Update worlds/ror2/__init__.py

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

* Remove unnecessary comment

---------

Co-authored-by: Zach Parks <zach@alliware.com>
2023-06-26 23:47:52 -05:00
Fabian Dill
125ee8b198 WebHost: fix dict lookup exceptions 2023-06-27 04:39:21 +02:00
Mewlif
553fe0be19 Undertale for AP (#439)
Randomizes the items, and adds a new item to the pool, "Plot" which lets you go further and further in the game the more you have.

Developers: WirTheAvali (Preferred name for professional use, mewlif)
2023-06-27 04:35:41 +02:00
Zach Parks
71bfb6babd Generate: Add skip progression balancing argument. (#1876) 2023-06-26 16:14:01 -05:00
James Groom
1698c17caa Docs: Revise all docs mentioning Lua in EmuHawk (which are in English), and other misc. corrections (#1782)
* Fix links to TASVideos.org using HTTP

* Revise all docs mentioning Lua in EmuHawk which are in English

resolves TASEmulators/BizHawk#3650

* Correct capitalisation of "BizHawk"

in strings and camelCase identifiers

* Use the term "EmuHawk" when referring to the app, in English docs

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-26 08:53:44 +02:00
black-sliver
751e5cec63 Ori: fix py3.8 apworld compatibility 2023-06-26 08:16:56 +02:00
NewSoupVi
dc46e96e3f Witness: APworld compatibility, but for real this time (#1896)
* removed relative imports from outside the witness package

* Remove Witness from the apworld shame list
2023-06-26 00:38:39 +02:00
NewSoupVi
0934e5c711 The Witness: Fixed seeds not generating with vanilla logic (#1895)
Yikes, I swear I ran like 15 generations with a random yaml, I got so unlucky
2023-06-26 00:20:28 +02:00
Fabian Dill
aa8ffa247d Setup: flip apworld list (#1882)
* Setup: flip apworld list

* Update setup.py

Co-authored-by: kindasneaki <ryandj67@hotmail.com>

* Update setup.py

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* setup: make TLoZ an apworld

This reverts commit fd026c5eb2.

---------

Co-authored-by: kindasneaki <ryandj67@hotmail.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-25 03:47:38 +02:00
black-sliver
a45e8730cb Fill: fix fill_restrictive for mixed minimal and non-minimal and test (#1800)
* Tests: add test for mixing minimal and non-minimal

* Tests: minor cleanup in test_minimal_mixed_fill

* fix fill_restrictive for mixed minimal/non-minimal

The reason why this only happens for minimal is because it would not accept the solution it found otherwise.
Tracking and releasing unreachable items would be the better solution, but that's a lot harder to do.

* fix typo in fill_restrictive

* fix pep8 in fill_restrictive

* Fill: cleanup invalid unsafe placements, better comments

* Fill: more cleanup
2023-06-25 02:55:13 +02:00
Fabian Dill
46f2f3d7cd Factorio: Client in folder, TextClient: always available (#1829)
* Factorio: move Client into world folder

* Factorio: declare Client as Client Component

* FactorioClient: use centralized launch_subprocess

* TextClient: make always available
2023-06-25 02:31:25 +02:00
black-sliver
a96ff8de16 Linux: add freeze_support, Launcher: use spawn (#1890) 2023-06-25 02:24:43 +02:00
agilbert1412
f3e2e429b8 DLC Quest: Option Documentation improvements (#1887) 2023-06-25 02:13:33 +02:00
NewSoupVi
46b13e0b53 Witness: apworld support (#1885) 2023-06-25 02:00:56 +02:00
t3hf1gm3nt
7a4e903906 TLOZ: APworld support (#1884)
- Remove a relative import in Rules.py
- Clean up a few unused imports in __init__.py
- Use pkgutil instead of open when applying base patch
- make sure rom_name is initialized correctly in modify_multidata

* use os.path.join() instead of explicit "/"
2023-06-25 01:58:54 +02:00
Aaron Wagener
f1ccf1b663 reenable ping 2023-06-25 01:24:39 +02:00
NewSoupVi
ec0822c5eb Docs: Mention Git in the "Optional" section of "Running from Source" (#1880)
* Docs: Mention Git in the "Optional" section of "Running from Source"

GIt is required to install the Zilliandomizer package.

Also, this is probably just nice to have.

* Remove mention of Zillion so the text doesn't need updating.

* Update docs/running from source.md

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

* Update docs/running from source.md

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

* Mention PyCharm's git integration

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-24 12:59:14 +02:00
Fabian Dill
78b981228a Generate: improve error message for missing game (#1857)
---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-23 10:17:35 +02:00
NewSoupVi
f3c788d0cc Witness: Fix missing location
All Pressure Plates puzzles are now always locations.

This makes a line where a Pressure Plates location gets added and a different one cause incorrect behavior.
2023-06-23 10:16:39 +02:00
Zach Parks
59ad9e97e5 WebHost: Fix special-range value setting to custom when randomization is toggled off (#1856)
* WebHost: Fix custom-range value setting to `custom` when randomization is toggled off

* Remove redundant code

* Add optional parameter default
2023-06-22 22:12:22 -04:00
StripesOO7
abd8eaf36e WebHost: Change default spoiler-option for games generated from WebHost to 3 instead of 0 (#1852)
* Change default spoiler-option in WebHostLib/generate.py to 3 instead of 0

* shifting spoiler-default to the JS calls instead of setting it in generate.py

---------

Co-authored-by: StripesOO7 <54711792+StripeesOO7@users.noreply.github.com>
2023-06-22 21:01:09 -05:00
black-sliver
f36468fc25 Docs: add info about maintaining worlds (#1838)
* Docs: add info about mainting worlds

* Docs: fix typos in world maintainer

* Docs: commit suggestions into world maintainers

Thanks Joethepic and Silvris

* Docs: fix more typos in world maintainer

* Docs: more typos

* Docs: world maintainers link to core maintainers

* Docs: world maintainers voting on discord

* Docs: add 'world maintainer' link to 'adding games'

* Docs: unmaintained worlds in 'disabled'

* Docs: world maintainer update from review

Thanks LegendaryLinux

* Doc: rephrase world maintainer voting
2023-06-22 08:51:02 +02:00
black-sliver
a939f50480 Clients: use certifi (#1879)
* Clients: use certifi for wss

On Windows, the local cert store might be outdated and refuse connection to some servers.

* Clients: lazily create ssl_context
2023-06-22 00:01:41 +02:00
Fabian Dill
b04b105bd8 LADX: use custom collect/remove to keep track of logical rupee counts instead of LogixMixin
May contain some pep8, sorry
2023-06-21 12:42:11 +02:00
NewSoupVi
845502ad39 The Witness: Hint distribution changes, added locations, misc fixes (#1785)
Changes:

* Hints should feel a lot less same-y now ("Priority hints" are no longer always hints in disguise)
* Keep Hedge Mazes 1-3 and Pressure Plates 1-3 are added as locations in all settings
* Desert Final Room Hexagonal & Desert Final Room Bent 3 are added as locations
* Entries in exclude_locations that are referring to panels are now sent through slot data. This means they can be pre-skipped on the client side.

Fixes:

* Logic error in the Stoneworks that led to more restrictive seeds than necessary
* Logic error for Theater Flowers EP that led to more restrictive seeds than necessary
* Fixed crash in plando when "item" is a dict with weights
* Spoiler log locations were in random order per region, now they are consistent
2023-06-21 00:45:26 +02:00
Sunny Bat
afe9e12ef4 Raft: Fix item prefilling (#1878) 2023-06-20 09:14:46 +02:00
Fabian Dill
a75159b57e WebHost: import Markup from markupsafe (#1848) 2023-06-20 01:01:42 +02:00
Fabian Dill
61fc80505e Core: refactor some loading mechanisms (#1753)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-20 01:01:18 +02:00
Fabian Dill
25f285b242 Launcher: deprecate FUNC Component type (#1872)
* Launcher: add hidden component type

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-19 09:57:17 +02:00
Fabian Dill
c4e28a8736 Setup: pin cx-Freeze to latest working version 2023-06-19 00:40:14 +02:00
Fabian Dill
422ccdaa4c WebHost: remove some unused imports 2023-06-18 22:56:55 +02:00
Bicoloursnake
1e7c650159 Docs: Updating the macOS guide for 'New Terminal at Folder' (#1865)
* Update mac_en.md

Added an alternate option to simply terminal navigation

* Update worlds/generic/docs/mac_en.md

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-06-18 13:21:12 +02:00
Aaron Wagener
ab64173600 SoE/SNIClient: auto launch SNI before browser when SNIClient patched (#1861)
* auto launch SNI before browser

* launch emulator too :)

* don't infinitely await sni connection
2023-06-18 11:27:08 +02:00
Fabian Dill
36499b8983 Setup: delete outdated Enemizer and SNI files 2023-06-17 00:56:13 +02:00
NewSoupVi
923ff033b1 The Witness: Logic Fix: Vanilla First Wooden Beam (#1867) 2023-06-15 00:30:50 +02:00
Sunny Bat
599d0ac81b Raft: Small website/code touchups (#1866)
* Remove unnecessary Set

* Ocean theme

* Use create_items instead of generate_basic
2023-06-15 00:30:14 +02:00
Ziktofel
ce2433b247 SC2: Python 3.11 compatibility (#1821)
Co-authored-by: Salzkorn
2023-06-12 07:41:53 +02:00
Scipio Wright
f6cb90daf9 Noita: Region connection edits (#1855)
Shifts the Lake region to be connected to The Laboratory, so that the Lake boss is late game instead of early game.
Shifts the Below Lava Lake region to be connected to the Snowy Depths, so instead of being early game it's early-mid game (since that's when you would be expected to be able to have decent enough digging or a Sädekivi.
2023-06-05 19:32:33 +02:00
el-u
54b200451d Docs: Fix typo in world api.md (#1854) 2023-06-01 22:56:44 -05:00
Exempt-Medic
b98080afee Docs: Update YAML planning guide (#1845)
* [Docs] Update YAML planning guide

Changed wording for items accessibility to describe how it actually works. Reordered settings such as local_items and start_location_hints to match their order in templates. Fixed some grammatical errors.

* Fix typo

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

* Update doc

Moved `accessibility`, `progression_balancing`, and `triggers` to game sections instead of root sections and reworded description accordingly. Updated version number. Fixed `progression_balancing` values in example YAMLs.

* Indented trigger to be part of ALTTP

---------

Co-authored-by: Zach Parks <zach@alliware.com>
2023-06-01 22:33:12 -05:00
Exempt-Medic
5401e485aa Blasphemous: Logic fixes for WotBC Cherub and Jondo upper west tree root (#1835) 2023-06-01 03:52:46 +02:00
Fabian Dill
58cf9783eb Tests: make names more unique 2023-06-01 01:45:24 +02:00
Fabian Dill
fad0fe16f4 Tests: sort custom loaded tests (#1851) 2023-06-01 01:44:54 +02:00
kindasneaki
c2884e9eb0 RoR2: Victory Conditions Doc Update (#1833) 2023-05-31 18:38:03 -05:00
Doug Hoskisson
1809823308 Zillion: cache key includes gun requirement (#1846)
The key for the logic cache was missing some important information, so it was yielding a cache hit when it should have been a miss.
2023-05-31 05:56:23 +02:00
lordlou
df7462efcc SMZ3 decoding fix (#1847) 2023-05-30 03:05:05 +02:00
FlySniper
00e3c44400 Wargroove: Fixed commander.json file never being closed by the mod (#1841)
The Wargroove mod didn't close the commander.json's file handle. The Wargroove mod will now close that file handle. The change for the mod can be viewed here: FlySniper/WargrooveArchipelagoMod@fc9aeb3
The change can be verified as present in this repository by viewing the binary data in the modAssets.dat file and searching for "commander.json"
2023-05-29 20:33:35 +02:00
agilbert1412
abf4b3bcbc Stardew valley: Fix package and imports for apworld linux (#1842)
- Fix csv load to use explicitly imported self package instead of keyword __package__
- Fix init.py having a relative import to outside of the apworld
2023-05-29 01:00:33 +02:00
Fabian Dill
c9f217943e LttP: fix patching crash if old always_apply adjuster settings were applied 2023-05-25 14:08:56 +02:00
Fabian Dill
e9f8b1ed28 WebHost: use Py3.11 compatible ponyorm 2023-05-25 14:07:21 +02:00
el-u
c46d8afcfa Core: clean up BaseClasses a bit (#1731) 2023-05-25 01:24:12 +02:00
ScootyPuffJr1
f4d9c294a3 [SM] Minor update to link in Options.py (#1831) 2023-05-23 16:30:39 +02:00
Exempt-Medic
42d8fb8409 [Blasphemous] Various logic fixes (#1830)
This makes a few changes to logic to better match the 1.3 rando's logic. This fixes instances where the wrong items were expected, fixes a typo of "Lorqiana", moves the expert logic on "PotSS: Second area ledge" to only apply if on expert, and adds a new route to "DC: Mea Culpa altar" via Linen of Golden Thread + Three Gnarled Tongues
2023-05-22 19:03:21 +02:00
axe-y
127d4812b5 DLCQuest: Fix Documentation Broken Link 2023-05-21 15:48:56 +02:00
Fabian Dill
527f30d91a Core: log race mode enabled 2023-05-21 05:02:14 +02:00
Fabian Dill
1d565b9aaf WebHost: add game to template export 2023-05-21 05:01:56 +02:00
Fabian Dill
6814bc158a WebHost: index columns used by landing page. 2023-05-21 05:01:29 +02:00
alwaysintreble
e80f3206b6 The Messenger: override start_inventory description (#1695)
* The Messenger: override start_inventory description

* use StartInventoryPool directly
2023-05-21 02:54:50 +02:00
el-u
54ea917c48 CI: treat all files as modified on new branches (#1826) 2023-05-20 21:57:38 +02:00
Exempt-Medic
5e9bf4b007 Docs: Update world api excluded/priority locations description (#1807)
* Update world api doc

Changed the description of excluded and priority locations to match how they appear in other places such as the options api doc

* Update world api.md
2023-05-20 20:04:26 +02:00
Fabian Dill
c8453035da LttP: extract Dungeon and Boss from core (#1787) 2023-05-20 19:57:48 +02:00
Fabian Dill
a2ddd5c9e8 LttP: deterministic shop_shuffle 2023-05-20 19:43:44 +02:00
Fabian Dill
97ba631b80 Core: update modules 2023-05-20 19:36:55 +02:00
black-sliver
be4c597c8d Logging: make sure level is applied for websockets 2023-05-20 19:27:12 +02:00
black-sliver
324d3cf042 Main: add __all__ and change wrong imports (#1824)
* Main: add __all__ and change wrong imports

* Adjusters: fix __version__ import
2023-05-20 19:21:39 +02:00
Fabian Dill
b1c5456d18 Subnautica: move mod exports to own module 2023-05-20 18:34:22 +02:00
Cybrou
f474b81f40 LADX: Add --no-magpie argument for disabling magpie bridge (#1788) 2023-05-20 15:30:33 +02:00
el-u
5255bc5cd8 CI: add a workflow to show flake8/mypy violations in modified files of a PR (#1513)
* CI: add a workflow to show flake8 violations in modified files of a PR

* modify a file to trigger the lint check

* CI: add a workflow to show mypy violations in modified files of a PR

* modify a file to trigger the type check

* Split flake8 and mypy into two parallel jobs; run a variant of the workflow on push event; modify a file to trigger the push workflow

* fail the task if there are syntax errors; remove old lint workflow
2023-05-20 14:40:51 +02:00
291 changed files with 23656 additions and 4026 deletions

View File

@@ -0,0 +1,80 @@
name: Analyze modified files
on:
pull_request:
paths:
- "**.py"
push:
paths:
- "**.py"
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
jobs:
flake8-or-mypy:
strategy:
fail-fast: false
matrix:
task: [flake8, mypy]
name: ${{ matrix.task }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
run: |
git fetch origin $BASE $HEAD
DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py")
echo "modified files:"
echo "$DIFF"
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
- name: "Determine modified files (push)"
if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
run: |
git fetch origin $BEFORE $AFTER
DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py")
echo "modified files:"
echo "$DIFF"
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
- name: "Treat all files as modified (new branch)"
if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000'
run: |
echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v4
if: env.diff != ''
with:
python-version: 3.8
- name: "Install dependencies"
if: env.diff != ''
run: |
python -m pip install --upgrade pip ${{ matrix.task }}
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "flake8: Stop the build if there are Python syntax errors or undefined names"
continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files"
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true
if: env.diff != '' && matrix.task == 'mypy'
run: |
mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }}

View File

@@ -1,35 +0,0 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: lint
on:
push:
paths:
- '**.py'
pull_request:
paths:
- '**.py'
jobs:
flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
pip install flake8
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

4
.gitignore vendored
View File

@@ -28,6 +28,7 @@
*.apsave
*.BIN
setups
build
bundle/components.wxs
dist
@@ -176,6 +177,9 @@ minecraft_versions.json
# pyenv
.python-version
#undertale stuff
/Undertale/
# OS General Files
.DS_Store
.AppleDouble

View File

@@ -396,7 +396,7 @@ async def atari_sync_task(ctx: AdventureContext):
ctx.atari_streams = await asyncio.wait_for(
asyncio.open_connection("localhost",
port),
timeout=10)
timeout=10)
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")

View File

@@ -7,9 +7,9 @@ import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import ChainMap, Counter, OrderedDict, deque
from collections import ChainMap, Counter, deque
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union
import NetUtils
import Options
@@ -28,15 +28,15 @@ class Group(TypedDict, total=False):
link_replacement: bool
class ThreadBarrierProxy():
class ThreadBarrierProxy:
"""Passes through getattr while passthrough is True"""
def __init__(self, obj: Any):
def __init__(self, obj: object) -> None:
self.passthrough = True
self.obj = obj
def __getattr__(self, item):
def __getattr__(self, name: str) -> Any:
if self.passthrough:
return getattr(self.obj, item)
return getattr(self.obj, name)
else:
raise RuntimeError("You are in a threaded context and global random state was removed for your safety. "
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
@@ -96,7 +96,6 @@ class MultiWorld():
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
self.glitch_triforce = False
self.algorithm = 'balanced'
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
self.groups = {}
self.regions = []
self.shops = []
@@ -386,12 +385,6 @@ class MultiWorld():
self._recache()
return self._location_cache[location, player]
def get_dungeon(self, dungeonname: str, player: int) -> Dungeon:
try:
return self.dungeons[dungeonname, player]
except KeyError as e:
raise KeyError('No such dungeon %s for player %d' % (dungeonname, player)) from e
def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
@@ -801,7 +794,6 @@ class Region:
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
dungeon: Optional[Dungeon] = None
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
@@ -904,63 +896,6 @@ class Entrance:
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
class Dungeon(object):
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
dungeon_items: List[Item], player: int):
self.name = name
self.regions = regions
self.big_key = big_key
self.small_keys = small_keys
self.dungeon_items = dungeon_items
self.bosses = dict()
self.player = player
self.multiworld = None
@property
def boss(self) -> Optional[Boss]:
return self.bosses.get(None, None)
@boss.setter
def boss(self, value: Optional[Boss]):
self.bosses[None] = value
@property
def keys(self) -> List[Item]:
return self.small_keys + ([self.big_key] if self.big_key else [])
@property
def all_items(self) -> List[Item]:
return self.dungeon_items + self.keys
def is_dungeon_item(self, item: Item) -> bool:
return item.player == self.player and item.name in (dungeon_item.name for dungeon_item in self.all_items)
def __eq__(self, other: Dungeon) -> bool:
if not other:
return False
return self.name == other.name and self.player == other.player
def __repr__(self):
return self.__str__()
def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
class Boss():
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
self.name = name
self.enemizer_name = enemizer_name
self.defeat_rule = defeat_rule
self.player = player
def can_defeat(self, state) -> bool:
return self.defeat_rule(state, self.player)
def __repr__(self):
return f"Boss({self.name})"
class LocationProgressType(IntEnum):
DEFAULT = 1
PRIORITY = 2
@@ -1093,15 +1028,19 @@ class Item:
def flags(self) -> int:
return self.classification.as_flag()
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
return self.name == other.name and self.player == other.player
def __lt__(self, other: Item) -> bool:
def __lt__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
if other.player != self.player:
return other.player < self.player
return self.name < other.name
def __hash__(self):
def __hash__(self) -> int:
return hash((self.name, self.player))
def __repr__(self) -> str:
@@ -1113,33 +1052,44 @@ class Item:
return f"{self.name} (Player {self.player})"
class Spoiler():
multiworld: MultiWorld
unreachables: Set[Location]
class EntranceInfo(TypedDict, total=False):
player: int
entrance: str
exit: str
direction: str
def __init__(self, world):
self.multiworld = world
class Spoiler:
multiworld: MultiWorld
hashes: Dict[int, str]
entrances: Dict[Tuple[str, str, int], EntranceInfo]
playthrough: Dict[str, Union[List[str], Dict[str, str]]] # sphere "0" is list, others are dict
unreachables: Set[Location]
paths: Dict[str, List[Union[Tuple[str, str], Tuple[str, None]]]] # last step takes no further exits
def __init__(self, multiworld: MultiWorld) -> None:
self.multiworld = multiworld
self.hashes = {}
self.entrances = OrderedDict()
self.entrances = {}
self.playthrough = {}
self.unreachables = set()
self.paths = {}
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -> None:
if self.multiworld.players == 1:
self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
self.entrances[(entrance, direction, player)] = \
{"entrance": entrance, "exit": exit_, "direction": direction}
else:
self.entrances[(entrance, direction, player)] = OrderedDict(
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
self.entrances[(entrance, direction, player)] = \
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
def create_playthrough(self, create_paths: bool = True):
def create_playthrough(self, create_paths: bool = True) -> None:
"""Destructive to the world while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement}
state_cache = [None]
state_cache: List[Optional[CollectionState]] = [None]
collection_spheres: List[Set[Location]] = []
state = CollectionState(multiworld)
sphere_candidates = set(prog_locations)
@@ -1248,17 +1198,17 @@ class Spoiler():
for item in removed_precollected:
multiworld.push_precollected(item)
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]) -> None:
from itertools import zip_longest
multiworld = self.multiworld
def flist_to_iter(node):
while node:
value, node = node
yield value
def flist_to_iter(path_value: Optional[PathValue]) -> Iterator[str]:
while path_value:
region_or_entrance, path_value = path_value
yield region_or_entrance
def get_path(state, region):
reversed_path_as_flist = state.path.get(region, (region, None))
def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, str], Tuple[str, None]]]:
reversed_path_as_flist: PathValue = state.path.get(region, (str(region), None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
@@ -1284,14 +1234,11 @@ class Spoiler():
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str):
def write_option(option_key: str, option_obj: type(Options.Option)):
def to_file(self, filename: str) -> None:
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key)
try:
outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
except:
raise Exception
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write(
@@ -1324,15 +1271,15 @@ class Spoiler():
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
for location in self.multiworld.get_locations() if location.show_in_spoiler]
for location in self.multiworld.get_locations() if location.show_in_spoiler]
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
['%s: %s' % (location, item) for location, item in locations]))
outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
[' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [
f' {item}' for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
if self.unreachables:
outfile.write('\n\nUnreachable Items:\n\n')
outfile.write(
@@ -1393,23 +1340,21 @@ class PlandoOptions(IntFlag):
@classmethod
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
try:
part = cls[part]
return base | 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
f"Known options: {', '.join(str(flag.name) for flag in cls)}") from e
def __str__(self) -> str:
if self.value:
return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
return ", ".join(str(flag.name) for flag in PlandoOptions if self.value & flag.value)
return "None"
seeddigits = 20
def get_seed(seed=None) -> int:
def get_seed(seed: Optional[int] = None) -> int:
if seed is None:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)

View File

@@ -23,6 +23,7 @@ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
import ssl
if typing.TYPE_CHECKING:
import kvui
@@ -33,6 +34,12 @@ logger = logging.getLogger("Client")
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@Utils.cache_argsless
def get_ssl_context():
import certifi
return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
@@ -184,6 +191,10 @@ class CommonContext:
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
# data storage
stored_data: typing.Dict[str, typing.Any]
stored_data_notification_keys: typing.Set[str]
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
@@ -219,6 +230,9 @@ class CommonContext:
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {}
self.stored_data = {}
self.stored_data_notification_keys = set()
self.input_queue = asyncio.Queue()
self.input_requests = 0
@@ -460,6 +474,21 @@ class CommonContext:
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
# data storage
def set_notify(self, *keys: str) -> None:
"""Subscribe to be notified of changes to selected data storage keys.
The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the
names of the data storage keys to the latest values received from the server.
"""
if new_keys := (set(keys) - self.stored_data_notification_keys):
self.stored_data_notification_keys.update(new_keys)
async_start(self.send_msgs([{"cmd": "Get",
"keys": list(new_keys)},
{"cmd": "SetNotify",
"keys": list(new_keys)}]))
# DeathLink hooks
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
@@ -589,7 +618,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
@@ -604,6 +634,7 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
except websockets.InvalidMessage:
# probably encrypted
if address.startswith("ws://"):
# try wss
await server_loop(ctx, "ws" + address[1:])
else:
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
@@ -728,6 +759,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if ctx.locations_scouted:
msgs.append({"cmd": "LocationScouts",
"locations": list(ctx.locations_scouted)})
if ctx.stored_data_notification_keys:
msgs.append({"cmd": "Get",
"keys": list(ctx.stored_data_notification_keys)})
msgs.append({"cmd": "SetNotify",
"keys": list(ctx.stored_data_notification_keys)})
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
@@ -791,7 +827,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
ctx.on_deathlink(args["data"])
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"]
if ctx.ui:
@@ -832,10 +873,9 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
def run_as_textclient():
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
tags = {"AP", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
@@ -850,12 +890,11 @@ if __name__ == '__main__':
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
async def disconnect(self, allow_autoreconnect: bool = False):
self.game = ""
await super().disconnect(allow_autoreconnect)
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
@@ -868,7 +907,6 @@ if __name__ == '__main__':
await ctx.exit_event.wait()
await ctx.shutdown()
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
@@ -888,3 +926,7 @@ if __name__ == '__main__':
asyncio.run(main(args))
colorama.deinit()
if __name__ == '__main__':
run_as_textclient()

View File

@@ -33,7 +33,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")

View File

@@ -1,553 +1,12 @@
from __future__ import annotations
import os
import logging
import json
import string
import copy
import re
import subprocess
import sys
import time
import random
import typing
import ModuleUpdate
ModuleUpdate.update()
import factorio_rcon
import colorama
import asyncio
from queue import Queue
from worlds.factorio.Client import check_stdin, launch
import Utils
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext
def _cmd_energy_link(self):
"""Print the status of the energy link."""
self.output(f"Energy Link: {self.ctx.energy_link_status}")
@mark_raw
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
if self.ctx.rcon_client:
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
self.ctx.print_to_game(f"/factorio {text}")
result = self.ctx.rcon_client.send_command(text)
if result:
self.output(result)
return True
return False
def _cmd_resync(self):
"""Manually trigger a resync."""
self.ctx.awaiting_bridge = True
def _cmd_toggle_send_filter(self):
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
self.ctx.toggle_filter_item_sends()
def _cmd_toggle_chat(self):
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
self.ctx.toggle_bridge_chat_out()
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
game = "Factorio"
items_handling = 0b111 # full remote
# updated by spinup server
mod_version: Utils.Version = Utils.Version(0, 0, 0)
def __init__(self, server_address, password):
super(FactorioContext, self).__init__(server_address, password)
self.send_index: int = 0
self.rcon_client = None
self.awaiting_bridge = False
self.write_data_path = None
self.death_link_tick: int = 0 # last send death link on Factorio layer
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0
self.last_deplete = 0
self.filter_item_sends: bool = False
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
if self.rcon_client:
await get_info(self, self.rcon_client) # retrieve current auth code
else:
raise Exception("Cannot connect to a server with unknown own identity, "
"bridge to Factorio first.")
await self.send_connect()
def on_print(self, args: dict):
super(FactorioContext, self).on_print(args)
if self.rcon_client:
if not args['text'].startswith(self.player_names[self.slot] + ":"):
self.print_to_game(args['text'])
def on_print_json(self, args: dict):
if self.rcon_client:
if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \
and not self.is_echoed_chat(args):
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future.
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args)
@property
def savegame_name(self) -> str:
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
def print_to_game(self, text):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}")
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
return "Disabled"
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict):
if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
super(FactorioContext, self).on_deathlink(data)
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}:
# catch up sync anything that is already cleared.
if "checked_locations" in args and args["checked_locations"]:
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"]
}]))
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
# it's our deplete request
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.debug(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# Mirror chat sent from the UI to the Factorio server.
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
return text
async def chat_from_factorio(self, user: str, message: str) -> None:
if not self.bridge_chat_out:
return
# Pass through commands
if message.startswith("!"):
await self.send_msgs([{"cmd": "Say", "text": message}])
return
# Omit messages that contain local coordinates
if "[gps=" in message:
return
prefix = f"({user}) " if self.multiplayer else ""
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
def toggle_filter_item_sends(self) -> None:
self.filter_item_sends = not self.filter_item_sends
if self.filter_item_sends:
announcement = "Item sends are now filtered."
else:
announcement = "Item sends are no longer filtered."
logger.info(announcement)
self.print_to_game(announcement)
def toggle_bridge_chat_out(self) -> None:
self.bridge_chat_out = not self.bridge_chat_out
if self.bridge_chat_out:
announcement = "Chat is now bridged to Archipelago."
else:
announcement = "Chat is no longer bridged to Archipelago."
logger.info(announcement)
self.print_to_game(announcement)
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")
next_bridge = time.perf_counter() + 1
try:
while not ctx.exit_event.is_set():
# TODO: restore on-demand refresh
if ctx.rcon_client and time.perf_counter() > next_bridge:
next_bridge = time.perf_counter() + 1
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if 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(
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
else:
data = data["info"]
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if ctx.locations_checked != research_data:
bridge_logger.debug(
f"New researches done: "
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags:
async_start(ctx.send_death())
if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"]
if in_world_bridges:
in_world_energy = data["energy"]
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill
ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}],
"last_deplete": ctx.last_deplete
}]))
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges
async_start(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}]
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer():
while process.poll() is None:
text = pipe.readline().strip()
if text:
queue.put_nowait(text)
from threading import Thread
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
thread.start()
return thread
async def factorio_server_watcher(ctx: FactorioContext):
savegame_name = os.path.abspath(ctx.savegame_name)
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name, "--preset", "archipelago"
))
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
*(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try:
while not ctx.exit_event.is_set():
if factorio_process.poll() is not None:
factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set()
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_queue.task_done()
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
factorio_server_logger.debug(msg)
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
factorio_server_logger.debug(msg)
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_filter_item_sends()
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_bridge_chat_out()
else:
factorio_server_logger.info(msg)
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
if match:
await ctx.chat_from_factorio(match.group(1), match.group(2))
if ctx.rcon_client:
commands = {}
while ctx.send_index < len(ctx.items_received):
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
item_id = transfer_item.item
player_name = ctx.player_names[transfer_item.player]
if item_id not in Factorio.item_id_to_name:
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
else:
item_name = Factorio.item_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
ctx.send_index += 1
if commands:
ctx.rcon_client.send_commands(commands)
await asyncio.sleep(0.1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.exit_event.set()
finally:
if factorio_process.poll() is not None:
if ctx.rcon_client:
ctx.rcon_client.close()
ctx.rcon_client = None
return
sent_quit = False
if ctx.rcon_client:
# Attempt clean quit through RCON.
try:
ctx.rcon_client.send_command("/quit")
except factorio_rcon.RCONNetworkError:
pass
else:
sent_quit = True
ctx.rcon_client.close()
ctx.rcon_client = None
if not sent_quit:
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
factorio_process.terminate()
try:
factorio_process.wait(10)
except subprocess.TimeoutExpired:
factorio_process.kill()
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
ctx.auth = info["slot_name"]
ctx.seed_name = info["seed_name"]
# 0.2.0 addition, not present earlier
death_link = bool(info.get("death_link", False))
ctx.energy_link_increment = info.get("energy_link", 0)
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
if ctx.energy_link_increment and ctx.ui:
ctx.ui.enable_energy_link()
await ctx.update_death_link(death_link)
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
savegame_name = os.path.abspath("Archipelago.zip")
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name
))
factorio_process = subprocess.Popen(
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Information Exchange Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
rcon_client = None
try:
while not ctx.auth:
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
parts = msg.split()
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
elif "Write data path: " in msg:
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
if "AppData" in ctx.write_data_path:
logger.warning("It appears your mods are loaded from Appdata, "
"this can lead to problems with multiple Factorio instances. "
"If this is the case, you will get a file locked error running Factorio.")
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if ctx.mod_version == ctx.__class__.mod_version:
raise Exception("No Archipelago mod was loaded. Aborting.")
await get_info(ctx, rcon_client)
await asyncio.sleep(0.01)
except Exception as e:
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:
logger.info(
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
return True
finally:
factorio_process.terminate()
factorio_process.wait(5)
return False
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.filter_item_sends = initial_filter_item_sends
ctx.bridge_chat_out = initial_bridge_chat_out
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
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:
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="FactorioProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await factorio_server_task
await ctx.shutdown()
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
for color in colors:
if color in self.color_codes:
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
return self._handle_text(node)
return self._handle_text(node)
if __name__ == '__main__':
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
"Remaining arguments get passed into bound Factorio instance."
"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()
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for x in range(32))
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 isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
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.")
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
executable = os.path.join(executable, "factorio")
if not os.path.isfile(executable):
if os.path.isfile(executable + ".exe"):
executable = executable + ".exe"
else:
raise FileNotFoundError(f"Path {executable} is not an executable file.")
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)
asyncio.run(main(args))
colorama.deinit()
launch()

38
Fill.py
View File

@@ -39,8 +39,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -84,25 +85,28 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
# we filled all reachable spots.
if swap:
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
# try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe)
for unsafe in (False, True)
for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts:
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player,
placed_item.name]
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item])
# swap_state assumes we can collect placed item before item_to_place
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations, which could happen with rules
# that want to not have both items. Left in until removal is proven useful.
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
@@ -117,13 +121,15 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
# Item can't be placed here, restore original item
@@ -144,6 +150,16 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if on_place:
on_place(spot_to_fill)
if cleanup_required:
# validate all placements and remove invalid ones
for placement in placements:
state = sweep_from_pool(base_state, [])
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
locations.append(placement)
if allow_excluded:
# check if partial fill is the result of excluded locations, in which case retry
excluded_locations = [

View File

@@ -7,8 +7,8 @@ import random
import string
import urllib.parse
import urllib.request
from collections import Counter, ChainMap
from typing import Dict, Tuple, Callable, Any, Union
from collections import ChainMap, Counter
from typing import Any, Callable, Dict, Tuple, Union
import ModuleUpdate
@@ -27,9 +27,6 @@ from worlds.AutoWorld import AutoWorldRegister
import copy
def mystery_argparse():
options = get_options()
defaults = options["generator"]
@@ -56,6 +53,8 @@ def mystery_argparse():
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default=defaults["plando_options"],
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
@@ -74,10 +73,12 @@ def main(args=None, callback=ERmain):
args, options = mystery_argparse()
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed)
seed_name = get_seed_name(random)
if args.race:
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
weights_cache: Dict[str, Tuple[Any, ...]] = {}
@@ -86,15 +87,15 @@ def main(args=None, callback=ERmain):
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][-1], 'No description specified')}")
logging.info(f"Weights: {args.weights_file_path} >> "
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:
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
del(meta_weights["meta_description"])
except Exception as e:
@@ -120,17 +121,18 @@ def main(args=None, callback=ERmain):
for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}:
for yaml in yaml_data:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
logging.info(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"{args.plando}")
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {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. "
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
@@ -140,8 +142,7 @@ def main(args=None, callback=ERmain):
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.skip_prog_balancing = args.skip_prog_balancing
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
@@ -449,6 +450,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types:
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.")
if ret.game not in weights:
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
@@ -463,32 +469,29 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in Options.common_options.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.option_definitions.items():
for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.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 PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
if PlandoOptions.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 PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
return ret

View File

@@ -11,6 +11,7 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse
import itertools
import logging
import multiprocessing
import shlex
import subprocess
@@ -55,7 +56,7 @@ def open_patch():
except Exception as e:
messagebox('Error', str(e), error=True)
else:
file, _, component = identify(filename)
file, component = identify(filename)
if file and component:
launch([*get_exe(component), file], component.cli)
@@ -96,11 +97,13 @@ components.extend([
def identify(path: Union[None, str]):
if path is None:
return None, None, None
return None, None
for component in components:
if component.handles_file(path):
return path, component.script_name, component
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
return path, component
elif path == component.display_name or path == component.script_name:
return None, component
return None, None
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
@@ -155,10 +158,10 @@ def run_gui():
container: ContainerLayout
grid: GridLayout
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
def __init__(self, ctx=None):
self.title = self.base_title
@@ -199,7 +202,7 @@ def run_gui():
button_layout.add_widget(button)
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
build_button(tool[1])
@@ -215,14 +218,29 @@ def run_gui():
@staticmethod
def component_action(button):
if button.component.type == Type.FUNC:
if button.component.func:
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
self.root_window.close()
super()._stop(*largs)
Launcher().run()
def run_component(component: Component, *args):
if component.func:
component.func(*args)
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:
logging.warning(f"Component {component} does not appear to be executable.")
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
@@ -230,25 +248,34 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
args = {}
if "Patch|Game|Component" in args:
file, component, _ = identify(args["Patch|Game|Component"])
file, component = identify(args["Patch|Game|Component"])
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
if 'file' in args:
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
run_component(args["component"], args["file"], *args["args"])
elif 'component' in args:
subprocess.run([*get_exe(args['component']), *args['args']])
run_component(args["component"], *args["args"])
else:
run_gui()
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(description='Archipelago Launcher')
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
help="Pass either a patch file, a generated game or the name of a component to run.")
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
main(parser.parse_args())
from worlds.LauncherComponents import processes
for process in processes:
# we await all child processes to close before we tear down the process host
# this makes it feel like each one is its own program, as the Launcher is closed now
process.join()

View File

@@ -18,7 +18,7 @@ import typing
import urllib
import colorama
import struct
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
@@ -91,7 +91,7 @@ class LAClientConstants:
# wLinkSendShopTarget = 0xDDFF
wRecvIndex = 0xDDFE # 0xDB58
wRecvIndex = 0xDDFD # Two bytes
wCheckAddress = 0xC0FF - 0x4
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
@@ -365,14 +365,13 @@ class LinksAwakeningClient():
item_id, from_player])
status |= 1
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
self.gameboy.write_memory(LAClientConstants.wRecvIndex, [next_index])
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
async def wait_for_game_ready(self):
logger.info("Waiting on game to be in valid state...")
while not await self.gameboy.check_safe_gameplay(throw=False):
pass
logger.info("Ready!")
last_index = 0
async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
@@ -382,11 +381,6 @@ class LinksAwakeningClient():
await self.item_tracker.readItems()
await self.gps_tracker.read_location()
next_index = self.gameboy.read_memory(LAClientConstants.wRecvIndex)[0]
if next_index != self.last_index:
self.last_index = next_index
# logger.info(f"Got new index {next_index}")
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0:
self.deathlink_debounce = False
@@ -404,7 +398,7 @@ class LinksAwakeningClient():
if await self.is_victory():
await win_cb()
recv_index = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[0]
recv_index = struct.unpack(">H", self.gameboy.read_memory(LAClientConstants.wRecvIndex, 2))[0]
# Play back one at a time
if recv_index in self.recvd_checks:
@@ -438,12 +432,16 @@ class LinksAwakeningContext(CommonContext):
found_checks = []
last_resend = time.time()
magpie = MagpieBridge()
magpie_enabled = False
magpie = None
magpie_task = None
won = False
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient()
if magpie:
self.magpie_enabled = True
self.magpie = MagpieBridge()
super().__init__(server_address, password)
def run_gui(self) -> None:
@@ -462,16 +460,17 @@ class LinksAwakeningContext(CommonContext):
def build(self):
b = super().build()
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
if self.ctx.magpie_enabled:
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
@@ -506,7 +505,8 @@ class LinksAwakeningContext(CommonContext):
def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -537,7 +537,8 @@ class LinksAwakeningContext(CommonContext):
async def deathlink():
await self.send_deathlink()
self.magpie_task = asyncio.create_task(self.magpie.serve())
if self.magpie_enabled:
self.magpie_task = asyncio.create_task(self.magpie.serve())
# yield to allow UI to start
await asyncio.sleep(0)
@@ -558,9 +559,10 @@ class LinksAwakeningContext(CommonContext):
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
if self.magpie_enabled:
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
except GameboyException:
time.sleep(1.0)
@@ -570,9 +572,11 @@ class LinksAwakeningContext(CommonContext):
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args()
logger.info(args)
@@ -590,7 +594,7 @@ async def main():
if url.password:
args.password = urllib.parse.unquote(url.password)
ctx = LinksAwakeningContext(args.connect, args.password)
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")

View File

@@ -44,7 +44,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
return textwrap.dedent(action.help)
def main():
def get_argparser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
@@ -85,9 +85,6 @@ def main():
parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
# parser.add_argument('--link_palettes', default='default',
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
# 'sick'])
parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
@@ -115,6 +112,11 @@ def main():
''')
parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
return parser
def main():
parser = get_argparser()
args = parser.parse_args()
args.music = not args.disablemusic
# set up logger
@@ -193,7 +195,7 @@ def adjustGUI():
from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from argparse import Namespace
from Main import __version__ as MWVersion
from Utils import __version__ as MWVersion
adjustWindow = Tk()
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)

372
MMBN3Client.py Normal file
View File

@@ -0,0 +1,372 @@
import asyncio
import hashlib
import json
import os
import multiprocessing
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
import bsdiff4
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
import Utils
from NetUtils import ClientStatus
from worlds.mmbn3.Items import items_by_id
from worlds.mmbn3.Rom import get_base_rom_path
from worlds.mmbn3.Locations import all_locations, scoutable_locations
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua"
CONNECTION_REFUSED_STATUS = \
"Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version"
script_version: int = 2
debugEnabled = False
locations_checked = []
items_sent = []
itemIndex = 1
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
class MMBN3CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
def _cmd_gba(self):
"""Check GBA Connection State"""
if isinstance(self.ctx, MMBN3Context):
logger.info(f"GBA Status: {self.ctx.gba_status}")
def _cmd_debug(self):
"""Toggle the Debug Text overlay in ROM"""
global debugEnabled
debugEnabled = not debugEnabled
logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled")
class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3"
items_handling = 0b001 # full local
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gba_streams: (StreamReader, StreamWriter) = None
self.gba_sync_task = None
self.gba_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.location_table = {}
self.version_warning = False
self.auth_name = None
self.slot_data = dict()
self.patching_error = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(MMBN3Context, self).server_auth(password_requested)
if self.auth_name is None:
self.awaiting_rom = True
logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server")
return
logger.info("Attempting to decode from ROM... ")
self.awaiting_rom = False
self.auth = self.auth_name.decode("utf8").replace('\x00', '')
logger.info("Connecting as "+self.auth)
await self.send_connect(name=self.auth)
def run_gui(self):
from kvui import GameManager
class MMBN3Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago MegaMan Battle Network 3 Client"
self.ui = MMBN3Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.slot_data = args.get("slot_data", {})
print(self.slot_data)
class ItemInfo:
id = 0x00
sender = ""
type = ""
count = 1
itemName = "Unknown"
itemID = 0x00 # Item ID, Chip ID, etc.
subItemID = 0x00 # Code for chips, color for programs
itemIndex = 1
def __init__(self, id, sender, type):
self.id = id
self.sender = sender
self.type = type
def get_json(self):
json_data = {
"id": self.id,
"sender": self.sender,
"type": self.type,
"itemName": self.itemName,
"itemID": self.itemID,
"subItemID": self.subItemID,
"count": self.count,
"itemIndex": self.itemIndex
}
return json_data
def get_payload(ctx: MMBN3Context):
global debugEnabled
items_sent = []
for i, item in enumerate(ctx.items_received):
item_data = items_by_id[item.item]
new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type)
new_item.itemIndex = i+1
new_item.itemName = item_data.itemName
new_item.type = item_data.type
new_item.itemID = item_data.itemID
new_item.subItemID = item_data.subItemID
new_item.count = item_data.count
items_sent.append(new_item)
return json.dumps({
"items": [item.get_json() for item in items_sent],
"debug": debugEnabled
})
async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
# Game completion handling
if payload["gameComplete"] and not ctx.finished_game:
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL
}])
ctx.finished_game = True
# Locations handling
if ctx.location_table != payload["locations"]:
ctx.location_table = payload["locations"]
locs = [loc.id for loc in all_locations
if check_location_packet(loc, ctx.location_table)]
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": locs
}])
# If trade hinting is enabled, send scout checks
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
scouted_locs = [loc.id for loc in scoutable_locations
if check_location_scouted(loc, payload["locations"])]
await ctx.send_msgs([{
"cmd": "LocationScouts",
"locations": scouted_locs,
"create_as_hint": 2
}])
def check_location_packet(location, memory):
if len(memory) == 0:
return False
# Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well
location_key = hex(location.flag_byte)[2:]
byte = memory.get(location_key)
if byte is not None:
return byte & location.flag_mask
def check_location_scouted(location, memory):
if len(memory) == 0:
return False
location_key = hex(location.hint_flag)[2:]
byte = memory.get(location_key)
if byte is not None:
return byte & location.hint_flag_mask
async def gba_sync_task(ctx: MMBN3Context):
logger.info("Starting GBA connector. Use /gba for status information.")
if ctx.patching_error:
logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.')
while not ctx.exit_event.is_set():
error_status = None
if ctx.gba_streams:
(reader, writer) = ctx.gba_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to four fields
# 1. str: player name (always)
# 2. int: script version (always)
# 3. dict[str, byte]: value of location's memory byte
# 4. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode())
reported_version = data_decoded.get("scriptVersion", 0)
if reported_version >= script_version:
if ctx.game is not None and "locations" in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task((parse_payload(data_decoded, ctx, False)))
if not ctx.auth:
ctx.auth_name = bytes(data_decoded["playerName"])
if ctx.awaiting_rom:
logger.info("Awaiting data from ROM...")
await ctx.server_auth(False)
else:
if not ctx.version_warning:
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
"Please update to the latest version."
"Your connection to the Archipelago server will not be accepted.")
ctx.version_warning = True
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gba_streams = None
except ConnectionResetError:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gba_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gba_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gba_streams = None
if ctx.gba_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to GBA")
ctx.gba_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}"
elif error_status:
ctx.gba_status = error_status
logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates")
else:
try:
logger.debug("Attempting to connect to GBA")
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
options = Utils.get_options().get("mmbn3_options", None)
if options is None:
auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(apmmbn3_file):
base_name = os.path.splitext(apmmbn3_file)[0]
with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive:
try:
with patch_archive.open("delta.bsdiff4", 'r') as stream:
patch_data = stream.read()
except KeyError:
raise FileNotFoundError("Patch file missing from archive.")
rom_file = get_base_rom_path()
with open(rom_file, 'rb') as rom:
rom_bytes = rom.read()
patched_bytes = bsdiff4.patch(rom_bytes, patch_data)
patched_rom_file = base_name+".gba"
with open(patched_rom_file, 'wb') as patched_rom:
patched_rom.write(patched_bytes)
asyncio.create_task(run_game(patched_rom_file))
def confirm_checksum():
rom_file = get_base_rom_path()
if not os.path.exists(rom_file):
return False
with open(rom_file, 'rb') as rom:
rom_bytes = rom.read()
basemd5 = hashlib.md5()
basemd5.update(rom_bytes)
return CHECKSUM_BLUE == basemd5.hexdigest()
if __name__ == "__main__":
Utils.init_logging("MMBN3Client")
async def main():
multiprocessing.freeze_support()
parser = get_base_parser()
parser.add_argument("patch_file", default="", type=str, nargs="?",
help="Path to an APMMBN3 file")
args = parser.parse_args()
checksum_matches = confirm_checksum()
if checksum_matches:
if args.patch_file:
asyncio.create_task(patch_and_run_game(args.patch_file))
ctx = MMBN3Context(args.connect, args.password)
if not checksum_matches:
ctx.patching_error = True
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gba_sync_task:
await ctx.gba_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -20,6 +20,8 @@ from worlds.alttp.Shops import FillDisabledShopSlots
from worlds.alttp.SubClasses import LTTPRegionType
from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"]
ordered_areas = (
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
@@ -283,8 +285,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
AutoWorld.call_all(world, 'post_fill')
if world.players > 1:
if world.players > 1 and not args.skip_prog_balancing:
balance_multiworld_progression(world)
else:
logger.info("Progression balancing skipped.")
logger.info(f'Beginning output...')

View File

@@ -44,7 +44,7 @@ def adjustGUI():
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
OptionMenu, filedialog, messagebox, ttk
from argparse import Namespace
from Main import __version__ as MWVersion
from Utils import __version__ as MWVersion
window = tk.Tk()
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")

View File

@@ -100,7 +100,7 @@ class OoTContext(CommonContext):
await super(OoTContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to Bizhawk to get player information')
logger.info('Awaiting connection to EmuHawk to get player information')
return
await self.send_connect()

View File

@@ -78,7 +78,7 @@ class GBContext(CommonContext):
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to Bizhawk to get Player information')
logger.info('Awaiting connection to EmuHawk to get Player information')
return
await self.send_connect()

View File

@@ -45,6 +45,10 @@ Currently, the following games are supported:
* Adventure
* DLC Quest
* Noita
* Undertale
* Bumper Stickers
* Mega Man Battle Network 3: Blue Version
* Muse Dash
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -315,7 +315,7 @@ def launch_sni() -> None:
f"please start it yourself if it is not running")
async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol:
async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> WebSocketClientProtocol:
address = f"ws://{address}" if "://" not in address else address
snes_logger.info("Connecting to SNI at %s ..." % address)
seen_problems: typing.Set[str] = set()
@@ -336,6 +336,8 @@ async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtoco
await asyncio.sleep(1)
else:
return snes_socket
if not retry:
break
class SNESRequest(typing.TypedDict):
@@ -684,6 +686,8 @@ async def main() -> None:
logging.info(f"Wrote rom file to {romfile}")
if args.diff_file.endswith(".apsoe"):
import webbrowser
async_start(run_game(romfile))
await _snes_connect(SNIContext(args.snes, args.connect, args.password), args.snes, False)
webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}")
logging.info("Starting Evermizer Client in your Browser...")
import time

View File

@@ -25,11 +25,10 @@ logger = logging.getLogger("Client")
sc2_logger = logging.getLogger("Starcraft2")
import nest_asyncio
import sc2
from sc2.bot_ai import BotAI
from sc2.data import Race
from sc2.main import run_game
from sc2.player import Bot
from worlds._sc2common import bot
from worlds._sc2common.bot.data import Race
from worlds._sc2common.bot.main import run_game
from worlds._sc2common.bot.player import Bot
from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
@@ -240,8 +239,6 @@ class SC2Context(CommonContext):
from kivy.uix.floatlayout import FloatLayout
from kivy.properties import StringProperty
import Utils
class HoverableButton(HoverBehavior, Button):
pass
@@ -544,11 +541,11 @@ async def starcraft_launch(ctx: SC2Context, mission_id: int):
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),
run_game(bot.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):
class ArchipelagoBot(bot.bot_ai.BotAI):
game_running: bool = False
mission_completed: bool = False
boni: typing.List[bool]
@@ -867,7 +864,7 @@ def check_game_install_path() -> bool:
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]))
einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF]))
# Check if the file exists.
if os.path.isfile(einfo):
@@ -883,7 +880,7 @@ def check_game_install_path() -> bool:
f"try again.")
return False
if os.path.exists(base):
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions")
# Finally, check the path for an actual executable.
# If we find one, great. Set up the SC2PATH.

498
UndertaleClient.py Normal file
View File

@@ -0,0 +1,498 @@
from __future__ import annotations
import os
import asyncio
import typing
import bsdiff4
import shutil
import Utils
from NetUtils import NetworkItem, ClientStatus
from worlds import undertale
from MultiServer import mark_raw
from CommonClient import CommonContext, server_loop, \
gui_enabled, ClientCommandProcessor, get_base_parser
from Utils import async_start
class UndertaleCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
def _cmd_resync(self):
"""Manually trigger a resync."""
if isinstance(self.ctx, UndertaleContext):
self.output(f"Syncing items.")
self.ctx.syncing = True
def _cmd_patch(self):
"""Patch the game."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
@mark_raw
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
elif not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
" command. \"/auto_patch (Steam directory)\".")
else:
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
shutil.copy(tempInstall+"\\"+file_name,
os.getcwd() + "\\Undertale\\" + file_name)
self.ctx.patch_game()
self.output("Patching successful!")
def _cmd_online(self):
"""Makes you no longer able to see other Undertale players."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags:
self.output(f"Now online.")
else:
self.output(f"Now offline.")
def _cmd_deathlink(self):
"""Toggles deathlink"""
if isinstance(self.ctx, UndertaleContext):
self.ctx.deathlink_status = not self.ctx.deathlink_status
if self.ctx.deathlink_status:
self.output(f"Deathlink enabled.")
else:
self.output(f"Deathlink disabled.")
class UndertaleContext(CommonContext):
tags = {"AP", "Online"}
game = "Undertale"
command_processor = UndertaleCommandProcessor
items_handling = 0b111
route = None
pieces_needed = None
completed_routes = None
completed_count = 0
save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.pieces_needed = 0
self.game = "Undertale"
self.got_deathlink = False
self.syncing = False
self.deathlink_status = False
self.tem_armor = False
self.completed_count = 0
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
def patch_game(self):
with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
f.write(patchedFile)
os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
"Which Character.txt"), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"])
f.close()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super().server_auth(password_requested)
await self.get_username()
await self.send_connect()
def clear_undertale_files(self):
path = self.save_game_folder
self.finished_game = False
for root, dirs, files in os.walk(path):
for file in files:
if "check.spot" == file or "scout" == file:
os.remove(os.path.join(root, file))
elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad",
".youDied", ".LV", ".mine", ".flag", ".hint")):
os.remove(os.path.join(root, file))
async def connect(self, address: typing.Optional[str] = None):
self.clear_undertale_files()
await super().connect(address)
async def disconnect(self, allow_autoreconnect: bool = False):
self.clear_undertale_files()
await super().disconnect(allow_autoreconnect)
async def connection_closed(self):
self.clear_undertale_files()
await super().connection_closed()
async def shutdown(self):
self.clear_undertale_files()
await super().shutdown()
def update_online_mode(self, online):
old_tags = self.tags.copy()
if online:
self.tags.add("Online")
else:
self.tags -= {"Online"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]))
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
async_start(process_undertale_cmd(self, cmd, args))
def run_gui(self):
from kvui import GameManager
class UTManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Undertale Client"
self.ui = UTManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_deathlink(self, data: typing.Dict[str, typing.Any]):
self.got_deathlink = True
super().on_deathlink(data)
def to_room_name(place_name: str):
if place_name == "Old Home Exit":
return "room_ruinsexit"
elif place_name == "Snowdin Forest":
return "room_tundra1"
elif place_name == "Snowdin Town Exit":
return "room_fogroom"
elif place_name == "Waterfall":
return "room_water1"
elif place_name == "Waterfall Exit":
return "room_fire2"
elif place_name == "Hotland":
return "room_fire_prelab"
elif place_name == "Hotland Exit":
return "room_fire_precore"
elif place_name == "Core":
return "room_fire_core1"
async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if cmd == "Connected":
if not os.path.exists(ctx.save_game_folder):
os.mkdir(ctx.save_game_folder)
ctx.route = args["slot_data"]["route"]
ctx.pieces_needed = args["slot_data"]["key_pieces"]
ctx.tem_armor = args["slot_data"]["temy_armor_include"]
await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}])
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}])
if args["slot_data"]["only_flakes"]:
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
f.close()
if not args["slot_data"]["key_hunt"]:
ctx.pieces_needed = 0
if args["slot_data"]["rando_love"]:
filename = f"LOVErando.LV"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.close()
if args["slot_data"]["rando_stats"]:
filename = f"STATrando.LV"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.close()
filename = f"{ctx.route}.route"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.close()
filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
for ss in ctx.checked_locations:
f.write(str(ss-12000)+"\n")
f.close()
elif cmd == "LocationInfo":
for l in args["locations"]:
locationid = l.location
filename = f"{str(locationid-12000)}.hint"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = ""
for i in range(20):
if i < len(str(ctx.item_names[l.item])):
toDraw += str(ctx.item_names[l.item])[i]
else:
break
f.write(toDraw)
f.close()
elif cmd == "Retrieved":
if str(ctx.slot)+" RoutesDone neutral" in args["keys"]:
if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None:
ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"]
if str(ctx.slot)+" RoutesDone genocide" in args["keys"]:
if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None:
ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"]
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
elif cmd == "SetReply":
if args["value"] is not None:
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
ctx.completed_routes["pacifist"] = args["value"]
elif str(ctx.slot)+" RoutesDone genocide" == args["key"]:
ctx.completed_routes["genocide"] = args["value"]
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
ctx.completed_routes["neutral"] = args["value"]
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):
counter = -1
placedWeapon = 0
placedArmor = 0
for item in args["items"]:
id = NetworkItem(*item).location
while NetworkItem(*item).location < 0 and \
counter <= id:
id -= 1
if NetworkItem(*item).location < 0:
counter -= 1
filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
if NetworkItem(*item).item == 77701:
if placedWeapon == 0:
f.write(str(77013-11000))
elif placedWeapon == 1:
f.write(str(77014-11000))
elif placedWeapon == 2:
f.write(str(77025-11000))
elif placedWeapon == 3:
f.write(str(77045-11000))
elif placedWeapon == 4:
f.write(str(77049-11000))
elif placedWeapon == 5:
f.write(str(77047-11000))
elif placedWeapon == 6:
if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes":
f.write(str(77052-11000))
else:
f.write(str(77051-11000))
else:
f.write(str(77003-11000))
placedWeapon += 1
elif NetworkItem(*item).item == 77702:
if placedArmor == 0:
f.write(str(77012-11000))
elif placedArmor == 1:
f.write(str(77015-11000))
elif placedArmor == 2:
f.write(str(77024-11000))
elif placedArmor == 3:
f.write(str(77044-11000))
elif placedArmor == 4:
f.write(str(77048-11000))
elif placedArmor == 5:
if str(ctx.route) == "genocide":
f.write(str(77053-11000))
else:
f.write(str(77046-11000))
elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor):
if str(ctx.route) == "all_routes":
f.write(str(77053-11000))
elif str(ctx.route) == "genocide":
f.write(str(77064-11000))
else:
f.write(str(77050-11000))
elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide":
f.write(str(77064-11000))
else:
f.write(str(77004-11000))
placedArmor += 1
else:
f.write(str(NetworkItem(*item).item-11000))
f.close()
ctx.items_received.append(NetworkItem(*item))
if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0:
filename = f"{str(-99999)}PLR{str(0)}.item"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.write(str(77787 - 11000))
f.close()
filename = f"{str(-99998)}PLR{str(0)}.item"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.write(str(77789 - 11000))
f.close()
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
if "checked_locations" in args:
filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
for ss in ctx.checked_locations:
f.write(str(ss-12000)+"\n")
f.close()
elif cmd == "Bounced":
tags = args.get("tags", [])
if "Online" in tags:
data = args.get("worlds/undertale/data", {})
if data["player"] != ctx.slot and data["player"] is not None:
filename = f"FRISK" + str(data["player"]) + ".playerspot"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str(
data["spr"]) + str(data["frm"]))
f.close()
async def multi_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set():
path = ctx.save_game_folder
for root, dirs, files in os.walk(path):
for file in files:
if "spots.mine" in file and "Online" in ctx.tags:
with open(root + "/" + file, "r") as mine:
this_x = mine.readline()
this_y = mine.readline()
this_room = mine.readline()
this_sprite = mine.readline()
this_frame = mine.readline()
mine.close()
message = [{"cmd": "Bounce", "tags": ["Online"],
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
"spr": this_sprite, "frm": this_frame}}]
await ctx.send_msgs(message)
await asyncio.sleep(0.1)
async def game_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set():
await ctx.update_death_link(ctx.deathlink_status)
path = ctx.save_game_folder
if ctx.syncing:
for root, dirs, files in os.walk(path):
for file in files:
if ".item" in file:
os.remove(root+"/"+file)
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
if ctx.got_deathlink:
ctx.got_deathlink = False
with open(os.path.join(ctx.save_game_folder, "/WelcomeToTheDead.youDied"), "w") as f:
f.close()
sending = []
victory = False
found_routes = 0
for root, dirs, files in os.walk(path):
for file in files:
if "DontBeMad.mad" in file and "DeathLink" in ctx.tags:
os.remove(root+"/"+file)
await ctx.send_death()
if "scout" == file:
sending = []
with open(root+"/"+file, "r") as f:
lines = f.readlines()
for l in lines:
if ctx.server_locations.__contains__(int(l)+12000):
sending = sending + [int(l)+12000]
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
"create_as_hint": int(2)}])
os.remove(root+"/"+file)
if "check.spot" in file:
sending = []
with open(root+"/"+file, "r") as f:
lines = f.readlines()
for l in lines:
sending = sending+[(int(l))+12000]
message = [{"cmd": "LocationChecks", "locations": sending}]
await ctx.send_msgs(message)
if "victory" in file and str(ctx.route) in file:
victory = True
if ".playerspot" in file and "Online" not in ctx.tags:
os.remove(root+"/"+file)
if "victory" in file:
if str(ctx.route) == "all_routes":
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral",
"default": 0, "want_reply": True, "operations": [{"operation": "max",
"value": 1}]}])
elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1:
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist",
"default": 0, "want_reply": True, "operations": [{"operation": "max",
"value": 1}]}])
elif "genocide" in file and ctx.completed_routes["genocide"] != 1:
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide",
"default": 0, "want_reply": True, "operations": [{"operation": "max",
"value": 1}]}])
if str(ctx.route) == "all_routes":
found_routes += ctx.completed_routes["neutral"]
found_routes += ctx.completed_routes["pacifist"]
found_routes += ctx.completed_routes["genocide"]
if str(ctx.route) == "all_routes" and found_routes >= 3:
victory = True
ctx.locations_checked = sending
if (not ctx.finished_game) and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def main():
Utils.init_logging("UndertaleClient", exception_logger="Client")
async def _main():
ctx = UndertaleContext(None, None)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
asyncio.create_task(
game_watcher(ctx), name="UndertaleProgressionWatcher")
asyncio.create_task(
multi_watcher(ctx), name="UndertaleMultiplayerWatcher")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
import colorama
colorama.init()
asyncio.run(_main())
colorama.deinit()
if __name__ == "__main__":
parser = get_base_parser(description="Undertale Client, for text interfacing.")
args = parser.parse_args()
main()

View File

@@ -42,7 +42,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.4.1"
__version__ = "0.4.2"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -339,6 +339,10 @@ def get_default_options() -> OptionsType:
"wargroove_options": {
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
},
"mmbn3_options": {
"rom_file": "Mega Man Battle Network 3 - Blue Version (USA).gba",
"rom_start": True
},
"adventure_options": {
"rom_file": "ADVNTURE.BIN",
"display_msgs": True,
@@ -549,6 +553,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.removeHandler(handler)
handler.close()
root_logger.setLevel(loglevel)
logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets
if "a" not in write_mode:
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
file_handler = logging.FileHandler(
@@ -765,10 +770,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
return buffer
_faf_tasks: "Set[asyncio.Task[None]]" = set()
_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
@@ -781,6 +786,60 @@ def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str]
# ```
# This implementation follows the pattern given in that documentation.
task = asyncio.create_task(co, name=name)
task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)
def deprecate(message: str):
if __debug__:
raise Exception(message)
import warnings
warnings.warn(message)
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing
import multiprocessing.spawn
def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen."""
from subprocess import _args_from_interpreter_flags
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
# Handle the first process that MP will create
if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
):
exec(sys.argv[-1])
sys.exit()
# Handle the second process that MP will create
if multiprocessing.spawn.is_forking(sys.argv):
kwargs = {}
for arg in sys.argv[2:]:
name, value = arg.split('=')
if value == 'None':
kwargs[name] = None
else:
kwargs[name] = int(value)
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
if not is_windows and is_frozen():
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
def freeze_support() -> None:
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
import multiprocessing
_extend_freeze_support()
multiprocessing.freeze_support()

View File

@@ -2,7 +2,8 @@ import json
import pickle
from uuid import UUID
from flask import request, session, url_for, Markup
from flask import request, session, url_for
from markupsafe import Markup
from pony.orm import commit
from WebHostLib import app

View File

@@ -1,7 +1,8 @@
import zipfile
from typing import *
from flask import request, flash, redirect, url_for, render_template, Markup
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from WebHostLib import app
@@ -91,7 +92,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
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}"
results[filename] = f"Failed to generate options in {filename}: {e}"
else:
results[filename] = True
return results, rolled_results

View File

@@ -18,7 +18,7 @@ from pony.orm import commit, db_session, select
import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from Utils import restricted_loads, cache_argsless
from .models import Command, GameDataPackage, Room, db
@@ -169,13 +169,11 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
ping_interval=None, ssl=ssl_context)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
port = 0

View File

@@ -106,7 +106,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta["generator_options"].setdefault("race", False)
race = meta.setdefault("generator_options", {}).setdefault("race", False)
def task():
target = tempfile.TemporaryDirectory()
@@ -123,7 +123,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = meta["generator_options"]["spoiler"]
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name

View File

@@ -21,7 +21,7 @@ class Slot(db.Entity):
class Room(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
owner = Required(UUID, index=True)
commands = Set('Command')
seed = Required('Seed', index=True)
@@ -38,7 +38,7 @@ class Seed(db.Entity):
rooms = Set(Room)
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags

View File

@@ -44,7 +44,7 @@ def create():
# Generate JSON files for player-settings pages
player_settings = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
"name": "Player",
},

View File

@@ -1,7 +1,9 @@
flask>=2.2.3
pony>=0.7.16
pony>=0.7.16; python_version <= '3.10'
pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
waitress>=2.1.2
Flask-Caching>=2.0.2
Flask-Compress>=1.13
Flask-Limiter>=3.3.0
bokeh>=3.1.0
bokeh>=3.1.1
markupsafe>=2.1.3

View File

@@ -148,7 +148,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
@@ -185,7 +185,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
@@ -269,7 +269,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, [specialRange, specialRangeSelect])
event, specialRange, specialRangeSelect)
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
@@ -294,23 +294,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table;
};
const toggleRandomize = (event, inputElements) => {
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameSetting(randomButton);
};
const updateBaseSetting = (event) => {
@@ -364,6 +366,7 @@ const generateGame = (raceMode = false) => {
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;

View File

@@ -1199,6 +1199,7 @@ const generateGame = (raceMode = false) => {
weights: { player: JSON.stringify(settings) },
presetData: { player: JSON.stringify(settings) },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;

View File

@@ -31,12 +31,12 @@
<th>#</th>
<th>Name</th>
<th>Game</th>
<th>Status</th>
{% block custom_table_headers %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column">Status</th>
<th class="center-column hours">Last<br>Activity</th>
</tr>
</thead>
@@ -47,13 +47,15 @@
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
<td>{{ games[player] }}</td>
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
{% block custom_table_row scoped %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<td class="center-column">{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}</td>
<td class="center-column" data-sort="{{ checks["Total"] }}">
{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}
</td>
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
{%- if activity_timers[team, player] -%}
<td class="center-column">{{ activity_timers[team, player].total_seconds() }}</td>
{%- else -%}

View File

@@ -32,7 +32,6 @@
<h2>Tutorials</h2>
<ul>
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="/tutorial/Archipelago/using_website/en">Website User Guide</a></li>
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>

View File

@@ -7,12 +7,13 @@ import zipfile
import zlib
from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template, Markup
from flask import request, flash, redirect, url_for, session, render_template
from markupsafe import Markup
from pony.orm import commit, flush, select, rollback
from pony.orm.core import TransactionIntegrityError
import MultiServer
from NetUtils import NetworkSlot, SlotType
from NetUtils import SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app

View File

@@ -46,7 +46,7 @@ class ZeldaCommandProcessor(ClientCommandProcessor):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")

View File

@@ -27,8 +27,8 @@ end
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_major == 2 and bizhawk_minor >= 3 and bizhawk_minor <= 5)
local isGreaterOrEqualTo26 = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor >= 6)
local isUntestedBizhawk = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9)
local untestedBizhawkMessage = "Warning: this version of bizhawk is newer than we know about. If it doesn't work, consider downgrading to 2.9"
local isUntestedBizHawk = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9)
local untestedBizHawkMessage = "Warning: this version of BizHawk is newer than we know about. If it doesn't work, consider downgrading to 2.9"
u8 = memory.read_u8
wU8 = memory.write_u8
@@ -94,12 +94,12 @@ function drawMessages()
end
end
function checkBizhawkVersion()
function checkBizHawkVersion()
if not is23Or24Or25 and not isGreaterOrEqualTo26 then
print("Must use a version of bizhawk 2.3.1 or higher")
print("Must use a version of BizHawk 2.3.1 or higher")
return false
elseif isUntestedBizhawk then
print(untestedBizhawkMessage)
elseif isUntestedBizHawk then
print(untestedBizHawkMessage)
end
return true
end

View File

@@ -457,7 +457,7 @@ end
function main()
memory.usememorydomain("System Bus")
if not checkBizhawkVersion() then
if not checkBizHawkVersion() then
return
end
local playerSlot = memory.read_u8(PlayerSlotAddress)

View File

@@ -414,7 +414,7 @@ function receive()
end
function main()
if not checkBizhawkVersion() then
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)

View File

@@ -3,8 +3,8 @@
-- SPDX-License-Identifier: MIT
-- This script attempts to implement the basic functionality needed in order for
-- the LADXR Archipelago client to be able to talk to BizHawk instead of RetroArch
-- by reproducing the RetroArch API with BizHawk's Lua interface.
-- the LADXR Archipelago client to be able to talk to EmuHawk instead of RetroArch
-- by reproducing the RetroArch API with EmuHawk's Lua interface.
--
-- RetroArch UDP API: https://github.com/libretro/RetroArch/blob/master/command.c
--
@@ -16,19 +16,19 @@
-- commands are supported right now.
--
-- USAGE:
-- Load this script in BizHawk ("Tools" -> "Lua Console" -> "Script" -> "Open Script")
-- Load this script in EmuHawk ("Tools" -> "Lua Console" -> "Script" -> "Open Script", or drag+drop)
--
-- All inconsistencies (like missing newlines for some commands) of the RetroArch
-- UDP API (network_cmd_enable) are reproduced as-is in order for clients written to work with
-- RetroArch's current API to "just work"(tm).
--
-- This script has only been tested on GB(C). If you have made sure it works for N64 or other
-- cores supported by BizHawk, please let me know. Note that GET_STATUS, at the very least, will
-- cores supported by EmuHawk, please let me know. Note that GET_STATUS, at the very least, will
-- have to be adjusted.
--
--
-- NOTE:
-- BizHawk's Lua API is very trigger-happy on throwing exceptions.
-- EmuHawk's Lua API is very trigger-happy on throwing exceptions.
-- Emulation will continue fine, but the RetroArch API layer will stop working. This
-- is indicated only by an exception visible in the Lua console, which most players
-- will probably not have in the foreground.
@@ -82,7 +82,7 @@ while true do
-- "GET_STATUS PLAYING game_boy,AP_62468482466172374046_P1_Lonk,crc32=3ecb7b6f"
-- CRC32 isn't readily available through the Lua API. We could calculate
-- it ourselves, but since LADXR doesn't make use of this field it is
-- simply replaced by the hash that BizHawk _does_ make available.
-- simply replaced by the hash that EmuHawk _does_ make available.
udp:sendto(
"GET_STATUS " .. status .. " game_boy," ..

View File

@@ -0,0 +1,723 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require('common')
local last_modified_date = '2023-31-05' -- Should be the last modified date
local script_version = 4
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local mmbn3Socket = nil
local frame = 0
-- States
local ITEMSTATE_NONINITIALIZED = "Game Not Yet Started" -- Game has not yet started
local ITEMSTATE_NONITEM = "Non-Itemable State" -- Do not send item now. RAM is not capable of holding
local ITEMSTATE_IDLE = "Item State Ready" -- Ready for the next item if there are any
local ITEMSTATE_SENT = "Item Sent Not Claimed" -- The ItemBit is set, but the dialog has not been closed yet
local itemState = ITEMSTATE_NONINITIALIZED
local itemQueued = nil
local itemQueueCounter = 120
local debugEnabled = false
local game_complete = false
local backup_bytes = nil
local itemsReceived = {}
local previousMessageBit = 0x00
local key_item_start_address = 0x20019C0
-- The Canary Byte is a flag byte that is intentionally left unused. If this byte is FF, then we know the flag
-- data cannot be trusted, so we don't send checks.
local canary_byte = 0x20001A9
local charDict = {
[' ']=0x00,['0']=0x01,['1']=0x02,['2']=0x03,['3']=0x04,['4']=0x05,['5']=0x06,['6']=0x07,['7']=0x08,['8']=0x09,['9']=0x0A,
['A']=0x0B,['B']=0x0C,['C']=0x0D,['D']=0x0E,['E']=0x0F,['F']=0x10,['G']=0x11,['H']=0x12,['I']=0x13,['J']=0x14,['K']=0x15,
['L']=0x16,['M']=0x17,['N']=0x18,['O']=0x19,['P']=0x1A,['Q']=0x1B,['R']=0x1C,['S']=0x1D,['T']=0x1E,['U']=0x1F,['V']=0x20,
['W']=0x21,['X']=0x22,['Y']=0x23,['Z']=0x24,['a']=0x25,['b']=0x26,['c']=0x27,['d']=0x28,['e']=0x29,['f']=0x2A,['g']=0x2B,
['h']=0x2C,['i']=0x2D,['j']=0x2E,['k']=0x2F,['l']=0x30,['m']=0x31,['n']=0x32,['o']=0x33,['p']=0x34,['q']=0x35,['r']=0x36,
['s']=0x37,['t']=0x38,['u']=0x39,['v']=0x3A,['w']=0x3B,['x']=0x3C,['y']=0x3D,['z']=0x3E,['-']=0x3F,['×']=0x40,[']=']=0x41,
[':']=0x42,['+']=0x43,['÷']=0x44,['']=0x45,['*']=0x46,['!']=0x47,['?']=0x48,['%']=0x49,['&']=0x4A,[',']=0x4B,['']=0x4C,
['.']=0x4D,['']=0x4E,[';']=0x4F,['\'']=0x50,['\"']=0x51,['~']=0x52,['/']=0x53,['(']=0x54,[')']=0x55,['']=0x56,['']=0x57,
["[V2]"]=0x58,["[V3]"]=0x59,["[V4]"]=0x5A,["[V5]"]=0x5B,['@']=0x5C,['']=0x5D,['']=0x5E,["[MB]"]=0x5F,['']=0x60,['_']=0x61,
["[circle1]"]=0x62,["[circle2]"]=0x63,["[cross1]"]=0x64,["[cross2]"]=0x65,["[bracket1]"]=0x66,["[bracket2]"]=0x67,["[ModTools1]"]=0x68,
["[ModTools2]"]=0x69,["[ModTools3]"]=0x6A,['Σ']=0x6B,['Ω']=0x6C,['α']=0x6D,['β']=0x6E,['#']=0x6F,['']=0x70,['>']=0x71,
['<']=0x72,['']=0x73,["[BowneGlobal1]"]=0x74,["[BowneGlobal2]"]=0x75,["[BowneGlobal3]"]=0x76,["[BowneGlobal4]"]=0x77,
["[BowneGlobal5]"]=0x78,["[BowneGlobal6]"]=0x79,["[BowneGlobal7]"]=0x7A,["[BowneGlobal8]"]=0x7B,["[BowneGlobal9]"]=0x7C,
["[BowneGlobal10]"]=0x7D,["[BowneGlobal11]"]=0x7E,['\n']=0xE8
}
local TableConcat = function(t1,t2)
for i=1,#t2 do
t1[#t1+1] = t2[i]
end
return t1
end
local int32ToByteList_le = function(x)
bytes = {}
hexString = string.format("%08x", x)
for i=#hexString, 1, -2 do
hbyte = hexString:sub(i-1, i)
table.insert(bytes,tonumber(hbyte,16))
end
return bytes
end
local int16ToByteList_le = function(x)
bytes = {}
hexString = string.format("%04x", x)
for i=#hexString, 1, -2 do
hbyte = hexString:sub(i-1, i)
table.insert(bytes,tonumber(hbyte,16))
end
return bytes
end
local IsInMenu = function()
return bit.band(memory.read_u8(0x0200027A),0x10) ~= 0
end
local IsInTransition = function()
return bit.band(memory.read_u8(0x02001880), 0x10) ~= 0
end
local IsInDialog = function()
return bit.band(memory.read_u8(0x02009480),0x01) ~= 0
end
local IsInBattle = function()
return memory.read_u8(0x020097F8) == 0x08
end
local IsItemQueued = function()
return memory.read_u8(0x2000224) == 0x01
end
-- This function actually determines when you're on ANY full-screen menu (navi cust, link battle, etc.) but we
-- don't want to check any locations there either so it's fine.
local IsOnTitle = function()
return bit.band(memory.read_u8(0x020097F8),0x04) == 0
end
local IsItemable = function()
return not IsInMenu() and not IsInTransition() and not IsInDialog() and not IsInBattle() and not IsOnTitle() and not IsItemQueued()
end
local is_game_complete = function()
if IsOnTitle() or itemState == ITEMSTATE_NONINITIALIZED then return game_complete end
-- If the game is already marked complete, do not read memory
if game_complete then return true end
local is_alpha_defeated = bit.band(memory.read_u8(0x2000433), 0x01) ~= 0
if (is_alpha_defeated) then
game_complete = true
return true
end
-- Game is still ongoing
return false
end
local saveItemIndexToRAM = function(newIndex)
memory.write_s16_le(0x20000AE,newIndex)
end
local loadItemIndexFromRAM = function()
last_index = memory.read_s16_le(0x20000AE)
if (last_index < 0) then
last_index = 0
saveItemIndexToRAM(0)
end
return last_index
end
local loadPlayerNameFromROM = function()
return memory.read_bytes_as_array(0x7FFFC0,63,"ROM")
end
local check_all_locations = function()
local location_checks = {}
-- Title Screen should not check items
if itemState == ITEMSTATE_NONINITIALIZED or IsInTransition() then
return location_checks
end
if memory.read_u8(canary_byte) == 0xFF then
return location_checks
end
for k,v in pairs(memory.read_bytes_as_dict(0x02000000, 0x434)) do
str_k = string.format("%x", k)
location_checks[str_k] = v
end
return location_checks
end
local Check_Progressive_Undernet_ID = function()
ordered_offsets = { 0x020019DB,0x020019DC,0x020019DD,0x020019DE,0x020019DF,0x020019E0,0x020019FA,0x020019E2 }
for i=1,#ordered_offsets do
offset=ordered_offsets[i]
if memory.read_u8(offset) == 0 then
return i
end
end
return 9
end
local GenerateTextBytes = function(message)
bytes = {}
for i = 1, #message do
local c = message:sub(i,i)
table.insert(bytes, charDict[c])
end
return bytes
end
-- Item Message Generation functions
local Next_Progressive_Undernet_ID = function(index)
ordered_IDs = { 27,28,29,30,31,32,58,34}
if index > #ordered_IDs then
--It shouldn't reach this point, but if it does, just give another GigFreez I guess
return 34
end
item_index=ordered_IDs[index]
return item_index
end
local Extra_Progressive_Undernet = function()
fragBytes = int32ToByteList_le(20)
bytes = {
0xF6, 0x50, fragBytes[1], fragBytes[2], fragBytes[3], fragBytes[4], 0xFF, 0xFF, 0xFF
}
bytes = TableConcat(bytes, GenerateTextBytes("The extra data\ndecompiles into:\n\"20 BugFrags\"!!"))
return bytes
end
local GenerateChipGet = function(chip, code, amt)
chipBytes = int16ToByteList_le(chip)
bytes = {
0xF6, 0x10, chipBytes[1], chipBytes[2], code, amt,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict[' '], charDict['c'], charDict['h'], charDict['i'], charDict['p'], charDict[' '], charDict['f'], charDict['o'], charDict['r'], charDict['\n'],
}
if chip < 256 then
bytes = TableConcat(bytes, {
charDict['\"'], 0xF9,0x00,chipBytes[1],0x01,0x00,0xF9,0x00,code,0x03, charDict['\"'],charDict['!'],charDict['!']
})
else
bytes = TableConcat(bytes, {
charDict['\"'], 0xF9,0x00,chipBytes[1],0x02,0x00,0xF9,0x00,code,0x03, charDict['\"'],charDict['!'],charDict['!']
})
end
return bytes
end
local GenerateKeyItemGet = function(item, amt)
bytes = {
0xF6, 0x00, item, amt,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'],
charDict['\"'], 0xF9, 0x00, item, 0x00, charDict['\"'],charDict['!'],charDict['!']
}
return bytes
end
local GenerateSubChipGet = function(subchip, amt)
-- SubChips have an extra bit of trouble. If you have too many, they're supposed to skip to another text bank that doesn't give you the item
-- Instead, I'm going to just let it get eaten
bytes = {
0xF6, 0x20, subchip, amt, 0xFF, 0xFF, 0xFF,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'],
charDict['S'], charDict['u'], charDict['b'], charDict['C'], charDict['h'], charDict['i'], charDict['p'], charDict[' '], charDict['f'], charDict['o'], charDict['r'], charDict['\n'],
charDict['\"'], 0xF9, 0x00, subchip, 0x00, charDict['\"'],charDict['!'],charDict['!']
}
return bytes
end
local GenerateZennyGet = function(amt)
zennyBytes = int32ToByteList_le(amt)
bytes = {
0xF6, 0x30, zennyBytes[1], zennyBytes[2], zennyBytes[3], zennyBytes[4], 0xFF, 0xFF, 0xFF,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'], charDict['\"']
}
-- The text needs to be added one char at a time, so we need to convert the number to a string then iterate through it
zennyStr = tostring(amt)
for i = 1, #zennyStr do
local c = zennyStr:sub(i,i)
table.insert(bytes, charDict[c])
end
bytes = TableConcat(bytes, {
charDict[' '], charDict['Z'], charDict['e'], charDict['n'], charDict['n'], charDict['y'], charDict['s'], charDict['\"'],charDict['!'],charDict['!']
})
return bytes
end
local GenerateProgramGet = function(program, color, amt)
bytes = {
0xF6, 0x40, (program * 4), amt, color,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict[' '], charDict['N'], charDict['a'], charDict['v'], charDict['i'], charDict['\n'],
charDict['C'], charDict['u'], charDict['s'], charDict['t'], charDict['o'], charDict['m'], charDict['i'], charDict['z'], charDict['e'], charDict['r'], charDict[' '], charDict['P'], charDict['r'], charDict['o'], charDict['g'], charDict['r'], charDict['a'], charDict['m'], charDict[':'], charDict['\n'],
charDict['\"'], 0xF9, 0x00, program, 0x05, charDict['\"'],charDict['!'],charDict['!']
}
return bytes
end
local GenerateBugfragGet = function(amt)
fragBytes = int32ToByteList_le(amt)
bytes = {
0xF6, 0x50, fragBytes[1], fragBytes[2], fragBytes[3], fragBytes[4], 0xFF, 0xFF, 0xFF,
charDict['G'], charDict['o'], charDict['t'], charDict[':'], charDict['\n'], charDict['\"']
}
-- The text needs to be added one char at a time, so we need to convert the number to a string then iterate through it
bugFragStr = tostring(amt)
for i = 1, #bugFragStr do
local c = bugFragStr:sub(i,i)
table.insert(bytes, charDict[c])
end
bytes = TableConcat(bytes, {
charDict[' '], charDict['B'], charDict['u'], charDict['g'], charDict['F'], charDict['r'], charDict['a'], charDict['g'], charDict['s'], charDict['\"'],charDict['!'],charDict['!']
})
return bytes
end
local GenerateGetMessageFromItem = function(item)
--Special case for progressive undernet
if item["type"] == "undernet" then
undernet_id = Check_Progressive_Undernet_ID()
if undernet_id > 8 then
return Extra_Progressive_Undernet()
end
return GenerateKeyItemGet(Next_Progressive_Undernet_ID(undernet_id),1)
elseif item["type"] == "chip" then
return GenerateChipGet(item["itemID"], item["subItemID"], item["count"])
elseif item["type"] == "key" then
return GenerateKeyItemGet(item["itemID"], item["count"])
elseif item["type"] == "subchip" then
return GenerateSubChipGet(item["itemID"], item["count"])
elseif item["type"] == "zenny" then
return GenerateZennyGet(item["count"])
elseif item["type"] == "program" then
return GenerateProgramGet(item["itemID"], item["subItemID"], item["count"])
elseif item["type"] == "bugfrag" then
return GenerateBugfragGet(item["count"])
end
return GenerateTextBytes("Empty Message")
end
local GetMessage = function(item)
startBytes = {0x02, 0x00}
playerLockBytes = {0xF8,0x00, 0xF8, 0x10}
msgOpenBytes = {0xF1, 0x02}
textBytes = GenerateTextBytes("Receiving\ndata from\n"..item["sender"]..".")
dotdotWaitBytes = {0xEA,0x00,0x0A,0x00,0x4D,0xEA,0x00,0x0A,0x00,0x4D}
continueBytes = {0xEB, 0xE9}
-- continueBytes = {0xE9}
playReceiveAnimationBytes = {0xF8,0x04,0x18}
chipGiveBytes = GenerateGetMessageFromItem(item)
playerFinishBytes = {0xF8, 0x0C}
playerUnlockBytes={0xEB, 0xF8, 0x08}
-- playerUnlockBytes={0xF8, 0x08}
endMessageBytes = {0xF8, 0x10, 0xE7}
bytes = {}
bytes = TableConcat(bytes,startBytes)
bytes = TableConcat(bytes,playerLockBytes)
bytes = TableConcat(bytes,msgOpenBytes)
bytes = TableConcat(bytes,textBytes)
bytes = TableConcat(bytes,dotdotWaitBytes)
bytes = TableConcat(bytes,continueBytes)
bytes = TableConcat(bytes,playReceiveAnimationBytes)
bytes = TableConcat(bytes,chipGiveBytes)
bytes = TableConcat(bytes,playerFinishBytes)
bytes = TableConcat(bytes,playerUnlockBytes)
bytes = TableConcat(bytes,endMessageBytes)
return bytes
end
local getChipCodeIndex = function(chip_id, chip_code)
chipCodeArrayStartAddress = 0x8011510 + (0x20 * chip_id)
for i=1,6 do
currentCode = memory.read_u8(chipCodeArrayStartAddress + (i-1))
if currentCode == chip_code then
return i-1
end
end
return 0
end
local getProgramColorIndex = function(program_id, program_color)
-- The general case, most programs use white pink or yellow. This is the values the enums already have
if program_id >= 20 and program_id <= 47 then
return program_color-1
end
--The final three programs only have a color index 0, so just return those
if program_id > 47 then
return 0
end
--BrakChrg as an AP item only comes in orange, index 0
if program_id == 3 then
return 0
end
-- every other AP obtainable program returns only color index 3
return 3
end
local addChip = function(chip_id, chip_code, amount)
chipStartAddress = 0x02001F60
chipOffset = 0x12 * chip_id
chip_code_index = getChipCodeIndex(chip_id, chip_code)
currentChipAddress = chipStartAddress + chipOffset + chip_code_index
currentChipCount = memory.read_u8(currentChipAddress)
memory.write_u8(currentChipAddress,currentChipCount+amount)
end
local addProgram = function(program_id, program_color, amount)
programStartAddress = 0x02001A80
programOffset = 0x04 * program_id
program_code_index = getProgramColorIndex(program_id, program_color)
currentProgramAddress = programStartAddress + programOffset + program_code_index
currentProgramCount = memory.read_u8(currentProgramAddress)
memory.write_u8(currentProgramAddress, currentProgramCount+amount)
end
local addSubChip = function(subchip_id, amount)
subChipStartAddress = 0x02001A30
--SubChip indices start after the key items, so subtract 112 from the index to get the actual subchip index
currentSubChipAddress = subChipStartAddress + (subchip_id - 112)
currentSubChipCount = memory.read_u8(currentSubChipAddress)
--TODO check submem, reject if number too big
memory.write_u8(currentSubChipAddress, currentSubChipCount+amount)
end
local changeZenny = function(val)
if val == nil then
return 0
end
if memory.read_u32_le(0x20018F4) <= math.abs(tonumber(val)) and tonumber(val) < 0 then
memory.write_u32_le(0x20018f4, 0)
val = 0
return "empty"
end
memory.write_u32_le(0x20018f4, memory.read_u32_le(0x20018F4) + tonumber(val))
if memory.read_u32_le(0x20018F4) > 999999 then
memory.write_u32_le(0x20018F4, 999999)
end
return val
end
local changeFrags = function(val)
if val == nil then
return 0
end
if memory.read_u16_le(0x20018F8) <= math.abs(tonumber(val)) and tonumber(val) < 0 then
memory.write_u16_le(0x20018f8, 0)
val = 0
return "empty"
end
memory.write_u16_le(0x20018f8, memory.read_u16_le(0x20018F8) + tonumber(val))
if memory.read_u16_le(0x20018F8) > 9999 then
memory.write_u16_le(0x20018F8, 9999)
end
return val
end
-- Fix Health Pools
local fix_hp = function()
-- Current Health fix
if IsInBattle() and not (memory.read_u16_le(0x20018A0) == memory.read_u16_le(0x2037294)) then
memory.write_u16_le(0x20018A0, memory.read_u16_le(0x2037294))
end
-- Max Health Fix
if IsInBattle() and not (memory.read_u16_le(0x20018A2) == memory.read_u16_le(0x2037296)) then
memory.write_u16_le(0x20018A2, memory.read_u16_le(0x2037296))
end
end
local changeRegMemory = function(amt)
regMemoryAddress = 0x02001897
currentRegMem = memory.read_u8(regMemoryAddress)
memory.write_u8(regMemoryAddress, currentRegMem + amt)
end
local changeMaxHealth = function(val)
fix_hp()
if val == nil then
fix_hp()
return 0
end
if math.abs(tonumber(val)) >= memory.read_u16_le(0x20018A2) and tonumber(val) < 0 then
memory.write_u16_le(0x20018A2, 0)
if IsInBattle() then
memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2))
if memory.read_u16_le(0x2037296) >= memory.read_u16_le(0x20018A2) then
memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2))
end
end
fix_hp()
return "lethal"
end
memory.write_u16_le(0x20018A2, memory.read_u16_le(0x20018A2) + tonumber(val))
if memory.read_u16_le(0x20018A2) > 9999 then
memory.write_u16_le(0x20018A2, 9999)
end
if IsInBattle() then
memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2))
end
fix_hp()
return val
end
local SendItem = function(item)
if item["type"] == "undernet" then
undernet_id = Check_Progressive_Undernet_ID()
if undernet_id > 8 then
-- Generate Extra BugFrags
changeFrags(20)
gui.addmessage("Receiving extra Undernet Rank from "..item["sender"]..", +20 BugFrags")
-- print("Receiving extra Undernet Rank from "..item["sender"]..", +20 BugFrags")
else
itemAddress = key_item_start_address + Next_Progressive_Undernet_ID(undernet_id)
itemCount = memory.read_u8(itemAddress)
itemCount = itemCount + item["count"]
memory.write_u8(itemAddress, itemCount)
gui.addmessage("Received Undernet Rank from player "..item["sender"])
-- print("Received Undernet Rank from player "..item["sender"])
end
elseif item["type"] == "chip" then
addChip(item["itemID"], item["subItemID"], item["count"])
gui.addmessage("Received Chip "..item["itemName"].." from player "..item["sender"])
-- print("Received Chip "..item["itemName"].." from player "..item["sender"])
elseif item["type"] == "key" then
itemAddress = key_item_start_address + item["itemID"]
itemCount = memory.read_u8(itemAddress)
itemCount = itemCount + item["count"]
memory.write_u8(itemAddress, itemCount)
-- HPMemory will increase the internal counter but not actually increase the HP. If the item is one of those, do that
if item["itemID"] == 96 then
changeMaxHealth(20)
end
-- Same for the RegUps, but there's three of those
if item["itemID"] == 98 then
changeRegMemory(1)
end
if item["itemID"] == 99 then
changeRegMemory(2)
end
if item["itemID"] == 100 then
changeRegMemory(3)
end
gui.addmessage("Received Key Item "..item["itemName"].." from player "..item["sender"])
-- print("Received Key Item "..item["itemName"].." from player "..item["sender"])
elseif item["type"] == "subchip" then
addSubChip(item["itemID"], item["count"])
gui.addmessage("Received SubChip "..item["itemName"].." from player "..item["sender"])
-- print("Received SubChip "..item["itemName"].." from player "..item["sender"])
elseif item["type"] == "zenny" then
changeZenny(item["count"])
gui.addmessage("Received "..item["count"].."z from "..item["sender"])
-- print("Received "..item["count"].."z from "..item["sender"])
elseif item["type"] == "program" then
addProgram(item["itemID"], item["subItemID"], item["count"])
gui.addmessage("Received Program "..item["itemName"].." from player "..item["sender"])
-- print("Received Program "..item["itemName"].." from player "..item["sender"])
elseif item["type"] == "bugfrag" then
changeFrags(item["count"])
gui.addmessage("Received "..item["count"].." BugFrag(s) from "..item["sender"])
-- print("Received "..item["count"].." BugFrag(s) from "..item["sender"])
end
end
-- Set the flags for opening the shortcuts as soon as the Cybermetro passes are received to save having to check email
local OpenShortcuts = function()
if (memory.read_u8(key_item_start_address + 92) > 0) then
memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x10))
end
-- if CSciPass
if (memory.read_u8(key_item_start_address + 93) > 0) then
memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x08))
end
if (memory.read_u8(key_item_start_address + 94) > 0) then
memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x20))
end
if (memory.read_u8(key_item_start_address + 95) > 0) then
memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x40))
end
end
local RestoreItemRam = function()
if backup_bytes ~= nil then
memory.write_bytes_as_array(0x203fe10, backup_bytes)
end
backup_bytes = nil
end
local process_block = function(block)
-- Sometimes the block is nothing, if this is the case then quietly stop processing
if block == nil then
return
end
debugEnabled = block['debug']
-- Queue item for receiving, if one exists
if (itemsReceived ~= block['items']) then
itemsReceived = block['items']
end
return
end
local itemStateMachineProcess = function()
if itemState == ITEMSTATE_NONINITIALIZED then
itemQueueCounter = 120
-- Only exit this state the first time a dialog window pops up. This way we know for sure that we're ready to receive
if not IsInMenu() and (IsInDialog() or IsInTransition()) then
itemState = ITEMSTATE_NONITEM
end
elseif itemState == ITEMSTATE_NONITEM then
itemQueueCounter = 120
-- Always attempt to restore the previously stored memory in this state
-- Exit this state whenever the game is in an itemable status
if IsItemable() then
itemState = ITEMSTATE_IDLE
end
elseif itemState == ITEMSTATE_IDLE then
-- Remain Idle until an item is sent or we enter a non itemable status
if not IsItemable() then
itemState = ITEMSTATE_NONITEM
end
if itemQueueCounter == 0 then
if #itemsReceived > loadItemIndexFromRAM() and not IsItemQueued() then
itemQueued = itemsReceived[loadItemIndexFromRAM()+1]
SendItem(itemQueued)
itemState = ITEMSTATE_SENT
end
else
itemQueueCounter = itemQueueCounter - 1
end
elseif itemState == ITEMSTATE_SENT then
-- Once the item is sent, wait for the dialog to close. Then clear the item bit and be ready for the next item.
if IsInTransition() or IsInMenu() or IsOnTitle() then
itemState = ITEMSTATE_NONITEM
itemQueued = nil
RestoreItemRam()
elseif not IsInDialog() then
itemState = ITEMSTATE_IDLE
saveItemIndexToRAM(itemQueued["itemIndex"])
itemQueued = nil
RestoreItemRam()
end
end
end
local receive = function()
l, e = mmbn3Socket:receive()
-- Handle incoming message
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
process_block(json.decode(l))
end
local send = function()
-- Determine message to send back
local retTable = {}
retTable["playerName"] = loadPlayerNameFromROM()
retTable["scriptVersion"] = script_version
retTable["locations"] = check_all_locations()
retTable["gameComplete"] = is_game_complete()
-- Send the message
msg = json.encode(retTable).."\n"
local ret, error = mmbn3Socket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
curstate = STATE_OK
end
end
function main()
if (bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor >= 7)==false) then
print("Must use a version of bizhawk 2.7.0 or higher")
return
end
server, error = socket.bind('localhost', 28922)
while true do
frame = frame + 1
if not (curstate == prevstate) then
prevstate = curstate
end
itemStateMachineProcess()
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
-- If we're connected and everything's fine, receive and send data from the network
if (frame % 60 == 0) then
receive()
send()
-- Perform utility functions which read and write data but aren't directly related to checks
OpenShortcuts()
end
elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then
server:settimeout(2)
local client, timeout = server:accept()
if timeout == nil then
print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
mmbn3Socket = client
mmbn3Socket:settimeout(0)
else
print('Connection failed, ensure MMBN3Client is running and rerun connector_mmbn3.lua')
return
end
end
end
-- Handle the debug data display
gui.cleartext()
if debugEnabled then
-- gui.text(0,0,"Item Queued: "..tostring(IsItemQueued()))
-- gui.text(0,16,"In Battle: "..tostring(IsInBattle()))
-- gui.text(0,32,"In Dialog: "..tostring(IsInDialog()))
-- gui.text(0,48,"In Menu: "..tostring(IsInMenu()))
gui.text(0,48,"Item Wait Time: "..tostring(itemQueueCounter))
gui.text(0,64,itemState)
if itemQueued == nil then
gui.text(0,80,"No item queued")
else
gui.text(0,80,itemQueued["type"].." "..itemQueued["itemID"])
end
gui.text(0,96,"Item Index: "..loadItemIndexFromRAM())
end
emu.frameadvance()
end
end
main()

View File

@@ -1862,7 +1862,7 @@ function receive()
end
function main()
if not checkBizhawkVersion() then
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 28921)

View File

@@ -167,7 +167,7 @@ function receive()
end
function main()
if not checkBizhawkVersion() then
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 17242)

View File

@@ -561,7 +561,7 @@ function receive()
end
function main()
if not checkBizhawkVersion() then
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)

View File

@@ -341,3 +341,4 @@ The various methods and attributes are documented in `/worlds/AutoWorld.py[World
[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.
Make sure to check out [world maintainer.md](./world%20maintainer.md) before publishing.

View File

@@ -10,3 +10,5 @@ Otherwise, we tend to judge code on a case to case basis.
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](https://archipelago.gg/discord).
If you want to merge a new game, please make sure to read the responsibilities as
[world maintainer](/docs/world%20maintainer.md).

View File

@@ -35,7 +35,7 @@ flowchart LR
subgraph Final Fantasy 1
FF1[FF1Client]
FFLUA[Lua Connector]
BZFF[BizHawk with Final Fantasy Loaded]
BZFF[EmuHawk with Final Fantasy Loaded]
FF1 <-- LuaSockets --> FFLUA
FFLUA <--> BZFF
end
@@ -45,7 +45,7 @@ flowchart LR
subgraph Ocarina of Time
OC[OoTClient]
LC[Lua Connector]
OCB[BizHawk with Ocarina of Time Loaded]
OCB[EmuHawk with Ocarina of Time Loaded]
OC <-- LuaSockets --> LC
LC <--> OCB
end

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -69,6 +69,19 @@ It should be dropped as "SNI" into the root folder of the project. Alternatively
host.yaml at your SNI folder.
## Optional: Git
[Git](https://git-scm.com) is required to install some of the packages that Archipelago depends on.
It may be possible to run Archipelago from source without it, at your own risk.
It is also generally recommended to have Git installed and understand how to use it, especially if you're thinking about contributing.
You can download the latest release of Git at [The downloads page on the Git website](https://git-scm.com/downloads).
Beyond that, there are also graphical interfaces for Git that make it more accessible.
For repositories on Github (such as this one), [Github Desktop](https://desktop.github.com) is one such option.
PyCharm has a built-in version control integration that supports Git.
## Running tests
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.

View File

@@ -111,8 +111,8 @@ World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
Special locations with ID `None` can hold events.
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
The Fill algorithm will fill priority first, giving higher chance of it being
required, and not place progression or useful items in excluded locations.
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
required, and will prevent progression and useful items from being placed at excluded locations.
### Items
@@ -192,7 +192,7 @@ on a single item. It can be used to reject placement of an item there.
### Your World
All code for your world implementation should be placed in a python package in
the `/worlds` directory. The starting point for the package is `__init.py__`.
the `/worlds` directory. The starting point for the package is `__init__.py`.
Conventionally, your world class is placed in that file.
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,

60
docs/world maintainer.md Normal file
View File

@@ -0,0 +1,60 @@
# World Maintainer
A world maintainer is a person responsible for a world or part of a world in Archipelago.
If a world author does not want to take on the responsibilities of a world maintainer, they can release their world as
an unofficial [APWorld](/docs/apworld%20specification.md) or maintain their own fork instead.
## Responsibilities
Unless these are shared between multiple people, we expect the following from each world maintainer
* Be on our Discord to get updates on problems with and suggestions for the world.
* Decide if a feature (pull request) should be merged.
* Review contents of such pull requests or organize peer reviews or post that you did not review the content.
* Fix or point out issues when core changes break your code.
* Use the watch function on GitHub, the #github-updates channel on Discord or check manually from time to time for new
pull requests. Core maintainers may also ping you if a pull request concerns your world.
* Test (or have tested) the world on the main branch from time to time, especially during RC (release candidate) phases
of development.
* Let us know of long unavailabilities.
## Becoming a World Maintainer
### Adding a World
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
nominate someone else (i.e. there are multiple devs).
### Getting Voted
When a world is unmaintained, the [core maintainers](https://github.com/orgs/ArchipelagoMW/people)
can vote for a new maintainer if there is a candidate.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 1 week, but can end early if the majority is reached earlier.
Voting shall be conducted on Discord in #archipelago-dev.
## Dropping out
### Resigning
A world maintainer can resign. If no new maintainer steps up and gets voted, the world becomes unmaintained.
### Getting Voted out
A world maintainer can be voted out by the [core maintainers](https://github.com/orgs/ArchipelagoMW/people),
for example when they become unreachable.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
made their case or was pinged and has been unreachable for more than 2 weeks already.
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds
As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
moved from `worlds/` to `worlds_disabled/`.

View File

@@ -136,7 +136,7 @@ tloz_options:
# true for operating system default program
# Alternatively, a path to a program to open the .nes file with
rom_start: true
# Display message inside of Bizhawk
# Display message inside of EmuHawk
display_msgs: true
dkc3_options:
# File name of the DKC3 US rom
@@ -167,7 +167,10 @@ zillion_options:
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
rom_start: "retroarch"
mmbn3_options:
# File name of the MMBN3 Blue US rom
rom_file: "Mega Man Battle Network 3 - Blue Version (USA).gba"
rom_start: true
adventure_options:
# File name of the standard NTSC Adventure rom.
# The licensed "The 80 Classic Games" CD-ROM contains this.
@@ -178,14 +181,10 @@ adventure_options:
# Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
rom_start: true
# Optional, additional args passed into rom_start before the .bin file
# For example, this can be used to autoload the connector script in BizHawk
# (see BizHawk --lua= option)
# For example, this can be used to autoload the connector script in EmuHawk
# (see EmuHawk --lua= option)
# Windows example:
# rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"
rom_args: " "
# Set this to true to display item received messages in Emuhawk
display_msgs: true

View File

@@ -63,6 +63,7 @@ Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
Name: "generator/mmbn3"; Description: "MegaMan Battle Network 3"; Types: full hosting; ExtraDiskSpaceRequired: 8388608; Flags: disablenouninstallwarning
Name: "generator/ladx"; Description: "Link's Awakening DX ROM Setup"; Types: full hosting
Name: "generator/tloz"; Description: "The Legend of Zelda ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 135168; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting
@@ -81,6 +82,7 @@ Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/pkmn"; Description: "Pokemon Client"
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/mmbn3"; Description: "MegaMan Battle Network 3 Client"; Types: full playing;
Name: "client/ladx"; Description: "Link's Awakening Client"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
@@ -88,6 +90,7 @@ Name: "client/wargroove"; Description: "Wargroove"; Types: full playing
Name: "client/zl"; Description: "Zillion"; Types: full playing
Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing
Name: "client/advn"; Description: "Adventure"; Types: full playing
Name: "client/ut"; Description: "Undertale"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -104,6 +107,7 @@ Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
Source: "{code:GetBN3ROMPath}"; DestDir: "{app}"; DestName: "Mega Man Battle Network 3 - Blue Version (USA).gba"; Flags: external; Components: client/mmbn3
Source: "{code:GetLADXROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"; Flags: external; Components: client/ladx or generator/ladx
Source: "{code:GetTLoZROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The (U) (PRG0) [!].nes"; Flags: external; Components: client/tloz or generator/tloz
Source: "{code:GetAdvnROMPath}"; DestDir: "{app}"; DestName: "ADVNTURE.BIN"; Flags: external; Components: client/advn
@@ -127,10 +131,12 @@ Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: igno
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
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: "{#source_path}\ArchipelagoMMBN3Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/mmbn3
Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/tloz
Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove
Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2
Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn
Source: "{#source_path}\ArchipelagoUndertaleClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ut
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
[Icons]
@@ -146,10 +152,12 @@ Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\Archipelag
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
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: "{group}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Components: client/mmbn3
Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Components: client/tloz
Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2
Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn
Name: "{group}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Components: client/wargroove
Name: "{group}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Components: client/ut
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
@@ -162,10 +170,12 @@ Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\Ar
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
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
Name: "{commondesktop}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Tasks: desktopicon; Components: client/mmbn3
Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Tasks: desktopicon; Components: client/tloz
Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove
Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2
Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn
Name: "{commondesktop}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Tasks: desktopicon; Components: client/ut
[Run]
@@ -179,6 +189,8 @@ Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
[Registry]
@@ -243,6 +255,11 @@ Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Ar
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; Components: client/ladx
@@ -325,6 +342,9 @@ var RedROMFilePage: TInputFileWizardPage;
var bluerom: string;
var BlueROMFilePage: TInputFileWizardPage;
var bn3rom: string;
var BN3ROMFilePage: TInputFileWizardPage;
var ladxrom: string;
var LADXROMFilePage: TInputFileWizardPage;
@@ -444,6 +464,20 @@ begin
'.gb');
end;
function AddGBARomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'GBA ROM files|*.gba|All files|*.*',
'.gba');
end;
function AddSMSRomPage(name: string): TInputFileWizardPage;
begin
Result :=
@@ -452,7 +486,6 @@ begin
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SMS ROM files|*.sms|All files|*.*',
@@ -535,6 +568,8 @@ begin
Result := not (L2ACROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else if (assigned(BN3ROMFilePage)) and (CurPageID = BN3ROMFilePage.ID) then
Result := not (BN3ROMFilePage.Values[0] = '')
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
Result := not (ZlROMFilePage.Values[0] = '')
else if (assigned(RedROMFilePage)) and (CurPageID = RedROMFilePage.ID) then
@@ -759,6 +794,22 @@ begin
Result := '';
end;
function GetBN3ROMPath(Param: string): string;
begin
if Length(bn3rom) > 0 then
Result := bn3rom
else if Assigned(BN3ROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(BN3ROMFilePage.Values[0]), '6fe31df0144759b34ad666badaacc442')
if R <> 0 then
MsgBox('MegaMan Battle Network 3 Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := BN3ROMFilePage.Values[0]
end
else
Result := '';
end;
procedure InitializeWizard();
begin
AddOoTRomPage();
@@ -795,6 +846,10 @@ begin
if Length(bluerom) = 0 then
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
bn3rom := CheckRom('Mega Man Battle Network 3 - Blue Version (USA).gba','6fe31df0144759b34ad666badaacc442');
if Length(bn3rom) = 0 then
BN3ROMFilePage:= AddGBARomPage('Mega Man Battle Network 3 - Blue Version (USA).gba');
ladxrom := CheckRom('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc','07c211479386825042efb4ad31bb525f');
if Length(ladxrom) = 0 then
LADXROMFilePage:= AddGBRomPage('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc');
@@ -836,6 +891,8 @@ begin
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
if (assigned(BN3ROMFilePage)) and (PageID = BN3ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/mmbn3') or WizardIsComponentSelected('client/mmbn3'));
if (assigned(LADXROMFilePage)) and (PageID = LADXROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/ladx') or WizardIsComponentSelected('client/ladx'));
if (assigned(TLoZROMFilePage)) and (PageID = TLoZROMFilePage.ID) then

View File

@@ -1,9 +1,10 @@
colorama>=0.4.5
websockets>=11.0.1
websockets>=11.0.3
PyYAML>=6.0
jellyfish>=0.11.2
jinja2>=3.1.2
schema>=0.7.5
kivy>=2.1.0
kivy>=2.2.0
bsdiff4>=1.2.3
platformdirs>=3.2.0
platformdirs>=3.5.1
certifi>=2023.5.7

View File

@@ -20,7 +20,7 @@ from pathlib import Path
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
try:
requirement = 'cx-Freeze>=6.14.7'
requirement = 'cx-Freeze==6.14.9'
import pkg_resources
try:
pkg_resources.require(requirement)
@@ -60,20 +60,34 @@ from Utils import version_tuple, is_windows, is_linux
# On Python < 3.10 LogicMixin is not currently supported.
apworlds: set = {
"Subnautica",
"Factorio",
"Rogue Legacy",
"Sonic Adventure 2 Battle",
"Donkey Kong Country 3",
"Super Mario World",
"Stardew Valley",
"Timespinner",
"Minecraft",
"The Messenger",
"Links Awakening DX",
"Super Metroid",
"SMZ3",
non_apworlds: set = {
"A Link to the Past",
"Adventure",
"ArchipIDLE",
"Archipelago",
"Blasphemous",
"ChecksFinder",
"Clique",
"DLCQuest",
"Dark Souls III",
"Final Fantasy",
"Hollow Knight",
"Hylics 2",
"Kingdom Hearts 2",
"Lufia II Ancient Cave",
"Meritous",
"Ocarina of Time",
"Overcooked! 2",
"Pokemon Red and Blue",
"Raft",
"Secret of Evermore",
"Slay the Spire",
"Starcraft 2 Wings of Liberty",
"Sudoku",
"Super Mario 64",
"VVVVVV",
"Wargroove",
"Zillion",
}
@@ -322,11 +336,12 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister
assert not apworlds - set(AutoWorldRegister.world_types), "Unknown world designated for .apworld"
assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: typing.List[str] = []
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname in apworlds:
if worldname not in non_apworlds:
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name
# this method creates an apworld that cannot be moved to a different OS or minor python version,

View File

@@ -199,6 +199,41 @@ class TestFillRestrictive(unittest.TestCase):
# Unnecessary unreachable Item
self.assertEqual(locations[1].item, items[0])
def test_minimal_mixed_fill(self):
"""
Test that fill for 1 minimal and 1 non-minimal player will correctly place items in a way that lets
the non-minimal player get all items.
"""
multi_world = generate_multi_world(2)
player1 = generate_player_data(multi_world, 1, 3, 3)
player2 = generate_player_data(multi_world, 2, 3, 3)
multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal
multi_world.accessibility[player2.id].value = multi_world.accessibility[player2.id].option_locations
multi_world.completion_condition[player1.id] = lambda state: True
multi_world.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id)
set_rule(player1.locations[1], lambda state: state.has(player1.prog_items[0].name, player1.id))
set_rule(player1.locations[2], lambda state: state.has(player1.prog_items[1].name, player1.id))
set_rule(player2.locations[1], lambda state: state.has(player2.prog_items[0].name, player2.id))
set_rule(player2.locations[2], lambda state: state.has(player2.prog_items[1].name, player2.id))
# force-place an item that makes it impossible to have all locations accessible
player1.locations[0].place_locked_item(player1.prog_items[2])
# fill remaining locations with remaining items
location_pool = player1.locations[1:] + player2.locations
item_pool = player1.prog_items[:-1] + player2.prog_items
fill_restrictive(multi_world, multi_world.state, location_pool, item_pool)
multi_world.state.sweep_for_events() # collect everything
# all of player2's locations and items should be accessible (not all of player1's)
for item in player2.prog_items:
self.assertTrue(multi_world.state.has(item.name, player2.id),
f'{item} is unreachable in {item.location}')
def test_reversed_fill(self):
multi_world = generate_multi_world()
player1 = generate_player_data(multi_world, 1, 2, 2)

View File

@@ -2,7 +2,6 @@ import unittest
from BaseClasses import CollectionState
from worlds.AutoWorld import AutoWorldRegister
from . import setup_solo_multiworld
@@ -22,8 +21,9 @@ class TestBase(unittest.TestCase):
"ZD Eyeball Frog Timeout", # trade quest starts after this item
"ZR Top of Waterfall", # dummy region used for entrance shuffle
},
# The following SM regions are only used when the corresponding StartLocation option is selected (so not with default settings).
# Also, those dont have any entrances as they serve as starting Region (that's why they have to be excluded for testAllStateCanReachEverything).
# The following SM regions are only used when the corresponding StartLocation option is selected (so not with
# default settings). Also, those don't have any entrances as they serve as starting Region (that's why they
# have to be excluded for testAllStateCanReachEverything).
"Super Metroid": {
"Ceres",
"Gauntlet Top",
@@ -31,37 +31,35 @@ class TestBase(unittest.TestCase):
}
}
def testAllStateCanReachEverything(self):
def testDefaultAllStateCanReachEverything(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
if game_name not in {"Ori and the Blind Forest"}: # TODO: fix Ori Logic
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
with self.subTest("Game", game=game_name):
world = setup_solo_multiworld(world_type)
excluded = world.exclude_locations[1].value
state = world.get_all_state(False)
for location in world.get_locations():
if location.name not in excluded:
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
with self.subTest("Game", game=game_name):
world = setup_solo_multiworld(world_type)
excluded = world.exclude_locations[1].value
state = world.get_all_state(False)
for location in world.get_locations():
if location.name not in excluded:
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
for region in world.get_regions():
if region.name not in unreachable_regions:
with self.subTest("Region should be reached", region=region):
self.assertTrue(region.can_reach(state))
for region in world.get_regions():
if region.name not in unreachable_regions:
with self.subTest("Region should be reached", region=region):
self.assertTrue(region.can_reach(state))
with self.subTest("Completion Condition"):
self.assertTrue(world.can_beat_game(state))
with self.subTest("Completion Condition"):
self.assertTrue(world.can_beat_game(state))
def testEmptyStateCanReachSomething(self):
def testDefaultEmptyStateCanReachSomething(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
if game_name not in {"Archipelago", "Sudoku"}:
with self.subTest("Game", game=game_name):
world = setup_solo_multiworld(world_type)
state = CollectionState(world)
with self.subTest("Game", game=game_name):
world = setup_solo_multiworld(world_type)
state = CollectionState(world)
all_locations = world.get_locations()
if all_locations:
locations = set()
for location in world.get_locations():
for location in all_locations:
if location.can_reach(state):
locations.add(location)
self.assertGreater(len(locations), 0,

View File

@@ -1,14 +1,42 @@
def load_tests(loader, standard_tests, pattern):
import os
import unittest
from ..TestBase import file_path
import Utils
import typing
import zipfile
import importlib
import inspect
from worlds.AutoWorld import AutoWorldRegister
suite = unittest.TestSuite()
suite.addTests(standard_tests)
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
folders = [(os.path.join(os.path.split(world.__file__)[0], "test"), world.zip_path)
for world in AutoWorldRegister.world_types.values()]
for folder in folders:
if os.path.exists(folder):
suite.addTests(loader.discover(folder, top_level_dir=file_path))
all_tests: typing.List[unittest.TestCase] = [
]
for folder, zip_path in folders:
if os.path.exists(folder) and not zip_path:
all_tests.extend(
test_case
for test_collection in loader.discover(folder, top_level_dir=Utils.local_path("."))
for test_suite in test_collection
for test_case in test_suite
)
elif zip_path and os.path.exists(zip_path):
with zipfile.ZipFile(zip_path) as zf:
for zip_info in zf.infolist():
if "__pycache__" in zip_info.filename:
continue
if "test" in zip_info.filename and zip_info.filename.endswith((".py", ".pyc", ".pyo")):
import_path = "worlds." + os.path.splitext(zip_info.filename)[0].replace("/", ".")
module = importlib.import_module(import_path)
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, unittest.TestCase):
all_tests.extend(obj(method_name) for method_name in loader.getTestCaseNames(obj))
assert all_tests, "No custom tests found, when it was expected to find them."
suite.addTests(sorted(all_tests, key=lambda test: test.__module__))
return suite

View File

@@ -1,19 +1,22 @@
import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable
from Utils import local_path, is_windows
from Utils import local_path
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component
MISC = auto()
CLIENT = auto()
ADJUSTER = auto()
FUNC = auto() # do not use anymore
HIDDEN = auto()
class Component:
display_name: str
type: Optional[Type]
type: Type
script_name: Optional[str]
frozen_name: Optional[str]
icon: str # just the name, no suffix
@@ -22,18 +25,21 @@ class Component:
file_identifier: Optional[Callable[[str], bool]]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
file_identifier: Optional[Callable[[str], bool]] = None):
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None):
self.display_name = display_name
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
self.type = component_type or \
None if not display_name else \
Type.FUNC if func else \
Type.CLIENT if 'Client' in display_name else \
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
if component_type == Type.FUNC:
from Utils import deprecate
deprecate(f"Launcher Component {self.display_name} is using Type.FUNC Type, which is pending removal.")
component_type = Type.MISC
self.type = component_type or (
Type.CLIENT if "Client" in display_name else
Type.ADJUSTER if "Adjuster" in display_name else Type.MISC)
self.func = func
self.file_identifier = file_identifier
@@ -43,6 +49,14 @@ class Component:
def __repr__(self):
return f"{self.__class__.__name__}({self.display_name})"
processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str = None):
global processes
import multiprocessing
process = multiprocessing.Process(target=func, name=name)
process.start()
processes.add(process)
class SuffixIdentifier:
suffixes: Iterable[str]
@@ -58,14 +72,19 @@ class SuffixIdentifier:
return False
def launch_textclient():
import CommonClient
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
components: List[Component] = [
# Launcher
Component('', 'Launcher'),
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
@@ -85,7 +104,7 @@ components: List[Component] = [
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# TLoZ
Component('Zelda 1 Client', 'Zelda1Client'),
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
@@ -97,6 +116,9 @@ components: List[Component] = [
file_identifier=SuffixIdentifier('.apzl')),
# Kingdom Hearts 2
Component('KH2 Client', "KH2Client"),
#MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3'))
]

View File

@@ -39,9 +39,50 @@ class DataPackage(typing.TypedDict):
class WorldSource(typing.NamedTuple):
path: str # typically relative path from this module
is_zip: bool = False
relative: bool = True # relative to regular world import folder
def __repr__(self):
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip})"
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@property
def resolved_path(self) -> str:
if self.relative:
return os.path.join(folder, self.path)
return self.path
def load(self) -> bool:
try:
if self.is_zip:
importer = zipimport.zipimporter(self.resolved_path)
if hasattr(importer, "find_spec"): # new in Python 3.10
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
mod = importlib.util.module_from_spec(spec)
else: # TODO: remove with 3.8 support
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
mod.__package__ = f"worlds.{mod.__package__}"
mod.__name__ = f"worlds.{mod.__name__}"
sys.modules[mod.__name__] = mod
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
# Found no equivalent for < 3.10
if hasattr(importer, "exec_module"):
importer.exec_module(mod)
else:
importlib.import_module(f".{self.path}", "worlds")
return True
except Exception as e:
# A single world failing can still mean enough is working for the user, log and carry on
import traceback
import io
file_like = io.StringIO()
print(f"Could not load world {self}:", file=file_like)
traceback.print_exc(file=file_like)
file_like.seek(0)
import logging
logging.exception(file_like.read())
return False
# find potential world containers, currently folders and zip-importable .apworld's
@@ -58,35 +99,7 @@ for file in os.scandir(folder):
# import all submodules to trigger AutoWorldRegister
world_sources.sort()
for world_source in world_sources:
try:
if world_source.is_zip:
importer = zipimport.zipimporter(os.path.join(folder, world_source.path))
if hasattr(importer, "find_spec"): # new in Python 3.10
spec = importer.find_spec(world_source.path.split(".", 1)[0])
mod = importlib.util.module_from_spec(spec)
else: # TODO: remove with 3.8 support
mod = importer.load_module(world_source.path.split(".", 1)[0])
mod.__package__ = f"worlds.{mod.__package__}"
mod.__name__ = f"worlds.{mod.__name__}"
sys.modules[mod.__name__] = mod
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
# Found no equivalent for < 3.10
if hasattr(importer, "exec_module"):
importer.exec_module(mod)
else:
importlib.import_module(f".{world_source.path}", "worlds")
except Exception as e:
# A single world failing can still mean enough is working for the user, log and carry on
import traceback
import io
file_like = io.StringIO()
print(f"Could not load world {world_source}:", file=file_like)
traceback.print_exc(file=file_like)
file_like.seek(0)
import logging
logging.exception(file_like.read())
world_source.load()
lookup_any_item_id_to_name = {}
lookup_any_location_id_to_name = {}

View File

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Hannes Karppila
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,6 @@
# SC2 Bot
This is client library to communicate with Starcraft 2 game
It's based on `burnysc2` python package, see https://github.com/BurnySc2/python-sc2
The base package is stripped down to clean up unneeded features and those not working outside a
melee game.

View File

@@ -0,0 +1,16 @@
from pathlib import Path
from loguru import logger
def is_submodule(path):
if path.is_file():
return path.suffix == ".py" and path.stem != "__init__"
if path.is_dir():
return (path / "__init__.py").exists()
return False
__all__ = [p.stem for p in Path(__file__).parent.iterdir() if is_submodule(p)]
logger = logger

View File

@@ -0,0 +1,476 @@
# pylint: disable=W0212,R0916,R0904
from __future__ import annotations
import math
from functools import cached_property
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union
from .bot_ai_internal import BotAIInternal
from .cache import property_cache_once_per_frame
from .data import Alert, Result
from .position import Point2
from .unit import Unit
from .units import Units
if TYPE_CHECKING:
from .game_info import Ramp
class BotAI(BotAIInternal):
"""Base class for bots."""
EXPANSION_GAP_THRESHOLD = 15
@property
def time(self) -> float:
""" Returns time in seconds, assumes the game is played on 'faster' """
return self.state.game_loop / 22.4 # / (1/1.4) * (1/16)
@property
def time_formatted(self) -> str:
""" Returns time as string in min:sec format """
t = self.time
return f"{int(t // 60):02}:{int(t % 60):02}"
@property
def step_time(self) -> Tuple[float, float, float, float]:
"""Returns a tuple of step duration in milliseconds.
First value is the minimum step duration - the shortest the bot ever took
Second value is the average step duration
Third value is the maximum step duration - the longest the bot ever took (including on_start())
Fourth value is the step duration the bot took last iteration
If called in the first iteration, it returns (inf, 0, 0, 0)"""
avg_step_duration = (
(self._total_time_in_on_step / self._total_steps_iterations) if self._total_steps_iterations else 0
)
return (
self._min_step_time * 1000,
avg_step_duration * 1000,
self._max_step_time * 1000,
self._last_step_step_time * 1000,
)
def alert(self, alert_code: Alert) -> bool:
"""
Check if alert is triggered in the current step.
Possible alerts are listed here https://github.com/Blizzard/s2client-proto/blob/e38efed74c03bec90f74b330ea1adda9215e655f/s2clientprotocol/sc2api.proto#L679-L702
Example use::
from sc2.data import Alert
if self.alert(Alert.AddOnComplete):
print("Addon Complete")
Alert codes::
AlertError
AddOnComplete
BuildingComplete
BuildingUnderAttack
LarvaHatched
MergeComplete
MineralsExhausted
MorphComplete
MothershipComplete
MULEExpired
NuclearLaunchDetected
NukeComplete
NydusWormDetected
ResearchComplete
TrainError
TrainUnitComplete
TrainWorkerComplete
TransformationComplete
UnitUnderAttack
UpgradeComplete
VespeneExhausted
WarpInComplete
:param alert_code:
"""
assert isinstance(alert_code, Alert), f"alert_code {alert_code} is no Alert"
return alert_code.value in self.state.alerts
@property
def start_location(self) -> Point2:
"""
Returns the spawn location of the bot, using the position of the first created townhall.
This will be None if the bot is run on an arcade or custom map that does not feature townhalls at game start.
"""
return self.game_info.player_start_location
@property
def enemy_start_locations(self) -> List[Point2]:
"""Possible start locations for enemies."""
return self.game_info.start_locations
@cached_property
def main_base_ramp(self) -> Ramp:
"""Returns the Ramp instance of the closest main-ramp to start location.
Look in game_info.py for more information about the Ramp class
Example: See terran ramp wall bot
"""
# The reason for len(ramp.upper) in {2, 5} is:
# ParaSite map has 5 upper points, and most other maps have 2 upper points at the main ramp.
# The map Acolyte has 4 upper points at the wrong ramp (which is closest to the start position).
try:
found_main_base_ramp = min(
(ramp for ramp in self.game_info.map_ramps if len(ramp.upper) in {2, 5}),
key=lambda r: self.start_location.distance_to(r.top_center),
)
except ValueError:
# Hardcoded hotfix for Honorgrounds LE map, as that map has a large main base ramp with inbase natural
found_main_base_ramp = min(
(ramp for ramp in self.game_info.map_ramps if len(ramp.upper) in {4, 9}),
key=lambda r: self.start_location.distance_to(r.top_center),
)
return found_main_base_ramp
@property_cache_once_per_frame
def expansion_locations_list(self) -> List[Point2]:
""" Returns a list of expansion positions, not sorted in any way. """
assert (
self._expansion_positions_list
), "self._find_expansion_locations() has not been run yet, so accessing the list of expansion locations is pointless."
return self._expansion_positions_list
@property_cache_once_per_frame
def expansion_locations_dict(self) -> Dict[Point2, Units]:
"""
Returns dict with the correct expansion position Point2 object as key,
resources as Units (mineral fields and vespene geysers) as value.
Caution: This function is slow. If you only need the expansion locations, use the property above.
"""
assert (
self._expansion_positions_list
), "self._find_expansion_locations() has not been run yet, so accessing the list of expansion locations is pointless."
expansion_locations: Dict[Point2, Units] = {pos: Units([], self) for pos in self._expansion_positions_list}
for resource in self.resources:
# It may be that some resources are not mapped to an expansion location
exp_position: Point2 = self._resource_location_to_expansion_position_dict.get(resource.position, None)
if exp_position:
assert exp_position in expansion_locations
expansion_locations[exp_position].append(resource)
return expansion_locations
async def get_next_expansion(self) -> Optional[Point2]:
"""Find next expansion location."""
closest = None
distance = math.inf
for el in self.expansion_locations_list:
def is_near_to_expansion(t):
return t.distance_to(el) < self.EXPANSION_GAP_THRESHOLD
if any(map(is_near_to_expansion, self.townhalls)):
# already taken
continue
startp = self.game_info.player_start_location
d = await self.client.query_pathing(startp, el)
if d is None:
continue
if d < distance:
distance = d
closest = el
return closest
# pylint: disable=R0912
async def distribute_workers(self, resource_ratio: float = 2):
"""
Distributes workers across all the bases taken.
Keyword `resource_ratio` takes a float. If the current minerals to gas
ratio is bigger than `resource_ratio`, this function prefer filling gas_buildings
first, if it is lower, it will prefer sending workers to minerals first.
NOTE: This function is far from optimal, if you really want to have
refined worker control, you should write your own distribution function.
For example long distance mining control and moving workers if a base was killed
are not being handled.
WARNING: This is quite slow when there are lots of workers or multiple bases.
:param resource_ratio:"""
if not self.mineral_field or not self.workers or not self.townhalls.ready:
return
worker_pool = self.workers.idle
bases = self.townhalls.ready
gas_buildings = self.gas_buildings.ready
# list of places that need more workers
deficit_mining_places = []
for mining_place in bases | gas_buildings:
difference = mining_place.surplus_harvesters
# perfect amount of workers, skip mining place
if not difference:
continue
if mining_place.has_vespene:
# get all workers that target the gas extraction site
# or are on their way back from it
local_workers = self.workers.filter(
lambda unit: unit.order_target == mining_place.tag or
(unit.is_carrying_vespene and unit.order_target == bases.closest_to(mining_place).tag)
)
else:
# get tags of minerals around expansion
local_minerals_tags = {
mineral.tag
for mineral in self.mineral_field if mineral.distance_to(mining_place) <= 8
}
# get all target tags a worker can have
# tags of the minerals he could mine at that base
# get workers that work at that gather site
local_workers = self.workers.filter(
lambda unit: unit.order_target in local_minerals_tags or
(unit.is_carrying_minerals and unit.order_target == mining_place.tag)
)
# too many workers
if difference > 0:
for worker in local_workers[:difference]:
worker_pool.append(worker)
# too few workers
# add mining place to deficit bases for every missing worker
else:
deficit_mining_places += [mining_place for _ in range(-difference)]
# prepare all minerals near a base if we have too many workers
# and need to send them to the closest patch
if len(worker_pool) > len(deficit_mining_places):
all_minerals_near_base = [
mineral for mineral in self.mineral_field
if any(mineral.distance_to(base) <= 8 for base in self.townhalls.ready)
]
# distribute every worker in the pool
for worker in worker_pool:
# as long as have workers and mining places
if deficit_mining_places:
# choose only mineral fields first if current mineral to gas ratio is less than target ratio
if self.vespene and self.minerals / self.vespene < resource_ratio:
possible_mining_places = [place for place in deficit_mining_places if not place.vespene_contents]
# else prefer gas
else:
possible_mining_places = [place for place in deficit_mining_places if place.vespene_contents]
# if preferred type is not available any more, get all other places
if not possible_mining_places:
possible_mining_places = deficit_mining_places
# find closest mining place
current_place = min(deficit_mining_places, key=lambda place: place.distance_to(worker))
# remove it from the list
deficit_mining_places.remove(current_place)
# if current place is a gas extraction site, go there
if current_place.vespene_contents:
worker.gather(current_place)
# if current place is a gas extraction site,
# go to the mineral field that is near and has the most minerals left
else:
local_minerals = (
mineral for mineral in self.mineral_field if mineral.distance_to(current_place) <= 8
)
# local_minerals can be empty if townhall is misplaced
target_mineral = max(local_minerals, key=lambda mineral: mineral.mineral_contents, default=None)
if target_mineral:
worker.gather(target_mineral)
# more workers to distribute than free mining spots
# send to closest if worker is doing nothing
elif worker.is_idle and all_minerals_near_base:
target_mineral = min(all_minerals_near_base, key=lambda mineral: mineral.distance_to(worker))
worker.gather(target_mineral)
else:
# there are no deficit mining places and worker is not idle
# so dont move him
pass
@property_cache_once_per_frame
def owned_expansions(self) -> Dict[Point2, Unit]:
"""Dict of expansions owned by the player with mapping {expansion_location: townhall_structure}."""
owned = {}
for el in self.expansion_locations_list:
def is_near_to_expansion(t):
return t.distance_to(el) < self.EXPANSION_GAP_THRESHOLD
th = next((x for x in self.townhalls if is_near_to_expansion(x)), None)
if th:
owned[el] = th
return owned
async def chat_send(self, message: str, team_only: bool = False):
"""Send a chat message to the SC2 Client.
Example::
await self.chat_send("Hello, this is a message from my bot!")
:param message:
:param team_only:"""
assert isinstance(message, str), f"{message} is not a string"
await self.client.chat_send(message, team_only)
def in_map_bounds(self, pos: Union[Point2, tuple, list]) -> bool:
"""Tests if a 2 dimensional point is within the map boundaries of the pixelmaps.
:param pos:"""
return (
self.game_info.playable_area.x <= pos[0] <
self.game_info.playable_area.x + self.game_info.playable_area.width and self.game_info.playable_area.y <=
pos[1] < self.game_info.playable_area.y + self.game_info.playable_area.height
)
# For the functions below, make sure you are inside the boundaries of the map size.
def get_terrain_height(self, pos: Union[Point2, Unit]) -> int:
"""Returns terrain height at a position.
Caution: terrain height is different from a unit's z-coordinate.
:param pos:"""
assert isinstance(pos, (Point2, Unit)), "pos is not of type Point2 or Unit"
pos = pos.position.rounded
return self.game_info.terrain_height[pos]
def get_terrain_z_height(self, pos: Union[Point2, Unit]) -> float:
"""Returns terrain z-height at a position.
:param pos:"""
assert isinstance(pos, (Point2, Unit)), "pos is not of type Point2 or Unit"
pos = pos.position.rounded
return -16 + 32 * self.game_info.terrain_height[pos] / 255
def in_placement_grid(self, pos: Union[Point2, Unit]) -> bool:
"""Returns True if you can place something at a position.
Remember, buildings usually use 2x2, 3x3 or 5x5 of these grid points.
Caution: some x and y offset might be required, see ramp code in game_info.py
:param pos:"""
assert isinstance(pos, (Point2, Unit)), "pos is not of type Point2 or Unit"
pos = pos.position.rounded
return self.game_info.placement_grid[pos] == 1
def in_pathing_grid(self, pos: Union[Point2, Unit]) -> bool:
"""Returns True if a ground unit can pass through a grid point.
:param pos:"""
assert isinstance(pos, (Point2, Unit)), "pos is not of type Point2 or Unit"
pos = pos.position.rounded
return self.game_info.pathing_grid[pos] == 1
def is_visible(self, pos: Union[Point2, Unit]) -> bool:
"""Returns True if you have vision on a grid point.
:param pos:"""
# more info: https://github.com/Blizzard/s2client-proto/blob/9906df71d6909511907d8419b33acc1a3bd51ec0/s2clientprotocol/spatial.proto#L19
assert isinstance(pos, (Point2, Unit)), "pos is not of type Point2 or Unit"
pos = pos.position.rounded
return self.state.visibility[pos] == 2
def has_creep(self, pos: Union[Point2, Unit]) -> bool:
"""Returns True if there is creep on the grid point.
:param pos:"""
assert isinstance(pos, (Point2, Unit)), "pos is not of type Point2 or Unit"
pos = pos.position.rounded
return self.state.creep[pos] == 1
async def on_unit_destroyed(self, unit_tag: int):
"""
Override this in your bot class.
Note that this function uses unit tags and not the unit objects
because the unit does not exist any more.
This will event will be called when a unit (or structure, friendly or enemy) dies.
For enemy units, this only works if the enemy unit was in vision on death.
:param unit_tag:
"""
async def on_unit_created(self, unit: Unit):
"""Override this in your bot class. This function is called when a unit is created.
:param unit:"""
async def on_building_construction_started(self, unit: Unit):
"""
Override this in your bot class.
This function is called when a building construction has started.
:param unit:
"""
async def on_building_construction_complete(self, unit: Unit):
"""
Override this in your bot class. This function is called when a building
construction is completed.
:param unit:
"""
async def on_unit_took_damage(self, unit: Unit, amount_damage_taken: float):
"""
Override this in your bot class. This function is called when your own unit (unit or structure) took damage.
It will not be called if the unit died this frame.
This may be called frequently for terran structures that are burning down, or zerg buildings that are off creep,
or terran bio units that just used stimpack ability.
TODO: If there is a demand for it, then I can add a similar event for when enemy units took damage
Examples::
print(f"My unit took damage: {unit} took {amount_damage_taken} damage")
:param unit:
:param amount_damage_taken:
"""
async def on_enemy_unit_entered_vision(self, unit: Unit):
"""
Override this in your bot class. This function is called when an enemy unit (unit or structure) entered vision (which was not visible last frame).
:param unit:
"""
async def on_enemy_unit_left_vision(self, unit_tag: int):
"""
Override this in your bot class. This function is called when an enemy unit (unit or structure) left vision (which was visible last frame).
Same as the self.on_unit_destroyed event, this function is called with the unit's tag because the unit is no longer visible anymore.
If you want to store a snapshot of the unit, use self._enemy_units_previous_map[unit_tag] for units or self._enemy_structures_previous_map[unit_tag] for structures.
Examples::
last_known_unit = self._enemy_units_previous_map.get(unit_tag, None) or self._enemy_structures_previous_map[unit_tag]
print(f"Enemy unit left vision, last known location: {last_known_unit.position}")
:param unit_tag:
"""
async def on_before_start(self):
"""
Override this in your bot class. This function is called before "on_start"
and before "prepare_first_step" that calculates expansion locations.
Not all data is available yet.
This function is useful in realtime=True mode to split your workers or start producing the first worker.
"""
async def on_start(self):
"""
Override this in your bot class.
At this point, game_data, game_info and the first iteration of game_state (self.state) are available.
"""
async def on_step(self, iteration: int):
"""
You need to implement this function!
Override this in your bot class.
This function is called on every game step (looped in realtime mode).
:param iteration:
"""
raise NotImplementedError
async def on_end(self, game_result: Result):
"""Override this in your bot class. This function is called at the end of a game.
Unsure if this function will be called on the laddermanager client as the bot process may forcefully be terminated.
:param game_result:"""

View File

@@ -0,0 +1,490 @@
# pylint: disable=W0201,W0212,R0912
from __future__ import annotations
import math
import time
import warnings
from abc import ABC
from collections import Counter
from typing import TYPE_CHECKING, Any
from typing import Dict, Generator, Iterable, List, Set, Tuple, Union, final
from s2clientprotocol import sc2api_pb2 as sc_pb
from .constants import (
IS_PLACEHOLDER,
)
from .data import Race
from .game_data import GameData
from .game_state import Blip, GameState
from .pixel_map import PixelMap
from .position import Point2
from .unit import Unit
from .units import Units
# with warnings.catch_warnings():
# warnings.simplefilter("ignore")
# from scipy.spatial.distance import cdist, pdist
if TYPE_CHECKING:
from .client import Client
from .game_info import GameInfo
class BotAIInternal(ABC):
"""Base class for bots."""
@final
def _initialize_variables(self):
""" Called from main.py internally """
self.cache: Dict[str, Any] = {}
# Specific opponent bot ID used in sc2ai ladder games http://sc2ai.net/ and on ai arena https://aiarena.net
# The bot ID will stay the same each game so your bot can "adapt" to the opponent
if not hasattr(self, "opponent_id"):
# Prevent overwriting the opponent_id which is set here https://github.com/Hannessa/python-sc2-ladderbot/blob/master/__init__.py#L40
# otherwise set it to None
self.opponent_id: str = None
# Select distance calculation method, see _distances_override_functions function
if not hasattr(self, "distance_calculation_method"):
self.distance_calculation_method: int = 2
# Select if the Unit.command should return UnitCommand objects. Set this to True if your bot uses 'self.do(unit(ability, target))'
if not hasattr(self, "unit_command_uses_self_do"):
self.unit_command_uses_self_do: bool = False
# This value will be set to True by main.py in self._prepare_start if game is played in realtime (if true, the bot will have limited time per step)
self.realtime: bool = False
self.base_build: int = -1
self.all_units: Units = Units([], self)
self.units: Units = Units([], self)
self.workers: Units = Units([], self)
self.larva: Units = Units([], self)
self.structures: Units = Units([], self)
self.townhalls: Units = Units([], self)
self.gas_buildings: Units = Units([], self)
self.all_own_units: Units = Units([], self)
self.enemy_units: Units = Units([], self)
self.enemy_structures: Units = Units([], self)
self.all_enemy_units: Units = Units([], self)
self.resources: Units = Units([], self)
self.destructables: Units = Units([], self)
self.watchtowers: Units = Units([], self)
self.mineral_field: Units = Units([], self)
self.vespene_geyser: Units = Units([], self)
self.placeholders: Units = Units([], self)
self.techlab_tags: Set[int] = set()
self.reactor_tags: Set[int] = set()
self.minerals: int = 50
self.vespene: int = 0
self.supply_army: float = 0
self.supply_workers: float = 12 # Doesn't include workers in production
self.supply_cap: float = 15
self.supply_used: float = 12
self.supply_left: float = 3
self.idle_worker_count: int = 0
self.army_count: int = 0
self.warp_gate_count: int = 0
self.blips: Set[Blip] = set()
self.race: Race = None
self.enemy_race: Race = None
self._generated_frame = -100
self._units_created: Counter = Counter()
self._unit_tags_seen_this_game: Set[int] = set()
self._units_previous_map: Dict[int, Unit] = {}
self._structures_previous_map: Dict[int, Unit] = {}
self._enemy_units_previous_map: Dict[int, Unit] = {}
self._enemy_structures_previous_map: Dict[int, Unit] = {}
self._all_units_previous_map: Dict[int, Unit] = {}
self._expansion_positions_list: List[Point2] = []
self._resource_location_to_expansion_position_dict: Dict[Point2, Point2] = {}
self._time_before_step: float = None
self._time_after_step: float = None
self._min_step_time: float = math.inf
self._max_step_time: float = 0
self._last_step_step_time: float = 0
self._total_time_in_on_step: float = 0
self._total_steps_iterations: int = 0
# Internally used to keep track which units received an action in this frame, so that self.train() function does not give the same larva two orders - cleared every frame
self.unit_tags_received_action: Set[int] = set()
@final
@property
def _game_info(self) -> GameInfo:
""" See game_info.py """
warnings.warn(
"Using self._game_info is deprecated and may be removed soon. Please use self.game_info directly.",
DeprecationWarning,
stacklevel=2,
)
return self.game_info
@final
@property
def _game_data(self) -> GameData:
""" See game_data.py """
warnings.warn(
"Using self._game_data is deprecated and may be removed soon. Please use self.game_data directly.",
DeprecationWarning,
stacklevel=2,
)
return self.game_data
@final
@property
def _client(self) -> Client:
""" See client.py """
warnings.warn(
"Using self._client is deprecated and may be removed soon. Please use self.client directly.",
DeprecationWarning,
stacklevel=2,
)
return self.client
@final
def _prepare_start(self, client, player_id, game_info, game_data, realtime: bool = False, base_build: int = -1):
"""
Ran until game start to set game and player data.
:param client:
:param player_id:
:param game_info:
:param game_data:
:param realtime:
"""
self.client: Client = client
self.player_id: int = player_id
self.game_info: GameInfo = game_info
self.game_data: GameData = game_data
self.realtime: bool = realtime
self.base_build: int = base_build
self.race: Race = Race(self.game_info.player_races[self.player_id])
if len(self.game_info.player_races) == 2:
self.enemy_race: Race = Race(self.game_info.player_races[3 - self.player_id])
@final
def _prepare_first_step(self):
"""First step extra preparations. Must not be called before _prepare_step."""
if self.townhalls:
self.game_info.player_start_location = self.townhalls.first.position
# Calculate and cache expansion locations forever inside 'self._cache_expansion_locations', this is done to prevent a bug when this is run and cached later in the game
self._time_before_step: float = time.perf_counter()
@final
def _prepare_step(self, state, proto_game_info):
"""
:param state:
:param proto_game_info:
"""
# Set attributes from new state before on_step."""
self.state: GameState = state # See game_state.py
# update pathing grid, which unfortunately is in GameInfo instead of GameState
self.game_info.pathing_grid = PixelMap(proto_game_info.game_info.start_raw.pathing_grid, in_bits=True)
# Required for events, needs to be before self.units are initialized so the old units are stored
self._units_previous_map: Dict[int, Unit] = {unit.tag: unit for unit in self.units}
self._structures_previous_map: Dict[int, Unit] = {structure.tag: structure for structure in self.structures}
self._enemy_units_previous_map: Dict[int, Unit] = {unit.tag: unit for unit in self.enemy_units}
self._enemy_structures_previous_map: Dict[int, Unit] = {
structure.tag: structure
for structure in self.enemy_structures
}
self._all_units_previous_map: Dict[int, Unit] = {unit.tag: unit for unit in self.all_units}
self._prepare_units()
self.minerals: int = state.common.minerals
self.vespene: int = state.common.vespene
self.supply_army: int = state.common.food_army
self.supply_workers: int = state.common.food_workers # Doesn't include workers in production
self.supply_cap: int = state.common.food_cap
self.supply_used: int = state.common.food_used
self.supply_left: int = self.supply_cap - self.supply_used
if self.race == Race.Zerg:
# Workaround Zerg supply rounding bug
pass
# self._correct_zerg_supply()
elif self.race == Race.Protoss:
self.warp_gate_count: int = state.common.warp_gate_count
self.idle_worker_count: int = state.common.idle_worker_count
self.army_count: int = state.common.army_count
self._time_before_step: float = time.perf_counter()
if self.enemy_race == Race.Random and self.all_enemy_units:
self.enemy_race = Race(self.all_enemy_units.first.race)
@final
def _prepare_units(self):
# Set of enemy units detected by own sensor tower, as blips have less unit information than normal visible units
self.blips: Set[Blip] = set()
self.all_units: Units = Units([], self)
self.units: Units = Units([], self)
self.workers: Units = Units([], self)
self.larva: Units = Units([], self)
self.structures: Units = Units([], self)
self.townhalls: Units = Units([], self)
self.gas_buildings: Units = Units([], self)
self.all_own_units: Units = Units([], self)
self.enemy_units: Units = Units([], self)
self.enemy_structures: Units = Units([], self)
self.all_enemy_units: Units = Units([], self)
self.resources: Units = Units([], self)
self.destructables: Units = Units([], self)
self.watchtowers: Units = Units([], self)
self.mineral_field: Units = Units([], self)
self.vespene_geyser: Units = Units([], self)
self.placeholders: Units = Units([], self)
self.techlab_tags: Set[int] = set()
self.reactor_tags: Set[int] = set()
index: int = 0
for unit in self.state.observation_raw.units:
if unit.is_blip:
self.blips.add(Blip(unit))
else:
unit_type: int = unit.unit_type
# Convert these units to effects: reaper grenade, parasitic bomb dummy, forcefield
unit_obj = Unit(unit, self, distance_calculation_index=index, base_build=self.base_build)
index += 1
self.all_units.append(unit_obj)
if unit.display_type == IS_PLACEHOLDER:
self.placeholders.append(unit_obj)
continue
alliance = unit.alliance
# Alliance.Neutral.value = 3
if alliance == 3:
# XELNAGATOWER = 149
if unit_type == 149:
self.watchtowers.append(unit_obj)
# all destructable rocks
else:
self.destructables.append(unit_obj)
# Alliance.Self.value = 1
elif alliance == 1:
self.all_own_units.append(unit_obj)
if unit_obj.is_structure:
self.structures.append(unit_obj)
# Alliance.Enemy.value = 4
elif alliance == 4:
self.all_enemy_units.append(unit_obj)
if unit_obj.is_structure:
self.enemy_structures.append(unit_obj)
else:
self.enemy_units.append(unit_obj)
@final
async def _after_step(self) -> int:
""" Executed by main.py after each on_step function. """
# Keep track of the bot on_step duration
self._time_after_step: float = time.perf_counter()
step_duration = self._time_after_step - self._time_before_step
self._min_step_time = min(step_duration, self._min_step_time)
self._max_step_time = max(step_duration, self._max_step_time)
self._last_step_step_time = step_duration
self._total_time_in_on_step += step_duration
self._total_steps_iterations += 1
# Clear set of unit tags that were given an order this frame by self.do()
self.unit_tags_received_action.clear()
# Commit debug queries
await self.client._send_debug()
return self.state.game_loop
@final
async def _advance_steps(self, steps: int):
"""Advances the game loop by amount of 'steps'. This function is meant to be used as a debugging and testing tool only.
If you are using this, please be aware of the consequences, e.g. 'self.units' will be filled with completely new data."""
await self._after_step()
# Advance simulation by exactly "steps" frames
await self.client.step(steps)
state = await self.client.observation()
gs = GameState(state.observation)
proto_game_info = await self.client._execute(game_info=sc_pb.RequestGameInfo())
self._prepare_step(gs, proto_game_info)
await self.issue_events()
@final
async def issue_events(self):
"""This function will be automatically run from main.py and triggers the following functions:
- on_unit_created
- on_unit_destroyed
- on_building_construction_started
- on_building_construction_complete
- on_upgrade_complete
"""
await self._issue_unit_dead_events()
await self._issue_unit_added_events()
await self._issue_building_events()
await self._issue_upgrade_events()
await self._issue_vision_events()
@final
async def _issue_unit_added_events(self):
pass
# for unit in self.units:
# if unit.tag not in self._units_previous_map and unit.tag not in self._unit_tags_seen_this_game:
# self._unit_tags_seen_this_game.add(unit.tag)
# self._units_created[unit.type_id] += 1
# await self.on_unit_created(unit)
# elif unit.tag in self._units_previous_map:
# previous_frame_unit: Unit = self._units_previous_map[unit.tag]
# # Check if a unit took damage this frame and then trigger event
# if unit.health < previous_frame_unit.health or unit.shield < previous_frame_unit.shield:
# damage_amount = previous_frame_unit.health - unit.health + previous_frame_unit.shield - unit.shield
# await self.on_unit_took_damage(unit, damage_amount)
# # Check if a unit type has changed
# if previous_frame_unit.type_id != unit.type_id:
# await self.on_unit_type_changed(unit, previous_frame_unit.type_id)
@final
async def _issue_upgrade_events(self):
pass
# difference = self.state.upgrades - self._previous_upgrades
# for upgrade_completed in difference:
# await self.on_upgrade_complete(upgrade_completed)
# self._previous_upgrades = self.state.upgrades
@final
async def _issue_building_events(self):
pass
# for structure in self.structures:
# if structure.tag not in self._structures_previous_map:
# if structure.build_progress < 1:
# await self.on_building_construction_started(structure)
# else:
# # Include starting townhall
# self._units_created[structure.type_id] += 1
# await self.on_building_construction_complete(structure)
# elif structure.tag in self._structures_previous_map:
# # Check if a structure took damage this frame and then trigger event
# previous_frame_structure: Unit = self._structures_previous_map[structure.tag]
# if (
# structure.health < previous_frame_structure.health
# or structure.shield < previous_frame_structure.shield
# ):
# damage_amount = (
# previous_frame_structure.health - structure.health + previous_frame_structure.shield -
# structure.shield
# )
# await self.on_unit_took_damage(structure, damage_amount)
# # Check if a structure changed its type
# if previous_frame_structure.type_id != structure.type_id:
# await self.on_unit_type_changed(structure, previous_frame_structure.type_id)
# # Check if structure completed
# if structure.build_progress == 1 and previous_frame_structure.build_progress < 1:
# self._units_created[structure.type_id] += 1
# await self.on_building_construction_complete(structure)
@final
async def _issue_vision_events(self):
pass
# # Call events for enemy unit entered vision
# for enemy_unit in self.enemy_units:
# if enemy_unit.tag not in self._enemy_units_previous_map:
# await self.on_enemy_unit_entered_vision(enemy_unit)
# for enemy_structure in self.enemy_structures:
# if enemy_structure.tag not in self._enemy_structures_previous_map:
# await self.on_enemy_unit_entered_vision(enemy_structure)
# # Call events for enemy unit left vision
# enemy_units_left_vision: Set[int] = set(self._enemy_units_previous_map) - self.enemy_units.tags
# for enemy_unit_tag in enemy_units_left_vision:
# await self.on_enemy_unit_left_vision(enemy_unit_tag)
# enemy_structures_left_vision: Set[int] = (set(self._enemy_structures_previous_map) - self.enemy_structures.tags)
# for enemy_structure_tag in enemy_structures_left_vision:
# await self.on_enemy_unit_left_vision(enemy_structure_tag)
@final
async def _issue_unit_dead_events(self):
pass
# for unit_tag in self.state.dead_units & set(self._all_units_previous_map):
# await self.on_unit_destroyed(unit_tag)
# DISTANCE CALCULATION
@final
@property
def _units_count(self) -> int:
return len(self.all_units)
# Helper functions
@final
def square_to_condensed(self, i, j) -> int:
# Converts indices of a square matrix to condensed matrix
# https://stackoverflow.com/a/36867493/10882657
assert i != j, "No diagonal elements in condensed matrix! Diagonal elements are zero"
if i < j:
i, j = j, i
return self._units_count * j - j * (j + 1) // 2 + i - 1 - j
# Fast and simple calculation functions
@final
@staticmethod
def distance_math_hypot(
p1: Union[Tuple[float, float], Point2],
p2: Union[Tuple[float, float], Point2],
) -> float:
return math.hypot(p1[0] - p2[0], p1[1] - p2[1])
@final
@staticmethod
def distance_math_hypot_squared(
p1: Union[Tuple[float, float], Point2],
p2: Union[Tuple[float, float], Point2],
) -> float:
return pow(p1[0] - p2[0], 2) + pow(p1[1] - p2[1], 2)
@final
def _distance_squared_unit_to_unit_method0(self, unit1: Unit, unit2: Unit) -> float:
return self.distance_math_hypot_squared(unit1.position_tuple, unit2.position_tuple)
# Distance calculation using the pre-calculated matrix above
@final
def _distance_squared_unit_to_unit_method1(self, unit1: Unit, unit2: Unit) -> float:
# If checked on units if they have the same tag, return distance 0 as these are not in the 1 dimensional pdist array - would result in an error otherwise
if unit1.tag == unit2.tag:
return 0
# Calculate index, needs to be after pdist has been calculated and cached
condensed_index = self.square_to_condensed(unit1.distance_calculation_index, unit2.distance_calculation_index)
assert condensed_index < len(
self._cached_pdist
), f"Condensed index is larger than amount of calculated distances: {condensed_index} < {len(self._cached_pdist)}, units that caused the assert error: {unit1} and {unit2}"
distance = self._pdist[condensed_index]
return distance
@final
def _distance_squared_unit_to_unit_method2(self, unit1: Unit, unit2: Unit) -> float:
# Calculate index, needs to be after cdist has been calculated and cached
return self._cdist[unit1.distance_calculation_index, unit2.distance_calculation_index]
# Distance calculation using the fastest distance calculation functions
@final
def _distance_pos_to_pos(
self,
pos1: Union[Tuple[float, float], Point2],
pos2: Union[Tuple[float, float], Point2],
) -> float:
return self.distance_math_hypot(pos1, pos2)
@final
def _distance_units_to_pos(
self,
units: Units,
pos: Union[Tuple[float, float], Point2],
) -> Generator[float, None, None]:
""" This function does not scale well, if len(units) > 100 it gets fairly slow """
return (self.distance_math_hypot(u.position_tuple, pos) for u in units)
@final
def _distance_unit_to_points(
self,
unit: Unit,
points: Iterable[Tuple[float, float]],
) -> Generator[float, None, None]:
""" This function does not scale well, if len(points) > 100 it gets fairly slow """
pos = unit.position_tuple
return (self.distance_math_hypot(p, pos) for p in points)

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, Hashable, TypeVar
if TYPE_CHECKING:
from .bot_ai import BotAI
T = TypeVar("T")
class CacheDict(dict):
def retrieve_and_set(self, key: Hashable, func: Callable[[], T]) -> T:
""" Either return the value at a certain key,
or set the return value of a function to that key, then return that value. """
if key not in self:
self[key] = func()
return self[key]
class property_cache_once_per_frame(property):
"""This decorator caches the return value for one game loop,
then clears it if it is accessed in a different game loop.
Only works on properties of the bot object, because it requires
access to self.state.game_loop
This decorator compared to the above runs a little faster, however you should only use this decorator if you are sure that you do not modify the mutable once it is calculated and cached.
Copied and modified from https://tedboy.github.io/flask/_modules/werkzeug/utils.html#cached_property
# """
def __init__(self, func: Callable[[BotAI], T], name=None):
# pylint: disable=W0231
self.__name__ = name or func.__name__
self.__frame__ = f"__frame__{self.__name__}"
self.func = func
def __set__(self, obj: BotAI, value: T):
obj.cache[self.__name__] = value
obj.cache[self.__frame__] = obj.state.game_loop
def __get__(self, obj: BotAI, _type=None) -> T:
value = obj.cache.get(self.__name__, None)
bot_frame = obj.state.game_loop
if value is None or obj.cache[self.__frame__] < bot_frame:
value = self.func(obj)
obj.cache[self.__name__] = value
obj.cache[self.__frame__] = bot_frame
return value

View File

@@ -0,0 +1,720 @@
from __future__ import annotations
from typing import Dict, Iterable, List, Optional, Set, Tuple, Union
from worlds._sc2common.bot import logger
from s2clientprotocol import debug_pb2 as debug_pb
from s2clientprotocol import query_pb2 as query_pb
from s2clientprotocol import raw_pb2 as raw_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from s2clientprotocol import spatial_pb2 as spatial_pb
from .data import ActionResult, ChatChannel, Race, Result, Status
from .game_data import AbilityData, GameData
from .game_info import GameInfo
from .position import Point2, Point3
from .protocol import ConnectionAlreadyClosed, Protocol, ProtocolError
from .renderer import Renderer
from .unit import Unit
from .units import Units
# pylint: disable=R0904
class Client(Protocol):
def __init__(self, ws, save_replay_path: str = None):
"""
:param ws:
"""
super().__init__(ws)
# How many frames will be waited between iterations before the next one is called
self.game_step: int = 4
self.save_replay_path: Optional[str] = save_replay_path
self._player_id = None
self._game_result = None
# Store a hash value of all the debug requests to prevent sending the same ones again if they haven't changed last frame
self._debug_hash_tuple_last_iteration: Tuple[int, int, int, int] = (0, 0, 0, 0)
self._debug_draw_last_frame = False
self._debug_texts = []
self._debug_lines = []
self._debug_boxes = []
self._debug_spheres = []
self._renderer = None
self.raw_affects_selection = False
@property
def in_game(self) -> bool:
return self._status in {Status.in_game, Status.in_replay}
async def join_game(self, name=None, race=None, observed_player_id=None, portconfig=None, rgb_render_config=None):
ifopts = sc_pb.InterfaceOptions(
raw=True,
score=True,
show_cloaked=True,
show_burrowed_shadows=True,
raw_affects_selection=self.raw_affects_selection,
raw_crop_to_playable_area=False,
show_placeholders=True,
)
if rgb_render_config:
assert isinstance(rgb_render_config, dict)
assert "window_size" in rgb_render_config and "minimap_size" in rgb_render_config
window_size = rgb_render_config["window_size"]
minimap_size = rgb_render_config["minimap_size"]
self._renderer = Renderer(self, window_size, minimap_size)
map_width, map_height = window_size
minimap_width, minimap_height = minimap_size
ifopts.render.resolution.x = map_width
ifopts.render.resolution.y = map_height
ifopts.render.minimap_resolution.x = minimap_width
ifopts.render.minimap_resolution.y = minimap_height
if race is None:
assert isinstance(observed_player_id, int), f"observed_player_id is of type {type(observed_player_id)}"
# join as observer
req = sc_pb.RequestJoinGame(observed_player_id=observed_player_id, options=ifopts)
else:
assert isinstance(race, Race)
req = sc_pb.RequestJoinGame(race=race.value, options=ifopts)
if portconfig:
req.server_ports.game_port = portconfig.server[0]
req.server_ports.base_port = portconfig.server[1]
for ppc in portconfig.players:
p = req.client_ports.add()
p.game_port = ppc[0]
p.base_port = ppc[1]
if name is not None:
assert isinstance(name, str), f"name is of type {type(name)}"
req.player_name = name
result = await self._execute(join_game=req)
self._game_result = None
self._player_id = result.join_game.player_id
return result.join_game.player_id
async def leave(self):
""" You can use 'await self.client.leave()' to surrender midst game. """
is_resign = self._game_result is None
if is_resign:
# For all clients that can leave, result of leaving the game either
# loss, or the client will ignore the result
self._game_result = {self._player_id: Result.Defeat}
try:
if self.save_replay_path is not None:
await self.save_replay(self.save_replay_path)
self.save_replay_path = None
await self._execute(leave_game=sc_pb.RequestLeaveGame())
except (ProtocolError, ConnectionAlreadyClosed):
if is_resign:
raise
async def save_replay(self, path):
logger.debug("Requesting replay from server")
result = await self._execute(save_replay=sc_pb.RequestSaveReplay())
with open(path, "wb") as f:
f.write(result.save_replay.data)
logger.info(f"Saved replay to {path}")
async def observation(self, game_loop: int = None):
if game_loop is not None:
result = await self._execute(observation=sc_pb.RequestObservation(game_loop=game_loop))
else:
result = await self._execute(observation=sc_pb.RequestObservation())
assert result.HasField("observation")
if not self.in_game or result.observation.player_result:
# Sometimes game ends one step before results are available
if not result.observation.player_result:
result = await self._execute(observation=sc_pb.RequestObservation())
assert result.observation.player_result
player_id_to_result = {}
for pr in result.observation.player_result:
player_id_to_result[pr.player_id] = Result(pr.result)
self._game_result = player_id_to_result
self._game_result = None
# if render_data is available, then RGB rendering was requested
if self._renderer and result.observation.observation.HasField("render_data"):
await self._renderer.render(result.observation)
return result
async def step(self, step_size: int = None):
""" EXPERIMENTAL: Change self._client.game_step during the step function to increase or decrease steps per second """
step_size = step_size or self.game_step
return await self._execute(step=sc_pb.RequestStep(count=step_size))
async def get_game_data(self) -> GameData:
result = await self._execute(
data=sc_pb.RequestData(ability_id=True, unit_type_id=True, upgrade_id=True, buff_id=True, effect_id=True)
)
return GameData(result.data)
async def dump_data(self, ability_id=True, unit_type_id=True, upgrade_id=True, buff_id=True, effect_id=True):
"""
Dump the game data files
choose what data to dump in the keywords
this function writes to a text file
call it one time in on_step with:
await self._client.dump_data()
"""
result = await self._execute(
data=sc_pb.RequestData(
ability_id=ability_id,
unit_type_id=unit_type_id,
upgrade_id=upgrade_id,
buff_id=buff_id,
effect_id=effect_id,
)
)
with open("data_dump.txt", "a") as file:
file.write(str(result.data))
async def get_game_info(self) -> GameInfo:
result = await self._execute(game_info=sc_pb.RequestGameInfo())
return GameInfo(result.game_info)
async def query_pathing(self, start: Union[Unit, Point2, Point3],
end: Union[Point2, Point3]) -> Optional[Union[int, float]]:
"""Caution: returns "None" when path not found
Try to combine queries with the function below because the pathing query is generally slow.
:param start:
:param end:"""
assert isinstance(start, (Point2, Unit))
assert isinstance(end, Point2)
if isinstance(start, Point2):
path = [query_pb.RequestQueryPathing(start_pos=start.as_Point2D, end_pos=end.as_Point2D)]
else:
path = [query_pb.RequestQueryPathing(unit_tag=start.tag, end_pos=end.as_Point2D)]
result = await self._execute(query=query_pb.RequestQuery(pathing=path))
distance = float(result.query.pathing[0].distance)
if distance <= 0.0:
return None
return distance
async def query_pathings(self, zipped_list: List[List[Union[Unit, Point2, Point3]]]) -> List[float]:
"""Usage: await self.query_pathings([[unit1, target2], [unit2, target2]])
-> returns [distance1, distance2]
Caution: returns 0 when path not found
:param zipped_list:
"""
assert zipped_list, "No zipped_list"
assert isinstance(zipped_list, list), f"{type(zipped_list)}"
assert isinstance(zipped_list[0], list), f"{type(zipped_list[0])}"
assert len(zipped_list[0]) == 2, f"{len(zipped_list[0])}"
assert isinstance(zipped_list[0][0], (Point2, Unit)), f"{type(zipped_list[0][0])}"
assert isinstance(zipped_list[0][1], Point2), f"{type(zipped_list[0][1])}"
if isinstance(zipped_list[0][0], Point2):
path = (
query_pb.RequestQueryPathing(start_pos=p1.as_Point2D, end_pos=p2.as_Point2D) for p1, p2 in zipped_list
)
else:
path = (query_pb.RequestQueryPathing(unit_tag=p1.tag, end_pos=p2.as_Point2D) for p1, p2 in zipped_list)
results = await self._execute(query=query_pb.RequestQuery(pathing=path))
return [float(d.distance) for d in results.query.pathing]
async def query_building_placement(
self,
ability: AbilityData,
positions: List[Union[Point2, Point3]],
ignore_resources: bool = True
) -> List[ActionResult]:
"""This function might be deleted in favor of the function above (_query_building_placement_fast).
:param ability:
:param positions:
:param ignore_resources:"""
assert isinstance(ability, AbilityData)
result = await self._execute(
query=query_pb.RequestQuery(
placements=(
query_pb.RequestQueryBuildingPlacement(ability_id=ability.id.value, target_pos=position.as_Point2D)
for position in positions
),
ignore_resource_requirements=ignore_resources,
)
)
# Unnecessary converting to ActionResult?
return [ActionResult(p.result) for p in result.query.placements]
async def chat_send(self, message: str, team_only: bool):
""" Writes a message to the chat """
ch = ChatChannel.Team if team_only else ChatChannel.Broadcast
await self._execute(
action=sc_pb.RequestAction(
actions=[sc_pb.Action(action_chat=sc_pb.ActionChat(channel=ch.value, message=message))]
)
)
async def debug_kill_unit(self, unit_tags: Union[Unit, Units, List[int], Set[int]]):
"""
:param unit_tags:
"""
if isinstance(unit_tags, Units):
unit_tags = unit_tags.tags
if isinstance(unit_tags, Unit):
unit_tags = [unit_tags.tag]
assert unit_tags
await self._execute(
debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(kill_unit=debug_pb.DebugKillUnit(tag=unit_tags))])
)
async def move_camera(self, position: Union[Unit, Units, Point2, Point3]):
"""Moves camera to the target position
:param position:"""
assert isinstance(position, (Unit, Units, Point2, Point3))
if isinstance(position, Units):
position = position.center
if isinstance(position, Unit):
position = position.position
await self._execute(
action=sc_pb.RequestAction(
actions=[
sc_pb.Action(
action_raw=raw_pb.ActionRaw(
camera_move=raw_pb.ActionRawCameraMove(center_world_space=position.to3.as_Point)
)
)
]
)
)
async def obs_move_camera(self, position: Union[Unit, Units, Point2, Point3]):
"""Moves observer camera to the target position. Only works when observing (e.g. watching the replay).
:param position:"""
assert isinstance(position, (Unit, Units, Point2, Point3))
if isinstance(position, Units):
position = position.center
if isinstance(position, Unit):
position = position.position
await self._execute(
obs_action=sc_pb.RequestObserverAction(
actions=[
sc_pb.ObserverAction(camera_move=sc_pb.ActionObserverCameraMove(world_pos=position.as_Point2D))
]
)
)
async def move_camera_spatial(self, position: Union[Point2, Point3]):
"""Moves camera to the target position using the spatial aciton interface
:param position:"""
assert isinstance(position, (Point2, Point3))
action = sc_pb.Action(
action_render=spatial_pb.ActionSpatial(
camera_move=spatial_pb.ActionSpatialCameraMove(center_minimap=position.as_PointI)
)
)
await self._execute(action=sc_pb.RequestAction(actions=[action]))
def debug_text_simple(self, text: str):
""" Draws a text in the top left corner of the screen (up to a max of 6 messages fit there). """
self._debug_texts.append(DrawItemScreenText(text=text, color=None, start_point=Point2((0, 0)), font_size=8))
def debug_text_screen(
self,
text: str,
pos: Union[Point2, Point3, tuple, list],
color: Union[tuple, list, Point3] = None,
size: int = 8,
):
"""
Draws a text on the screen (monitor / game window) with coordinates 0 <= x, y <= 1.
:param text:
:param pos:
:param color:
:param size:
"""
assert len(pos) >= 2
assert 0 <= pos[0] <= 1
assert 0 <= pos[1] <= 1
pos = Point2((pos[0], pos[1]))
self._debug_texts.append(DrawItemScreenText(text=text, color=color, start_point=pos, font_size=size))
def debug_text_2d(
self,
text: str,
pos: Union[Point2, Point3, tuple, list],
color: Union[tuple, list, Point3] = None,
size: int = 8,
):
return self.debug_text_screen(text, pos, color, size)
def debug_text_world(
self, text: str, pos: Union[Unit, Point3], color: Union[tuple, list, Point3] = None, size: int = 8
):
"""
Draws a text at Point3 position in the game world.
To grab a unit's 3d position, use unit.position3d
Usually the Z value of a Point3 is between 8 and 14 (except for flying units). Use self.get_terrain_z_height() from bot_ai.py to get the Z value (height) of the terrain at a 2D position.
:param text:
:param color:
:param size:
"""
if isinstance(pos, Unit):
pos = pos.position3d
assert isinstance(pos, Point3)
self._debug_texts.append(DrawItemWorldText(text=text, color=color, start_point=pos, font_size=size))
def debug_text_3d(
self, text: str, pos: Union[Unit, Point3], color: Union[tuple, list, Point3] = None, size: int = 8
):
return self.debug_text_world(text, pos, color, size)
def debug_line_out(
self, p0: Union[Unit, Point3], p1: Union[Unit, Point3], color: Union[tuple, list, Point3] = None
):
"""
Draws a line from p0 to p1.
:param p0:
:param p1:
:param color:
"""
if isinstance(p0, Unit):
p0 = p0.position3d
assert isinstance(p0, Point3)
if isinstance(p1, Unit):
p1 = p1.position3d
assert isinstance(p1, Point3)
self._debug_lines.append(DrawItemLine(color=color, start_point=p0, end_point=p1))
def debug_box_out(
self,
p_min: Union[Unit, Point3],
p_max: Union[Unit, Point3],
color: Union[tuple, list, Point3] = None,
):
"""
Draws a box with p_min and p_max as corners of the box.
:param p_min:
:param p_max:
:param color:
"""
if isinstance(p_min, Unit):
p_min = p_min.position3d
assert isinstance(p_min, Point3)
if isinstance(p_max, Unit):
p_max = p_max.position3d
assert isinstance(p_max, Point3)
self._debug_boxes.append(DrawItemBox(start_point=p_min, end_point=p_max, color=color))
def debug_box2_out(
self,
pos: Union[Unit, Point3],
half_vertex_length: float = 0.25,
color: Union[tuple, list, Point3] = None,
):
"""
Draws a box center at a position 'pos', with box side lengths (vertices) of two times 'half_vertex_length'.
:param pos:
:param half_vertex_length:
:param color:
"""
if isinstance(pos, Unit):
pos = pos.position3d
assert isinstance(pos, Point3)
p0 = pos + Point3((-half_vertex_length, -half_vertex_length, -half_vertex_length))
p1 = pos + Point3((half_vertex_length, half_vertex_length, half_vertex_length))
self._debug_boxes.append(DrawItemBox(start_point=p0, end_point=p1, color=color))
def debug_sphere_out(self, p: Union[Unit, Point3], r: float, color: Union[tuple, list, Point3] = None):
"""
Draws a sphere at point p with radius r.
:param p:
:param r:
:param color:
"""
if isinstance(p, Unit):
p = p.position3d
assert isinstance(p, Point3)
self._debug_spheres.append(DrawItemSphere(start_point=p, radius=r, color=color))
async def _send_debug(self):
"""Sends the debug draw execution. This is run by main.py now automatically, if there is any items in the list. You do not need to run this manually any longer.
Check examples/terran/ramp_wall.py for example drawing. Each draw request needs to be sent again in every single on_step iteration.
"""
debug_hash = (
sum(hash(item) for item in self._debug_texts),
sum(hash(item) for item in self._debug_lines),
sum(hash(item) for item in self._debug_boxes),
sum(hash(item) for item in self._debug_spheres),
)
if debug_hash != (0, 0, 0, 0):
if debug_hash != self._debug_hash_tuple_last_iteration:
# Something has changed, either more or less is to be drawn, or a position of a drawing changed (e.g. when drawing on a moving unit)
self._debug_hash_tuple_last_iteration = debug_hash
try:
await self._execute(
debug=sc_pb.RequestDebug(
debug=[
debug_pb.DebugCommand(
draw=debug_pb.DebugDraw(
text=[text.to_proto()
for text in self._debug_texts] if self._debug_texts else None,
lines=[line.to_proto()
for line in self._debug_lines] if self._debug_lines else None,
boxes=[box.to_proto()
for box in self._debug_boxes] if self._debug_boxes else None,
spheres=[sphere.to_proto()
for sphere in self._debug_spheres] if self._debug_spheres else None,
)
)
]
)
)
except ProtocolError:
return
self._debug_draw_last_frame = True
self._debug_texts.clear()
self._debug_lines.clear()
self._debug_boxes.clear()
self._debug_spheres.clear()
elif self._debug_draw_last_frame:
# Clear drawing if we drew last frame but nothing to draw this frame
self._debug_hash_tuple_last_iteration = (0, 0, 0, 0)
await self._execute(
debug=sc_pb.RequestDebug(
debug=[
debug_pb.DebugCommand(draw=debug_pb.DebugDraw(text=None, lines=None, boxes=None, spheres=None))
]
)
)
self._debug_draw_last_frame = False
async def debug_leave(self):
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(end_game=debug_pb.DebugEndGame())]))
async def debug_set_unit_value(self, unit_tags: Union[Iterable[int], Units, Unit], unit_value: int, value: float):
"""Sets a "unit value" (Energy, Life or Shields) of the given units to the given value.
Can't set the life of a unit to 0, use "debug_kill_unit" for that. Also can't set the life above the unit's maximum.
The following example sets the health of all your workers to 1:
await self.debug_set_unit_value(self.workers, 2, value=1)"""
if isinstance(unit_tags, Units):
unit_tags = unit_tags.tags
if isinstance(unit_tags, Unit):
unit_tags = [unit_tags.tag]
assert hasattr(
unit_tags, "__iter__"
), f"unit_tags argument needs to be an iterable (list, dict, set, Units), given argument is {type(unit_tags).__name__}"
assert (
1 <= unit_value <= 3
), f"unit_value needs to be between 1 and 3 (1 for energy, 2 for life, 3 for shields), given argument is {unit_value}"
assert all(tag > 0 for tag in unit_tags), f"Unit tags have invalid value: {unit_tags}"
assert isinstance(value, (int, float)), "Value needs to be of type int or float"
assert value >= 0, "Value can't be negative"
await self._execute(
debug=sc_pb.RequestDebug(
debug=(
debug_pb.DebugCommand(
unit_value=debug_pb.
DebugSetUnitValue(unit_value=unit_value, value=float(value), unit_tag=unit_tag)
) for unit_tag in unit_tags
)
)
)
async def debug_hang(self, delay_in_seconds: float):
""" Freezes the SC2 client. Not recommended to be used. """
delay_in_ms = int(round(delay_in_seconds * 1000))
await self._execute(
debug=sc_pb.RequestDebug(
debug=[debug_pb.DebugCommand(test_process=debug_pb.DebugTestProcess(test=1, delay_ms=delay_in_ms))]
)
)
async def debug_show_map(self):
""" Reveals the whole map for the bot. Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=1)]))
async def debug_control_enemy(self):
""" Allows control over enemy units and structures similar to team games control - does not allow the bot to spend the opponent's ressources. Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=2)]))
async def debug_food(self):
""" Should disable food usage (does not seem to work?). Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=3)]))
async def debug_free(self):
""" Units, structures and upgrades are free of mineral and gas cost. Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=4)]))
async def debug_all_resources(self):
""" Gives 5000 minerals and 5000 vespene to the bot. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=5)]))
async def debug_god(self):
""" Your units and structures no longer take any damage. Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=6)]))
async def debug_minerals(self):
""" Gives 5000 minerals to the bot. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=7)]))
async def debug_gas(self):
""" Gives 5000 vespene to the bot. This does not seem to be working. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=8)]))
async def debug_cooldown(self):
""" Disables cooldowns of unit abilities for the bot. Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=9)]))
async def debug_tech_tree(self):
""" Removes all tech requirements (e.g. can build a factory without having a barracks). Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=10)]))
async def debug_upgrade(self):
""" Researches all currently available upgrades. E.g. using it once unlocks combat shield, stimpack and 1-1. Using it a second time unlocks 2-2 and all other upgrades stay researched. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=11)]))
async def debug_fast_build(self):
""" Sets the build time of units and structures and upgrades to zero. Using it a second time disables it again. """
await self._execute(debug=sc_pb.RequestDebug(debug=[debug_pb.DebugCommand(game_state=12)]))
async def quick_save(self):
"""Saves the current game state to an in-memory bookmark.
See: https://github.com/Blizzard/s2client-proto/blob/eeaf5efaea2259d7b70247211dff98da0a2685a2/s2clientprotocol/sc2api.proto#L93"""
await self._execute(quick_save=sc_pb.RequestQuickSave())
async def quick_load(self):
"""Loads the game state from the previously stored in-memory bookmark.
Caution:
- The SC2 Client will crash if the game wasn't quicksaved
- The bot step iteration counter will not reset
- self.state.game_loop will be set to zero after the quickload, and self.time is dependant on it"""
await self._execute(quick_load=sc_pb.RequestQuickLoad())
class DrawItem:
@staticmethod
def to_debug_color(color: Union[tuple, Point3]):
""" Helper function for color conversion """
if color is None:
return debug_pb.Color(r=255, g=255, b=255)
# Need to check if not of type Point3 because Point3 inherits from tuple
if isinstance(color, (tuple, list)) and not isinstance(color, Point3) and len(color) == 3:
return debug_pb.Color(r=color[0], g=color[1], b=color[2])
# In case color is of type Point3
r = getattr(color, "r", getattr(color, "x", 255))
g = getattr(color, "g", getattr(color, "y", 255))
b = getattr(color, "b", getattr(color, "z", 255))
if max(r, g, b) <= 1:
r *= 255
g *= 255
b *= 255
return debug_pb.Color(r=int(r), g=int(g), b=int(b))
class DrawItemScreenText(DrawItem):
def __init__(self, start_point: Point2 = None, color: Point3 = None, text: str = "", font_size: int = 8):
self._start_point: Point2 = start_point
self._color: Point3 = color
self._text: str = text
self._font_size: int = font_size
def to_proto(self):
return debug_pb.DebugText(
color=self.to_debug_color(self._color),
text=self._text,
virtual_pos=self._start_point.to3.as_Point,
world_pos=None,
size=self._font_size,
)
def __hash__(self):
return hash((self._start_point, self._color, self._text, self._font_size))
class DrawItemWorldText(DrawItem):
def __init__(self, start_point: Point3 = None, color: Point3 = None, text: str = "", font_size: int = 8):
self._start_point: Point3 = start_point
self._color: Point3 = color
self._text: str = text
self._font_size: int = font_size
def to_proto(self):
return debug_pb.DebugText(
color=self.to_debug_color(self._color),
text=self._text,
virtual_pos=None,
world_pos=self._start_point.as_Point,
size=self._font_size,
)
def __hash__(self):
return hash((self._start_point, self._text, self._font_size, self._color))
class DrawItemLine(DrawItem):
def __init__(self, start_point: Point3 = None, end_point: Point3 = None, color: Point3 = None):
self._start_point: Point3 = start_point
self._end_point: Point3 = end_point
self._color: Point3 = color
def to_proto(self):
return debug_pb.DebugLine(
line=debug_pb.Line(p0=self._start_point.as_Point, p1=self._end_point.as_Point),
color=self.to_debug_color(self._color),
)
def __hash__(self):
return hash((self._start_point, self._end_point, self._color))
class DrawItemBox(DrawItem):
def __init__(self, start_point: Point3 = None, end_point: Point3 = None, color: Point3 = None):
self._start_point: Point3 = start_point
self._end_point: Point3 = end_point
self._color: Point3 = color
def to_proto(self):
return debug_pb.DebugBox(
min=self._start_point.as_Point,
max=self._end_point.as_Point,
color=self.to_debug_color(self._color),
)
def __hash__(self):
return hash((self._start_point, self._end_point, self._color))
class DrawItemSphere(DrawItem):
def __init__(self, start_point: Point3 = None, radius: float = None, color: Point3 = None):
self._start_point: Point3 = start_point
self._radius: float = radius
self._color: Point3 = color
def to_proto(self):
return debug_pb.DebugSphere(
p=self._start_point.as_Point, r=self._radius, color=self.to_debug_color(self._color)
)
def __hash__(self):
return hash((self._start_point, self._radius, self._color))

View File

@@ -0,0 +1,30 @@
from typing import Set
from .data import Alliance, Attribute, CloakState, DisplayType, TargetType
IS_STRUCTURE: int = Attribute.Structure.value
IS_LIGHT: int = Attribute.Light.value
IS_ARMORED: int = Attribute.Armored.value
IS_BIOLOGICAL: int = Attribute.Biological.value
IS_MECHANICAL: int = Attribute.Mechanical.value
IS_MASSIVE: int = Attribute.Massive.value
IS_PSIONIC: int = Attribute.Psionic.value
TARGET_GROUND: Set[int] = {TargetType.Ground.value, TargetType.Any.value}
TARGET_AIR: Set[int] = {TargetType.Air.value, TargetType.Any.value}
TARGET_BOTH = TARGET_GROUND | TARGET_AIR
IS_SNAPSHOT = DisplayType.Snapshot.value
IS_VISIBLE = DisplayType.Visible.value
IS_PLACEHOLDER = DisplayType.Placeholder.value
IS_MINE = Alliance.Self.value
IS_ENEMY = Alliance.Enemy.value
IS_CLOAKED: Set[int] = {CloakState.Cloaked.value, CloakState.CloakedDetected.value, CloakState.CloakedAllied.value}
IS_REVEALED: int = CloakState.CloakedDetected.value
CAN_BE_ATTACKED: Set[int] = {CloakState.NotCloaked.value, CloakState.CloakedDetected.value}
TARGET_HELPER = {
1: "no target",
2: "Point2",
3: "Unit",
4: "Point2 or Unit",
5: "Point2 or no target",
}

View File

@@ -0,0 +1,80 @@
import platform
from pathlib import Path
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .player import Computer
from .protocol import Protocol
class Controller(Protocol):
def __init__(self, ws, process):
super().__init__(ws)
self._process = process
@property
def running(self):
# pylint: disable=W0212
return self._process._process is not None
async def create_game(self, game_map, players, realtime: bool, random_seed=None, disable_fog=None):
req = sc_pb.RequestCreateGame(
local_map=sc_pb.LocalMap(map_path=str(game_map.relative_path)), realtime=realtime, disable_fog=disable_fog
)
if random_seed is not None:
req.random_seed = random_seed
for player in players:
p = req.player_setup.add()
p.type = player.type.value
if isinstance(player, Computer):
p.race = player.race.value
p.difficulty = player.difficulty.value
p.ai_build = player.ai_build.value
logger.info("Creating new game")
logger.info(f"Map: {game_map.name}")
logger.info(f"Players: {', '.join(str(p) for p in players)}")
result = await self._execute(create_game=req)
return result
async def request_available_maps(self):
req = sc_pb.RequestAvailableMaps()
result = await self._execute(available_maps=req)
return result
async def request_save_map(self, download_path: str):
""" Not working on linux. """
req = sc_pb.RequestSaveMap(map_path=download_path)
result = await self._execute(save_map=req)
return result
async def request_replay_info(self, replay_path: str):
""" Not working on linux. """
req = sc_pb.RequestReplayInfo(replay_path=replay_path, download_data=False)
result = await self._execute(replay_info=req)
return result
async def start_replay(self, replay_path: str, realtime: bool, observed_id: int = 0):
ifopts = sc_pb.InterfaceOptions(
raw=True, score=True, show_cloaked=True, raw_affects_selection=True, raw_crop_to_playable_area=False
)
if platform.system() == "Linux":
replay_name = Path(replay_path).name
home_replay_folder = Path.home() / "Documents" / "StarCraft II" / "Replays"
if str(home_replay_folder / replay_name) != replay_path:
logger.warning(
f"Linux detected, please put your replay in your home directory at {home_replay_folder}. It was detected at {replay_path}"
)
raise FileNotFoundError
replay_path = replay_name
req = sc_pb.RequestStartReplay(
replay_path=replay_path, observed_player_id=observed_id, realtime=realtime, options=ifopts
)
result = await self._execute(start_replay=req)
assert result.status == 4, f"{result.start_replay.error} - {result.start_replay.error_details}"
return result

View File

@@ -0,0 +1,36 @@
""" For the list of enums, see here
https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188fbe9dbf/include/sc2api/sc2_gametypes.h
https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188fbe9dbf/include/sc2api/sc2_action.h
https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188fbe9dbf/include/sc2api/sc2_unit.h
https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188fbe9dbf/include/sc2api/sc2_data.h
"""
import enum
from s2clientprotocol import common_pb2 as common_pb
from s2clientprotocol import data_pb2 as data_pb
from s2clientprotocol import error_pb2 as error_pb
from s2clientprotocol import raw_pb2 as raw_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
CreateGameError = enum.Enum("CreateGameError", sc_pb.ResponseCreateGame.Error.items())
PlayerType = enum.Enum("PlayerType", sc_pb.PlayerType.items())
Difficulty = enum.Enum("Difficulty", sc_pb.Difficulty.items())
AIBuild = enum.Enum("AIBuild", sc_pb.AIBuild.items())
Status = enum.Enum("Status", sc_pb.Status.items())
Result = enum.Enum("Result", sc_pb.Result.items())
Alert = enum.Enum("Alert", sc_pb.Alert.items())
ChatChannel = enum.Enum("ChatChannel", sc_pb.ActionChat.Channel.items())
Race = enum.Enum("Race", common_pb.Race.items())
DisplayType = enum.Enum("DisplayType", raw_pb.DisplayType.items())
Alliance = enum.Enum("Alliance", raw_pb.Alliance.items())
CloakState = enum.Enum("CloakState", raw_pb.CloakState.items())
Attribute = enum.Enum("Attribute", data_pb.Attribute.items())
TargetType = enum.Enum("TargetType", data_pb.Weapon.TargetType.items())
Target = enum.Enum("Target", data_pb.AbilityData.Target.items())
ActionResult = enum.Enum("ActionResult", error_pb.ActionResult.items())

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from collections import OrderedDict
from threading import RLock
from typing import TYPE_CHECKING, Any, Iterable, Union
if TYPE_CHECKING:
from .bot_ai import BotAI
class ExpiringDict(OrderedDict):
"""
An expiring dict that uses the bot.state.game_loop to only return items that are valid for a specific amount of time.
Example usages::
async def on_step(iteration: int):
# This dict will hold up to 10 items and only return values that have been added up to 20 frames ago
my_dict = ExpiringDict(self, max_age_frames=20)
if iteration == 0:
# Add item
my_dict["test"] = "something"
if iteration == 2:
# On default, one iteration is called every 8 frames
if "test" in my_dict:
print("test is in dict")
if iteration == 20:
if "test" not in my_dict:
print("test is not anymore in dict")
"""
def __init__(self, bot: BotAI, max_age_frames: int = 1):
assert max_age_frames >= -1
assert bot
OrderedDict.__init__(self)
self.bot: BotAI = bot
self.max_age: Union[int, float] = max_age_frames
self.lock: RLock = RLock()
@property
def frame(self) -> int:
return self.bot.state.game_loop
def __contains__(self, key) -> bool:
""" Return True if dict has key, else False, e.g. 'key in dict' """
with self.lock:
if OrderedDict.__contains__(self, key):
# Each item is a list of [value, frame time]
item = OrderedDict.__getitem__(self, key)
if self.frame - item[1] < self.max_age:
return True
del self[key]
return False
def __getitem__(self, key, with_age=False) -> Any:
""" Return the item of the dict using d[key] """
with self.lock:
# Each item is a list of [value, frame time]
item = OrderedDict.__getitem__(self, key)
if self.frame - item[1] < self.max_age:
if with_age:
return item[0], item[1]
return item[0]
OrderedDict.__delitem__(self, key)
raise KeyError(key)
def __setitem__(self, key, value):
""" Set d[key] = value """
with self.lock:
OrderedDict.__setitem__(self, key, (value, self.frame))
def __repr__(self):
""" Printable version of the dict instead of getting memory adress """
print_list = []
with self.lock:
for key, value in OrderedDict.items(self):
if self.frame - value[1] < self.max_age:
print_list.append(f"{repr(key)}: {repr(value)}")
print_str = ", ".join(print_list)
return f"ExpiringDict({print_str})"
def __str__(self):
return self.__repr__()
def __iter__(self):
""" Override 'for key in dict:' """
with self.lock:
return self.keys()
# TODO find a way to improve len
def __len__(self):
"""Override len method as key value pairs aren't instantly being deleted, but only on __get__(item).
This function is slow because it has to check if each element is not expired yet."""
with self.lock:
count = 0
for _ in self.values():
count += 1
return count
def pop(self, key, default=None, with_age=False):
""" Return the item and remove it """
with self.lock:
if OrderedDict.__contains__(self, key):
item = OrderedDict.__getitem__(self, key)
if self.frame - item[1] < self.max_age:
del self[key]
if with_age:
return item[0], item[1]
return item[0]
del self[key]
if default is None:
raise KeyError(key)
if with_age:
return default, self.frame
return default
def get(self, key, default=None, with_age=False):
""" Return the value for key if key is in dict, else default """
with self.lock:
if OrderedDict.__contains__(self, key):
item = OrderedDict.__getitem__(self, key)
if self.frame - item[1] < self.max_age:
if with_age:
return item[0], item[1]
return item[0]
if default is None:
raise KeyError(key)
if with_age:
return default, self.frame
return None
return None
def update(self, other_dict: dict):
with self.lock:
for key, value in other_dict.items():
self[key] = value
def items(self) -> Iterable:
""" Return iterator of zipped list [keys, values] """
with self.lock:
for key, value in OrderedDict.items(self):
if self.frame - value[1] < self.max_age:
yield key, value[0]
def keys(self) -> Iterable:
""" Return iterator of keys """
with self.lock:
for key, value in OrderedDict.items(self):
if self.frame - value[1] < self.max_age:
yield key
def values(self) -> Iterable:
""" Return iterator of values """
with self.lock:
for value in OrderedDict.values(self):
if self.frame - value[1] < self.max_age:
yield value[0]

View File

@@ -0,0 +1,209 @@
# pylint: disable=W0212
from __future__ import annotations
from bisect import bisect_left
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Union
from .data import Attribute, Race
# Set of parts of names of abilities that have no cost
# E.g every ability that has 'Hold' in its name is free
FREE_ABILITIES = {"Lower", "Raise", "Land", "Lift", "Hold", "Harvest"}
class GameData:
def __init__(self, data):
"""
:param data:
"""
self.abilities: Dict[int, AbilityData] = {}
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
class AbilityData:
@classmethod
def id_exists(cls, ability_id):
assert isinstance(ability_id, int), f"Wrong type: {ability_id} is not int"
if ability_id == 0:
return False
i = bisect_left(cls.ability_ids, ability_id) # quick binary search
return i != len(cls.ability_ids) and cls.ability_ids[i] == ability_id
def __init__(self, game_data, proto):
self._game_data = game_data
self._proto = proto
# What happens if we comment this out? Should this not be commented out? What is its purpose?
assert self.id != 0
def __repr__(self) -> str:
return f"AbilityData(name={self._proto.button_name})"
@property
def link_name(self) -> str:
""" For Stimpack this returns 'BarracksTechLabResearch' """
return self._proto.link_name
@property
def button_name(self) -> str:
""" For Stimpack this returns 'Stimpack' """
return self._proto.button_name
@property
def friendly_name(self) -> str:
""" For Stimpack this returns 'Research Stimpack' """
return self._proto.friendly_name
@property
def is_free_morph(self) -> bool:
return any(free in self._proto.link_name for free in FREE_ABILITIES)
@property
def cost(self) -> Cost:
return self._game_data.calculate_ability_cost(self.id)
class UnitTypeData:
def __init__(self, game_data: GameData, proto):
"""
:param game_data:
:param proto:
"""
self._game_data = game_data
self._proto = proto
def __repr__(self) -> str:
return f"UnitTypeData(name={self.name})"
@property
def name(self) -> str:
return self._proto.name
@property
def creation_ability(self) -> Optional[AbilityData]:
if self._proto.ability_id == 0:
return None
if self._proto.ability_id not in self._game_data.abilities:
return None
return self._game_data.abilities[self._proto.ability_id]
@property
def footprint_radius(self) -> Optional[float]:
""" See unit.py footprint_radius """
if self.creation_ability is None:
return None
return self.creation_ability._proto.footprint_radius
@property
def attributes(self) -> List[Attribute]:
return self._proto.attributes
def has_attribute(self, attr) -> bool:
assert isinstance(attr, Attribute)
return attr in self.attributes
@property
def has_minerals(self) -> bool:
return self._proto.has_minerals
@property
def has_vespene(self) -> bool:
return self._proto.has_vespene
@property
def cargo_size(self) -> int:
""" How much cargo this unit uses up in cargo_space """
return self._proto.cargo_size
@property
def race(self) -> Race:
return Race(self._proto.race)
@property
def cost(self) -> Cost:
return Cost(self._proto.mineral_cost, self._proto.vespene_cost, self._proto.build_time)
@property
def cost_zerg_corrected(self) -> Cost:
""" This returns 25 for extractor and 200 for spawning pool instead of 75 and 250 respectively """
if self.race == Race.Zerg and Attribute.Structure.value in self.attributes:
return Cost(self._proto.mineral_cost - 50, self._proto.vespene_cost, self._proto.build_time)
return self.cost
class UpgradeData:
def __init__(self, game_data: GameData, proto):
"""
:param game_data:
:param proto:
"""
self._game_data = game_data
self._proto = proto
def __repr__(self):
return f"UpgradeData({self.name} - research ability: {self.research_ability}, {self.cost})"
@property
def name(self) -> str:
return self._proto.name
@property
def research_ability(self) -> Optional[AbilityData]:
if self._proto.ability_id == 0:
return None
if self._proto.ability_id not in self._game_data.abilities:
return None
return self._game_data.abilities[self._proto.ability_id]
@property
def cost(self) -> Cost:
return Cost(self._proto.mineral_cost, self._proto.vespene_cost, self._proto.research_time)
@dataclass
class Cost:
"""
The cost of an action, a structure, a unit or a research upgrade.
The time is given in frames (22.4 frames per game second).
"""
minerals: int
vespene: int
time: Optional[float] = None
def __repr__(self) -> str:
return f"Cost({self.minerals}, {self.vespene})"
def __eq__(self, other: Cost) -> bool:
return self.minerals == other.minerals and self.vespene == other.vespene
def __ne__(self, other: Cost) -> bool:
return self.minerals != other.minerals or self.vespene != other.vespene
def __bool__(self) -> bool:
return self.minerals != 0 or self.vespene != 0
def __add__(self, other) -> Cost:
if not other:
return self
if not self:
return other
time = (self.time or 0) + (other.time or 0)
return Cost(self.minerals + other.minerals, self.vespene + other.vespene, time=time)
def __sub__(self, other: Cost) -> Cost:
time = (self.time or 0) + (other.time or 0)
return Cost(self.minerals - other.minerals, self.vespene - other.vespene, time=time)
def __mul__(self, other: int) -> Cost:
return Cost(self.minerals * other, self.vespene * other, time=self.time)
def __rmul__(self, other: int) -> Cost:
return Cost(self.minerals * other, self.vespene * other, time=self.time)

View File

@@ -0,0 +1,282 @@
from __future__ import annotations
import heapq
from collections import deque
from dataclasses import dataclass
from functools import cached_property
from typing import Deque, Dict, FrozenSet, Iterable, List, Optional, Set, Tuple
from .pixel_map import PixelMap
from .player import Player, Race
from .position import Point2, Rect, Size
@dataclass
class Ramp:
points: FrozenSet[Point2]
game_info: GameInfo
@property
def x_offset(self) -> float:
# Tested by printing actual building locations vs calculated depot positions
return 0.5
@property
def y_offset(self) -> float:
# Tested by printing actual building locations vs calculated depot positions
return 0.5
@cached_property
def _height_map(self):
return self.game_info.terrain_height
@cached_property
def size(self) -> int:
return len(self.points)
def height_at(self, p: Point2) -> int:
return self._height_map[p]
@cached_property
def upper(self) -> FrozenSet[Point2]:
""" Returns the upper points of a ramp. """
current_max = -10000
result = set()
for p in self.points:
height = self.height_at(p)
if height > current_max:
current_max = height
result = {p}
elif height == current_max:
result.add(p)
return frozenset(result)
@cached_property
def upper2_for_ramp_wall(self) -> FrozenSet[Point2]:
""" Returns the 2 upper ramp points of the main base ramp required for the supply depot and barracks placement properties used in this file. """
# From bottom center, find 2 points that are furthest away (within the same ramp)
return frozenset(heapq.nlargest(2, self.upper, key=lambda x: x.distance_to_point2(self.bottom_center)))
@cached_property
def top_center(self) -> Point2:
length = len(self.upper)
pos = Point2((sum(p.x for p in self.upper) / length, sum(p.y for p in self.upper) / length))
return pos
@cached_property
def lower(self) -> FrozenSet[Point2]:
current_min = 10000
result = set()
for p in self.points:
height = self.height_at(p)
if height < current_min:
current_min = height
result = {p}
elif height == current_min:
result.add(p)
return frozenset(result)
@cached_property
def bottom_center(self) -> Point2:
length = len(self.lower)
pos = Point2((sum(p.x for p in self.lower) / length, sum(p.y for p in self.lower) / length))
return pos
@cached_property
def barracks_in_middle(self) -> Optional[Point2]:
""" Barracks position in the middle of the 2 depots """
if len(self.upper) not in {2, 5}:
return None
if len(self.upper2_for_ramp_wall) == 2:
points = set(self.upper2_for_ramp_wall)
p1 = points.pop().offset((self.x_offset, self.y_offset))
p2 = points.pop().offset((self.x_offset, self.y_offset))
# Offset from top point to barracks center is (2, 1)
intersects = p1.circle_intersection(p2, 5**0.5)
any_lower_point = next(iter(self.lower))
return max(intersects, key=lambda p: p.distance_to_point2(any_lower_point))
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
@cached_property
def depot_in_middle(self) -> Optional[Point2]:
""" Depot in the middle of the 3 depots """
if len(self.upper) not in {2, 5}:
return None
if len(self.upper2_for_ramp_wall) == 2:
points = set(self.upper2_for_ramp_wall)
p1 = points.pop().offset((self.x_offset, self.y_offset))
p2 = points.pop().offset((self.x_offset, self.y_offset))
# Offset from top point to depot center is (1.5, 0.5)
try:
intersects = p1.circle_intersection(p2, 2.5**0.5)
except AssertionError:
# Returns None when no placement was found, this is the case on the map Honorgrounds LE with an exceptionally large main base ramp
return None
any_lower_point = next(iter(self.lower))
return max(intersects, key=lambda p: p.distance_to_point2(any_lower_point))
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
@cached_property
def corner_depots(self) -> FrozenSet[Point2]:
""" Finds the 2 depot positions on the outside """
if not self.upper2_for_ramp_wall:
return frozenset()
if len(self.upper2_for_ramp_wall) == 2:
points = set(self.upper2_for_ramp_wall)
p1 = points.pop().offset((self.x_offset, self.y_offset))
p2 = points.pop().offset((self.x_offset, self.y_offset))
center = p1.towards(p2, p1.distance_to_point2(p2) / 2)
depot_position = self.depot_in_middle
if depot_position is None:
return frozenset()
# Offset from middle depot to corner depots is (2, 1)
intersects = center.circle_intersection(depot_position, 5**0.5)
return intersects
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
@cached_property
def barracks_can_fit_addon(self) -> bool:
""" Test if a barracks can fit an addon at natural ramp """
# https://i.imgur.com/4b2cXHZ.png
if len(self.upper2_for_ramp_wall) == 2:
return self.barracks_in_middle.x + 1 > max(self.corner_depots, key=lambda depot: depot.x).x
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
@cached_property
def barracks_correct_placement(self) -> Optional[Point2]:
""" Corrected placement so that an addon can fit """
if self.barracks_in_middle is None:
return None
if len(self.upper2_for_ramp_wall) == 2:
if self.barracks_can_fit_addon:
return self.barracks_in_middle
return self.barracks_in_middle.offset((-2, 0))
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
@cached_property
def protoss_wall_pylon(self) -> Optional[Point2]:
"""
Pylon position that powers the two wall buildings and the warpin position.
"""
if len(self.upper) not in {2, 5}:
return None
if len(self.upper2_for_ramp_wall) != 2:
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
middle = self.depot_in_middle
# direction up the ramp
direction = self.barracks_in_middle.negative_offset(middle)
return middle + 6 * direction
@cached_property
def protoss_wall_buildings(self) -> FrozenSet[Point2]:
"""
List of two positions for 3x3 buildings that form a wall with a spot for a one unit block.
These buildings can be powered by a pylon on the protoss_wall_pylon position.
"""
if len(self.upper) not in {2, 5}:
return frozenset()
if len(self.upper2_for_ramp_wall) == 2:
middle = self.depot_in_middle
# direction up the ramp
direction = self.barracks_in_middle.negative_offset(middle)
# sort depots based on distance to start to get wallin orientation
sorted_depots = sorted(
self.corner_depots, key=lambda depot: depot.distance_to(self.game_info.player_start_location)
)
wall1: Point2 = sorted_depots[1].offset(direction)
wall2 = middle + direction + (middle - wall1) / 1.5
return frozenset([wall1, wall2])
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
@cached_property
def protoss_wall_warpin(self) -> Optional[Point2]:
"""
Position for a unit to block the wall created by protoss_wall_buildings.
Powered by protoss_wall_pylon.
"""
if len(self.upper) not in {2, 5}:
return None
if len(self.upper2_for_ramp_wall) != 2:
raise Exception("Not implemented. Trying to access a ramp that has a wrong amount of upper points.")
middle = self.depot_in_middle
# direction up the ramp
direction = self.barracks_in_middle.negative_offset(middle)
# sort depots based on distance to start to get wallin orientation
sorted_depots = sorted(self.corner_depots, key=lambda x: x.distance_to(self.game_info.player_start_location))
return sorted_depots[0].negative_offset(direction)
class GameInfo:
def __init__(self, proto):
self._proto = proto
self.players: List[Player] = [Player.from_proto(p) for p in self._proto.player_info]
self.map_name: str = self._proto.map_name
self.local_map_path: str = self._proto.local_map_path
self.map_size: Size = Size.from_proto(self._proto.start_raw.map_size)
# self.pathing_grid[point]: if 0, point is not pathable, if 1, point is pathable
self.pathing_grid: PixelMap = PixelMap(self._proto.start_raw.pathing_grid, in_bits=True)
# self.terrain_height[point]: returns the height in range of 0 to 255 at that point
self.terrain_height: PixelMap = PixelMap(self._proto.start_raw.terrain_height)
# self.placement_grid[point]: if 0, point is not placeable, if 1, point is pathable
self.placement_grid: PixelMap = PixelMap(self._proto.start_raw.placement_grid, in_bits=True)
self.playable_area = Rect.from_proto(self._proto.start_raw.playable_area)
self.map_center = self.playable_area.center
self.map_ramps: List[Ramp] = None # Filled later by BotAI._prepare_first_step
self.vision_blockers: FrozenSet[Point2] = None # Filled later by BotAI._prepare_first_step
self.player_races: Dict[int, Race] = {
p.player_id: p.race_actual or p.race_requested
for p in self._proto.player_info
}
self.start_locations: List[Point2] = [
Point2.from_proto(sl).round(decimals=1) for sl in self._proto.start_raw.start_locations
]
self.player_start_location: Point2 = None # Filled later by BotAI._prepare_first_step
def _find_groups(self, points: FrozenSet[Point2], minimum_points_per_group: int = 8) -> Iterable[FrozenSet[Point2]]:
"""
From a set of points, this function will try to group points together by
painting clusters of points in a rectangular map using flood fill algorithm.
Returns groups of points as list, like [{p1, p2, p3}, {p4, p5, p6, p7, p8}]
"""
# TODO do we actually need colors here? the ramps will never touch anyways.
NOT_COLORED_YET = -1
map_width = self.pathing_grid.width
map_height = self.pathing_grid.height
current_color: int = NOT_COLORED_YET
picture: List[List[int]] = [[-2 for _ in range(map_width)] for _ in range(map_height)]
def paint(pt: Point2) -> None:
picture[pt.y][pt.x] = current_color
nearby: List[Tuple[int, int]] = [(a, b) for a in [-1, 0, 1] for b in [-1, 0, 1] if a != 0 or b != 0]
remaining: Set[Point2] = set(points)
for point in remaining:
paint(point)
current_color = 1
queue: Deque[Point2] = deque()
while remaining:
current_group: Set[Point2] = set()
if not queue:
start = remaining.pop()
paint(start)
queue.append(start)
current_group.add(start)
while queue:
base: Point2 = queue.popleft()
for offset in nearby:
px, py = base.x + offset[0], base.y + offset[1]
# Do we ever reach out of map bounds?
if not (0 <= px < map_width and 0 <= py < map_height):
continue
if picture[py][px] != NOT_COLORED_YET:
continue
point: Point2 = Point2((px, py))
remaining.discard(point)
paint(point)
queue.append(point)
current_group.add(point)
if len(current_group) >= minimum_points_per_group:
yield frozenset(current_group)

View File

@@ -0,0 +1,204 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import cached_property
from itertools import chain
from typing import List, Set
from .constants import IS_ENEMY, IS_MINE
from .data import Alliance, DisplayType
from .pixel_map import PixelMap
from .position import Point2, Point3
from .power_source import PsionicMatrix
from .score import ScoreDetails
class Blip:
def __init__(self, proto):
"""
:param proto:
"""
self._proto = proto
@property
def is_blip(self) -> bool:
"""Detected by sensor tower."""
return self._proto.is_blip
@property
def is_snapshot(self) -> bool:
return self._proto.display_type == DisplayType.Snapshot.value
@property
def is_visible(self) -> bool:
return self._proto.display_type == DisplayType.Visible.value
@property
def alliance(self) -> Alliance:
return self._proto.alliance
@property
def is_mine(self) -> bool:
return self._proto.alliance == Alliance.Self.value
@property
def is_enemy(self) -> bool:
return self._proto.alliance == Alliance.Enemy.value
@property
def position(self) -> Point2:
"""2d position of the blip."""
return Point2.from_proto(self._proto.pos)
@property
def position3d(self) -> Point3:
"""3d position of the blip."""
return Point3.from_proto(self._proto.pos)
class Common:
ATTRIBUTES = [
"player_id",
"minerals",
"vespene",
"food_cap",
"food_used",
"food_army",
"food_workers",
"idle_worker_count",
"army_count",
"warp_gate_count",
"larva_count",
]
def __init__(self, proto):
self._proto = proto
def __getattr__(self, attr):
assert attr in self.ATTRIBUTES, f"'{attr}' is not a valid attribute"
return int(getattr(self._proto, attr))
class EffectData:
def __init__(self, proto, fake=False):
"""
:param proto:
:param fake:
"""
self._proto = proto
self.fake = fake
@property
def positions(self) -> Set[Point2]:
if self.fake:
return {Point2.from_proto(self._proto.pos)}
return {Point2.from_proto(p) for p in self._proto.pos}
@property
def alliance(self) -> Alliance:
return self._proto.alliance
@property
def is_mine(self) -> bool:
""" Checks if the effect is caused by me. """
return self._proto.alliance == IS_MINE
@property
def is_enemy(self) -> bool:
""" Checks if the effect is hostile. """
return self._proto.alliance == IS_ENEMY
@property
def owner(self) -> int:
return self._proto.owner
@property
def radius(self) -> float:
return self._proto.radius
def __repr__(self) -> str:
return f"{self.id} with radius {self.radius} at {self.positions}"
@dataclass
class ChatMessage:
player_id: int
message: str
@dataclass
class ActionRawCameraMove:
center_world_space: Point2
class GameState:
def __init__(self, response_observation, previous_observation=None):
"""
:param response_observation:
:param previous_observation:
"""
# Only filled in realtime=True in case the bot skips frames
self.previous_observation = previous_observation
self.response_observation = response_observation
# https://github.com/Blizzard/s2client-proto/blob/51662231c0965eba47d5183ed0a6336d5ae6b640/s2clientprotocol/sc2api.proto#L575
self.observation = response_observation.observation
self.observation_raw = self.observation.raw_data
self.player_result = response_observation.player_result
self.common: Common = Common(self.observation.player_common)
# Area covered by Pylons and Warpprisms
self.psionic_matrix: PsionicMatrix = PsionicMatrix.from_proto(self.observation_raw.player.power_sources)
# 22.4 per second on faster game speed
self.game_loop: int = self.observation.game_loop
# https://github.com/Blizzard/s2client-proto/blob/33f0ecf615aa06ca845ffe4739ef3133f37265a9/s2clientprotocol/score.proto#L31
self.score: ScoreDetails = ScoreDetails(self.observation.score)
self.abilities = self.observation.abilities # abilities of selected units
self.upgrades = set()
# self.upgrades: Set[UpgradeId] = {UpgradeId(upgrade) for upgrade in self.observation_raw.player.upgrade_ids}
# self.visibility[point]: 0=Hidden, 1=Fogged, 2=Visible
self.visibility: PixelMap = PixelMap(self.observation_raw.map_state.visibility)
# self.creep[point]: 0=No creep, 1=creep
self.creep: PixelMap = PixelMap(self.observation_raw.map_state.creep, in_bits=True)
# Effects like ravager bile shot, lurker attack, everything in effect_id.py
# self.effects: Set[EffectData] = {EffectData(effect) for effect in self.observation_raw.effects}
self.effects = set()
""" Usage:
for effect in self.state.effects:
if effect.id == EffectId.RAVAGERCORROSIVEBILECP:
positions = effect.positions
# dodge the ravager biles
"""
@cached_property
def dead_units(self) -> Set[int]:
""" A set of unit tags that died this frame """
_dead_units = set(self.observation_raw.event.dead_units)
if self.previous_observation:
return _dead_units | set(self.previous_observation.observation.raw_data.event.dead_units)
return _dead_units
@cached_property
def chat(self) -> List[ChatMessage]:
"""List of chat messages sent this frame (by either player)."""
previous_frame_chat = self.previous_observation.chat if self.previous_observation else []
return [
ChatMessage(message.player_id, message.message)
for message in chain(previous_frame_chat, self.response_observation.chat)
]
@cached_property
def alerts(self) -> List[int]:
"""
Game alerts, see https://github.com/Blizzard/s2client-proto/blob/01ab351e21c786648e4c6693d4aad023a176d45c/s2clientprotocol/sc2api.proto#L683-L706
"""
if self.previous_observation:
return list(chain(self.previous_observation.observation.alerts, self.observation.alerts))
return self.observation.alerts

View File

@@ -0,0 +1,646 @@
# pylint: disable=W0212
from __future__ import annotations
import asyncio
import json
import platform
import signal
from contextlib import suppress
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import mpyq
import portpicker
from aiohttp import ClientSession, ClientWebSocketResponse
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .bot_ai import BotAI
from .client import Client
from .controller import Controller
from .data import CreateGameError, Result, Status
from .game_state import GameState
from .maps import Map
from .player import AbstractPlayer, Bot, BotProcess, Human
from .portconfig import Portconfig
from .protocol import ConnectionAlreadyClosed, ProtocolError
from .proxy import Proxy
from .sc2process import SC2Process, kill_switch
@dataclass
class GameMatch:
"""Dataclass for hosting a match of SC2.
This contains all of the needed information for RequestCreateGame.
:param sc2_config: dicts of arguments to unpack into sc2process's construction, one per player
second sc2_config will be ignored if only one sc2_instance is spawned
e.g. sc2_args=[{"fullscreen": True}, {}]: only player 1's sc2instance will be fullscreen
:param game_time_limit: The time (in seconds) until a match is artificially declared a Tie
"""
map_sc2: Map
players: List[AbstractPlayer]
realtime: bool = False
random_seed: int = None
disable_fog: bool = None
sc2_config: List[Dict] = None
game_time_limit: int = None
def __post_init__(self):
# avoid players sharing names
if len(self.players) > 1 and self.players[0].name is not None and self.players[0].name == self.players[1].name:
self.players[1].name += "2"
if self.sc2_config is not None:
if isinstance(self.sc2_config, dict):
self.sc2_config = [self.sc2_config]
if len(self.sc2_config) == 0:
self.sc2_config = [{}]
while len(self.sc2_config) < len(self.players):
self.sc2_config += self.sc2_config
self.sc2_config = self.sc2_config[:len(self.players)]
@property
def needed_sc2_count(self) -> int:
return sum(player.needs_sc2 for player in self.players)
@property
def host_game_kwargs(self) -> Dict:
return {
"map_settings": self.map_sc2,
"players": self.players,
"realtime": self.realtime,
"random_seed": self.random_seed,
"disable_fog": self.disable_fog,
}
def __repr__(self):
p1 = self.players[0]
p1 = p1.name if p1.name else p1
p2 = self.players[1]
p2 = p2.name if p2.name else p2
return f"Map: {self.map_sc2.name}, {p1} vs {p2}, realtime={self.realtime}, seed={self.random_seed}"
async def _play_game_human(client, player_id, realtime, game_time_limit):
while True:
state = await client.observation()
if client._game_result:
return client._game_result[player_id]
if game_time_limit and state.observation.observation.game_loop / 22.4 > game_time_limit:
logger.info(state.observation.game_loop, state.observation.game_loop / 22.4)
return Result.Tie
if not realtime:
await client.step()
# pylint: disable=R0912,R0911,R0914
async def _play_game_ai(
client: Client, player_id: int, ai: BotAI, realtime: bool, game_time_limit: Optional[int]
) -> Result:
gs: GameState = None
async def initialize_first_step() -> Optional[Result]:
nonlocal gs
ai._initialize_variables()
game_data = await client.get_game_data()
game_info = await client.get_game_info()
ping_response = await client.ping()
# This game_data will become self.game_data in botAI
ai._prepare_start(
client, player_id, game_info, game_data, realtime=realtime, base_build=ping_response.ping.base_build
)
state = await client.observation()
# check game result every time we get the observation
if client._game_result:
await ai.on_end(client._game_result[player_id])
return client._game_result[player_id]
gs = GameState(state.observation)
proto_game_info = await client._execute(game_info=sc_pb.RequestGameInfo())
try:
ai._prepare_step(gs, proto_game_info)
await ai.on_before_start()
ai._prepare_first_step()
await ai.on_start()
# TODO Catching too general exception Exception (broad-except)
# pylint: disable=W0703
except Exception as e:
logger.exception(f"Caught unknown exception in AI on_start: {e}")
logger.error("Resigning due to previous error")
await ai.on_end(Result.Defeat)
return Result.Defeat
result = await initialize_first_step()
if result is not None:
return result
async def run_bot_iteration(iteration: int):
nonlocal gs
logger.debug(f"Running AI step, it={iteration} {gs.game_loop / 22.4:.2f}s")
# Issue event like unit created or unit destroyed
await ai.issue_events()
# In on_step various errors can occur - log properly
try:
await ai.on_step(iteration)
except (AttributeError, ) as e:
logger.exception(f"Caught exception: {e}")
raise
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
raise
await ai._after_step()
logger.debug("Running AI step: done")
# Only used in realtime=True
previous_state_observation = None
for iteration in range(10**10):
if realtime and gs:
# On realtime=True, might get an error here: sc2.protocol.ProtocolError: ['Not in a game']
with suppress(ProtocolError):
requested_step = gs.game_loop + client.game_step
state = await client.observation(requested_step)
# If the bot took too long in the previous observation, request another observation one frame after
if state.observation.observation.game_loop > requested_step:
logger.debug("Skipped a step in realtime=True")
previous_state_observation = state.observation
state = await client.observation(state.observation.observation.game_loop + 1)
else:
state = await client.observation()
# check game result every time we get the observation
if client._game_result:
await ai.on_end(client._game_result[player_id])
return client._game_result[player_id]
gs = GameState(state.observation, previous_state_observation)
previous_state_observation = None
logger.debug(f"Score: {gs.score.score}")
if game_time_limit and gs.game_loop / 22.4 > game_time_limit:
await ai.on_end(Result.Tie)
return Result.Tie
proto_game_info = await client._execute(game_info=sc_pb.RequestGameInfo())
ai._prepare_step(gs, proto_game_info)
await run_bot_iteration(iteration) # Main bot loop
if not realtime:
if not client.in_game: # Client left (resigned) the game
await ai.on_end(client._game_result[player_id])
return client._game_result[player_id]
# TODO: In bot vs bot, if the other bot ends the game, this bot gets stuck in requesting an observation when using main.py:run_multiple_games
await client.step()
return Result.Undecided
async def _play_game(
player: AbstractPlayer,
client: Client,
realtime,
portconfig,
game_time_limit=None,
rgb_render_config=None
) -> Result:
assert isinstance(realtime, bool), repr(realtime)
player_id = await client.join_game(
player.name, player.race, portconfig=portconfig, rgb_render_config=rgb_render_config
)
logger.info(f"Player {player_id} - {player.name if player.name else str(player)}")
if isinstance(player, Human):
result = await _play_game_human(client, player_id, realtime, game_time_limit)
else:
result = await _play_game_ai(client, player_id, player.ai, realtime, game_time_limit)
logger.info(
f"Result for player {player_id} - {player.name if player.name else str(player)}: "
f"{result._name_ if isinstance(result, Result) else result}"
)
return result
async def _setup_host_game(
server: Controller, map_settings, players, realtime, random_seed=None, disable_fog=None, save_replay_as=None
):
r = await server.create_game(map_settings, players, realtime, random_seed, disable_fog)
if r.create_game.HasField("error"):
err = f"Could not create game: {CreateGameError(r.create_game.error)}"
if r.create_game.HasField("error_details"):
err += f": {r.create_game.error_details}"
logger.critical(err)
raise RuntimeError(err)
return Client(server._ws, save_replay_as)
async def _host_game(
map_settings,
players,
realtime=False,
portconfig=None,
save_replay_as=None,
game_time_limit=None,
rgb_render_config=None,
random_seed=None,
sc2_version=None,
disable_fog=None,
):
assert players, "Can't create a game without players"
assert any(isinstance(p, (Human, Bot)) for p in players)
async with SC2Process(
fullscreen=players[0].fullscreen, render=rgb_render_config is not None, sc2_version=sc2_version
) as server:
await server.ping()
client = await _setup_host_game(
server, map_settings, players, realtime, random_seed, disable_fog, save_replay_as
)
# Bot can decide if it wants to launch with 'raw_affects_selection=True'
if not isinstance(players[0], Human) and getattr(players[0].ai, "raw_affects_selection", None) is not None:
client.raw_affects_selection = players[0].ai.raw_affects_selection
result = await _play_game(players[0], client, realtime, portconfig, game_time_limit, rgb_render_config)
if client.save_replay_path is not None:
await client.save_replay(client.save_replay_path)
try:
await client.leave()
except ConnectionAlreadyClosed:
logger.error("Connection was closed before the game ended")
await client.quit()
return result
async def _host_game_aiter(
map_settings,
players,
realtime,
portconfig=None,
save_replay_as=None,
game_time_limit=None,
):
assert players, "Can't create a game without players"
assert any(isinstance(p, (Human, Bot)) for p in players)
async with SC2Process() as server:
while True:
await server.ping()
client = await _setup_host_game(server, map_settings, players, realtime)
if not isinstance(players[0], Human) and getattr(players[0].ai, "raw_affects_selection", None) is not None:
client.raw_affects_selection = players[0].ai.raw_affects_selection
try:
result = await _play_game(players[0], client, realtime, portconfig, game_time_limit)
if save_replay_as is not None:
await client.save_replay(save_replay_as)
await client.leave()
except ConnectionAlreadyClosed:
logger.error("Connection was closed before the game ended")
return
new_players = yield result
if new_players is not None:
players = new_players
def _host_game_iter(*args, **kwargs):
game = _host_game_aiter(*args, **kwargs)
new_playerconfig = None
while True:
new_playerconfig = yield asyncio.get_event_loop().run_until_complete(game.asend(new_playerconfig))
async def _join_game(
players,
realtime,
portconfig,
save_replay_as=None,
game_time_limit=None,
):
async with SC2Process(fullscreen=players[1].fullscreen) as server:
await server.ping()
client = Client(server._ws)
# Bot can decide if it wants to launch with 'raw_affects_selection=True'
if not isinstance(players[1], Human) and getattr(players[1].ai, "raw_affects_selection", None) is not None:
client.raw_affects_selection = players[1].ai.raw_affects_selection
result = await _play_game(players[1], client, realtime, portconfig, game_time_limit)
if save_replay_as is not None:
await client.save_replay(save_replay_as)
try:
await client.leave()
except ConnectionAlreadyClosed:
logger.error("Connection was closed before the game ended")
await client.quit()
return result
def get_replay_version(replay_path: Union[str, Path]) -> Tuple[str, str]:
with open(replay_path, 'rb') as f:
replay_data = f.read()
replay_io = BytesIO()
replay_io.write(replay_data)
replay_io.seek(0)
archive = mpyq.MPQArchive(replay_io).extract()
metadata = json.loads(archive[b"replay.gamemetadata.json"].decode("utf-8"))
return metadata["BaseBuild"], metadata["DataVersion"]
# TODO Deprecate run_game function in favor of run_multiple_games
def run_game(map_settings, players, **kwargs) -> Union[Result, List[Optional[Result]]]:
"""
Returns a single Result enum if the game was against the built-in computer.
Returns a list of two Result enums if the game was "Human vs Bot" or "Bot vs Bot".
"""
if sum(isinstance(p, (Human, Bot)) for p in players) > 1:
host_only_args = ["save_replay_as", "rgb_render_config", "random_seed", "sc2_version", "disable_fog"]
join_kwargs = {k: v for k, v in kwargs.items() if k not in host_only_args}
portconfig = Portconfig()
async def run_host_and_join():
return await asyncio.gather(
_host_game(map_settings, players, **kwargs, portconfig=portconfig),
_join_game(players, **join_kwargs, portconfig=portconfig),
return_exceptions=True
)
result: List[Result] = asyncio.run(run_host_and_join())
assert isinstance(result, list)
assert all(isinstance(r, Result) for r in result)
else:
result: Result = asyncio.run(_host_game(map_settings, players, **kwargs))
assert isinstance(result, Result)
return result
async def play_from_websocket(
ws_connection: Union[str, ClientWebSocketResponse],
player: AbstractPlayer,
realtime: bool = False,
portconfig: Portconfig = None,
save_replay_as=None,
game_time_limit: int = None,
should_close=True,
):
"""Use this to play when the match is handled externally e.g. for bot ladder games.
Portconfig MUST be specified if not playing vs Computer.
:param ws_connection: either a string("ws://{address}:{port}/sc2api") or a ClientWebSocketResponse object
:param should_close: closes the connection if True. Use False if something else will reuse the connection
e.g. ladder usage: play_from_websocket("ws://127.0.0.1:5162/sc2api", MyBot, False, portconfig=my_PC)
"""
session = None
try:
if isinstance(ws_connection, str):
session = ClientSession()
ws_connection = await session.ws_connect(ws_connection, timeout=120)
should_close = True
client = Client(ws_connection)
result = await _play_game(player, client, realtime, portconfig, game_time_limit=game_time_limit)
if save_replay_as is not None:
await client.save_replay(save_replay_as)
except ConnectionAlreadyClosed:
logger.error("Connection was closed before the game ended")
return None
finally:
if should_close:
await ws_connection.close()
if session:
await session.close()
return result
async def run_match(controllers: List[Controller], match: GameMatch, close_ws=True):
await _setup_host_game(controllers[0], **match.host_game_kwargs)
# Setup portconfig beforehand, so all players use the same ports
startport = None
portconfig = None
if match.needed_sc2_count > 1:
if any(isinstance(player, BotProcess) for player in match.players):
portconfig = Portconfig.contiguous_ports()
# Most ladder bots generate their server and client ports as [s+2, s+3], [s+4, s+5]
startport = portconfig.server[0] - 2
else:
portconfig = Portconfig()
proxies = []
coros = []
players_that_need_sc2 = filter(lambda lambda_player: lambda_player.needs_sc2, match.players)
for i, player in enumerate(players_that_need_sc2):
if isinstance(player, BotProcess):
pport = portpicker.pick_unused_port()
p = Proxy(controllers[i], player, pport, match.game_time_limit, match.realtime)
proxies.append(p)
coros.append(p.play_with_proxy(startport))
else:
coros.append(
play_from_websocket(
controllers[i]._ws,
player,
match.realtime,
portconfig,
should_close=close_ws,
game_time_limit=match.game_time_limit,
)
)
async_results = await asyncio.gather(*coros, return_exceptions=True)
if not isinstance(async_results, list):
async_results = [async_results]
for i, a in enumerate(async_results):
if isinstance(a, Exception):
logger.error(f"Exception[{a}] thrown by {[p for p in match.players if p.needs_sc2][i]}")
return process_results(match.players, async_results)
def process_results(players: List[AbstractPlayer], async_results: List[Result]) -> Dict[AbstractPlayer, Result]:
opp_res = {Result.Victory: Result.Defeat, Result.Defeat: Result.Victory, Result.Tie: Result.Tie}
result: Dict[AbstractPlayer, Result] = {}
i = 0
for player in players:
if player.needs_sc2:
if sum(r == Result.Victory for r in async_results) <= 1:
result[player] = async_results[i]
else:
result[player] = Result.Undecided
i += 1
else: # computer
other_result = async_results[0]
result[player] = None
if other_result in opp_res:
result[player] = opp_res[other_result]
return result
# pylint: disable=R0912
async def maintain_SCII_count(count: int, controllers: List[Controller], proc_args: List[Dict] = None):
"""Modifies the given list of controllers to reflect the desired amount of SCII processes"""
# kill unhealthy ones.
if controllers:
to_remove = []
alive = await asyncio.wait_for(
asyncio.gather(*(c.ping() for c in controllers if not c._ws.closed), return_exceptions=True), timeout=20
)
i = 0 # for alive
for controller in controllers:
if controller._ws.closed:
if not controller._process._session.closed:
await controller._process._session.close()
to_remove.append(controller)
else:
if not isinstance(alive[i], sc_pb.Response):
try:
await controller._process._close_connection()
finally:
to_remove.append(controller)
i += 1
for c in to_remove:
c._process._clean(verbose=False)
if c._process in kill_switch._to_kill:
kill_switch._to_kill.remove(c._process)
controllers.remove(c)
# spawn more
if len(controllers) < count:
needed = count - len(controllers)
if proc_args:
index = len(controllers) % len(proc_args)
else:
proc_args = [{} for _ in range(needed)]
index = 0
extra = [SC2Process(**proc_args[(index + _) % len(proc_args)]) for _ in range(needed)]
logger.info(f"Creating {needed} more SC2 Processes")
for _ in range(3):
if platform.system() == "Linux":
# Works on linux: start one client after the other
# pylint: disable=C2801
new_controllers = [await asyncio.wait_for(sc.__aenter__(), timeout=50) for sc in extra]
else:
# Doesnt seem to work on linux: starting 2 clients nearly at the same time
new_controllers = await asyncio.wait_for(
# pylint: disable=C2801
asyncio.gather(*[sc.__aenter__() for sc in extra], return_exceptions=True),
timeout=50
)
controllers.extend(c for c in new_controllers if isinstance(c, Controller))
if len(controllers) == count:
await asyncio.wait_for(asyncio.gather(*(c.ping() for c in controllers)), timeout=20)
break
extra = [
extra[i] for i, result in enumerate(new_controllers) if not isinstance(new_controllers, Controller)
]
else:
logger.critical("Could not launch sufficient SC2")
raise RuntimeError
# kill excess
while len(controllers) > count:
proc = controllers.pop()
proc = proc._process
logger.info(f"Removing SCII listening to {proc._port}")
await proc._close_connection()
proc._clean(verbose=False)
if proc in kill_switch._to_kill:
kill_switch._to_kill.remove(proc)
def run_multiple_games(matches: List[GameMatch]):
return asyncio.get_event_loop().run_until_complete(a_run_multiple_games(matches))
# TODO Catching too general exception Exception (broad-except)
# pylint: disable=W0703
async def a_run_multiple_games(matches: List[GameMatch]) -> List[Dict[AbstractPlayer, Result]]:
"""Run multiple matches.
Non-python bots are supported.
When playing bot vs bot, this is less likely to fatally crash than repeating run_game()
"""
if not matches:
return []
results = []
controllers = []
for m in matches:
result = None
dont_restart = m.needed_sc2_count == 2
try:
await maintain_SCII_count(m.needed_sc2_count, controllers, m.sc2_config)
result = await run_match(controllers, m, close_ws=dont_restart)
except SystemExit as e:
logger.info(f"Game exit'ed as {e} during match {m}")
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
logger.info(f"Exception {e} thrown in match {m}")
finally:
if dont_restart: # Keeping them alive after a non-computer match can cause crashes
await maintain_SCII_count(0, controllers, m.sc2_config)
results.append(result)
kill_switch.kill_all()
return results
# TODO Catching too general exception Exception (broad-except)
# pylint: disable=W0703
async def a_run_multiple_games_nokill(matches: List[GameMatch]) -> List[Dict[AbstractPlayer, Result]]:
"""Run multiple matches while reusing SCII processes.
Prone to crashes and stalls
"""
# FIXME: check whether crashes between bot-vs-bot are avoidable or not
if not matches:
return []
# Start the matches
results = []
controllers = []
for m in matches:
logger.info(f"Starting match {1 + len(results)} / {len(matches)}: {m}")
result = None
try:
await maintain_SCII_count(m.needed_sc2_count, controllers, m.sc2_config)
result = await run_match(controllers, m, close_ws=False)
except SystemExit as e:
logger.critical(f"Game sys.exit'ed as {e} during match {m}")
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
logger.info(f"Exception {e} thrown in match {m}")
finally:
for c in controllers:
try:
await c.ping()
if c._status != Status.launched:
await c._execute(leave_game=sc_pb.RequestLeaveGame())
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
if not (isinstance(e, ProtocolError) and e.is_game_over_error):
logger.info(f"controller {c.__dict__} threw {e}")
results.append(result)
# Fire the killswitch manually, instead of letting the winning player fire it.
await asyncio.wait_for(asyncio.gather(*(c._process._close_connection() for c in controllers)), timeout=50)
kill_switch.kill_all()
signal.signal(signal.SIGINT, signal.SIG_DFL)
return results

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
from worlds._sc2common.bot import logger
from .paths import Paths
def get(name: str) -> Map:
# Iterate through 2 folder depths
for map_dir in (p for p in Paths.MAPS.iterdir()):
if map_dir.is_dir():
for map_file in (p for p in map_dir.iterdir()):
if Map.matches_target_map_name(map_file, name):
return Map(map_file)
elif Map.matches_target_map_name(map_dir, name):
return Map(map_dir)
raise KeyError(f"Map '{name}' was not found. Please put the map file in \"/StarCraft II/Maps/\".")
class Map:
def __init__(self, path: Path):
self.path = path
if self.path.is_absolute():
try:
self.relative_path = self.path.relative_to(Paths.MAPS)
except ValueError: # path not relative to basedir
logger.warning(f"Using absolute path: {self.path}")
self.relative_path = self.path
else:
self.relative_path = self.path
@property
def name(self):
return self.path.stem
@property
def data(self):
with open(self.path, "rb") as f:
return f.read()
def __repr__(self):
return f"Map({self.path})"
@classmethod
def is_map_file(cls, file: Path) -> bool:
return file.is_file() and file.suffix == ".SC2Map"
@classmethod
def matches_target_map_name(cls, file: Path, name: str) -> bool:
return cls.is_map_file(file) and file.stem == name

View File

@@ -0,0 +1,155 @@
"""
This class is very experimental and probably not up to date and needs to be refurbished.
If it works, you can watch replays with it.
"""
# pylint: disable=W0201,W0212
from __future__ import annotations
from typing import TYPE_CHECKING, List, Union
from .bot_ai_internal import BotAIInternal
from .data import Alert, Result
from .game_data import GameData
from .position import Point2
from .unit import Unit
from .units import Units
if TYPE_CHECKING:
from .client import Client
from .game_info import GameInfo
class ObserverAI(BotAIInternal):
"""Base class for bots."""
@property
def time(self) -> float:
""" Returns time in seconds, assumes the game is played on 'faster' """
return self.state.game_loop / 22.4 # / (1/1.4) * (1/16)
@property
def time_formatted(self) -> str:
""" Returns time as string in min:sec format """
t = self.time
return f"{int(t // 60):02}:{int(t % 60):02}"
@property
def game_info(self) -> GameInfo:
""" See game_info.py """
return self._game_info
@property
def game_data(self) -> GameData:
""" See game_data.py """
return self._game_data
@property
def client(self) -> Client:
""" See client.py """
return self._client
def alert(self, alert_code: Alert) -> bool:
"""
Check if alert is triggered in the current step.
Possible alerts are listed here https://github.com/Blizzard/s2client-proto/blob/e38efed74c03bec90f74b330ea1adda9215e655f/s2clientprotocol/sc2api.proto#L679-L702
Example use:
from sc2.data import Alert
if self.alert(Alert.AddOnComplete):
print("Addon Complete")
Alert codes::
AlertError
AddOnComplete
BuildingComplete
BuildingUnderAttack
LarvaHatched
MergeComplete
MineralsExhausted
MorphComplete
MothershipComplete
MULEExpired
NuclearLaunchDetected
NukeComplete
NydusWormDetected
ResearchComplete
TrainError
TrainUnitComplete
TrainWorkerComplete
TransformationComplete
UnitUnderAttack
UpgradeComplete
VespeneExhausted
WarpInComplete
:param alert_code:
"""
assert isinstance(alert_code, Alert), f"alert_code {alert_code} is no Alert"
return alert_code.value in self.state.alerts
@property
def start_location(self) -> Point2:
"""
Returns the spawn location of the bot, using the position of the first created townhall.
This will be None if the bot is run on an arcade or custom map that does not feature townhalls at game start.
"""
return self.game_info.player_start_location
@property
def enemy_start_locations(self) -> List[Point2]:
"""Possible start locations for enemies."""
return self.game_info.start_locations
async def on_unit_destroyed(self, unit_tag: int):
"""
Override this in your bot class.
This will event will be called when a unit (or structure, friendly or enemy) dies.
For enemy units, this only works if the enemy unit was in vision on death.
:param unit_tag:
"""
async def on_unit_created(self, unit: Unit):
"""Override this in your bot class. This function is called when a unit is created.
:param unit:"""
async def on_building_construction_started(self, unit: Unit):
"""
Override this in your bot class.
This function is called when a building construction has started.
:param unit:
"""
async def on_building_construction_complete(self, unit: Unit):
"""
Override this in your bot class. This function is called when a building
construction is completed.
:param unit:
"""
async def on_start(self):
"""
Override this in your bot class. This function is called after "on_start".
At this point, game_data, game_info and the first iteration of game_state (self.state) are available.
"""
async def on_step(self, iteration: int):
"""
You need to implement this function!
Override this in your bot class.
This function is called on every game step (looped in realtime mode).
:param iteration:
"""
raise NotImplementedError
async def on_end(self, game_result: Result):
"""Override this in your bot class. This function is called at the end of a game.
:param game_result:"""

View File

@@ -0,0 +1,157 @@
import os
import platform
import re
import sys
from contextlib import suppress
from pathlib import Path
from worlds._sc2common.bot import logger
from . import wsl
BASEDIR = {
"Windows": "C:/Program Files (x86)/StarCraft II",
"WSL1": "/mnt/c/Program Files (x86)/StarCraft II",
"WSL2": "/mnt/c/Program Files (x86)/StarCraft II",
"Darwin": "/Applications/StarCraft II",
"Linux": "~/StarCraftII",
"WineLinux": "~/.wine/drive_c/Program Files (x86)/StarCraft II",
}
USERPATH = {
"Windows": "Documents\\StarCraft II\\ExecuteInfo.txt",
"WSL1": "Documents/StarCraft II/ExecuteInfo.txt",
"WSL2": "Documents/StarCraft II/ExecuteInfo.txt",
"Darwin": "Library/Application Support/Blizzard/StarCraft II/ExecuteInfo.txt",
"Linux": None,
"WineLinux": None,
}
BINPATH = {
"Windows": "SC2_x64.exe",
"WSL1": "SC2_x64.exe",
"WSL2": "SC2_x64.exe",
"Darwin": "SC2.app/Contents/MacOS/SC2",
"Linux": "SC2_x64",
"WineLinux": "SC2_x64.exe",
}
CWD = {
"Windows": "Support64",
"WSL1": "Support64",
"WSL2": "Support64",
"Darwin": None,
"Linux": None,
"WineLinux": "Support64",
}
def platform_detect():
pf = os.environ.get("SC2PF", platform.system())
if pf == "Linux":
return wsl.detect() or pf
return pf
PF = platform_detect()
def get_home():
"""Get home directory of user, using Windows home directory for WSL."""
if PF in {"WSL1", "WSL2"}:
return wsl.get_wsl_home() or Path.home().expanduser()
return Path.home().expanduser()
def get_user_sc2_install():
"""Attempts to find a user's SC2 install if their OS has ExecuteInfo.txt"""
if USERPATH[PF]:
einfo = str(get_home() / Path(USERPATH[PF]))
if os.path.isfile(einfo):
with open(einfo) as f:
content = f.read()
if content:
base = re.search(r" = (.*)Versions", content).group(1)
if PF in {"WSL1", "WSL2"}:
base = str(wsl.win_path_to_wsl_path(base))
if os.path.exists(base):
return base
return None
def get_env():
# TODO: Linux env conf from: https://github.com/deepmind/pysc2/blob/master/pysc2/run_configs/platforms.py
return None
def get_runner_args(cwd):
if "WINE" in os.environ:
runner_file = Path(os.environ.get("WINE"))
runner_file = runner_file if runner_file.is_file() else runner_file / "wine"
"""
TODO Is converting linux path really necessary?
That would convert
'/home/burny/Games/battlenet/drive_c/Program Files (x86)/StarCraft II/Support64'
to
'Z:\\home\\burny\\Games\\battlenet\\drive_c\\Program Files (x86)\\StarCraft II\\Support64'
"""
return [runner_file, "start", "/d", cwd, "/unix"]
return []
def latest_executeble(versions_dir, base_build=None):
latest = None
if base_build is not None:
with suppress(ValueError):
latest = (
int(base_build[4:]),
max(p for p in versions_dir.iterdir() if p.is_dir() and p.name.startswith(str(base_build))),
)
if base_build is None or latest is None:
latest = max((int(p.name[4:]), p) for p in versions_dir.iterdir() if p.is_dir() and p.name.startswith("Base"))
version, path = latest
if version < 55958:
logger.critical("Your SC2 binary is too old. Upgrade to 3.16.1 or newer.")
sys.exit(1)
return path / BINPATH[PF]
class _MetaPaths(type):
""""Lazily loads paths to allow importing the library even if SC2 isn't installed."""
# pylint: disable=C0203
def __setup(self):
if PF not in BASEDIR:
logger.critical(f"Unsupported platform '{PF}'")
sys.exit(1)
try:
base = os.environ.get("SC2PATH") or get_user_sc2_install() or BASEDIR[PF]
self.BASE = Path(base).expanduser()
self.EXECUTABLE = latest_executeble(self.BASE / "Versions")
self.CWD = self.BASE / CWD[PF] if CWD[PF] else None
self.REPLAYS = self.BASE / "Replays"
if (self.BASE / "maps").exists():
self.MAPS = self.BASE / "maps"
else:
self.MAPS = self.BASE / "Maps"
except FileNotFoundError as e:
logger.critical(f"SC2 installation not found: File '{e.filename}' does not exist.")
sys.exit(1)
# pylint: disable=C0203
def __getattr__(self, attr):
# pylint: disable=E1120
self.__setup()
return getattr(self, attr)
class Paths(metaclass=_MetaPaths):
"""Paths for SC2 folders, lazily loaded using the above metaclass."""

View File

@@ -0,0 +1,98 @@
from typing import Callable, FrozenSet, List, Set, Tuple, Union
from .position import Point2
class PixelMap:
def __init__(self, proto, in_bits: bool = False):
"""
:param proto:
:param in_bits:
"""
self._proto = proto
# Used for copying pixelmaps
self._in_bits: bool = in_bits
assert self.width * self.height == (8 if in_bits else 1) * len(
self._proto.data
), f"{self.width * self.height} {(8 if in_bits else 1)*len(self._proto.data)}"
@property
def width(self) -> int:
return self._proto.size.x
@property
def height(self) -> int:
return self._proto.size.y
@property
def bits_per_pixel(self) -> int:
return self._proto.bits_per_pixel
@property
def bytes_per_pixel(self) -> int:
return self._proto.bits_per_pixel // 8
def __getitem__(self, pos: Tuple[int, int]) -> int:
""" Example usage: is_pathable = self._game_info.pathing_grid[Point2((20, 20))] != 0 """
assert 0 <= pos[0] < self.width, f"x is {pos[0]}, self.width is {self.width}"
assert 0 <= pos[1] < self.height, f"y is {pos[1]}, self.height is {self.height}"
return int(self.data_numpy[pos[1], pos[0]])
def __setitem__(self, pos: Tuple[int, int], value: int):
""" Example usage: self._game_info.pathing_grid[Point2((20, 20))] = 255 """
assert 0 <= pos[0] < self.width, f"x is {pos[0]}, self.width is {self.width}"
assert 0 <= pos[1] < self.height, f"y is {pos[1]}, self.height is {self.height}"
assert (
0 <= value <= 254 * self._in_bits + 1
), f"value is {value}, it should be between 0 and {254 * self._in_bits + 1}"
assert isinstance(value, int), f"value is of type {type(value)}, it should be an integer"
self.data_numpy[pos[1], pos[0]] = value
def is_set(self, p: Tuple[int, int]) -> bool:
return self[p] != 0
def is_empty(self, p: Tuple[int, int]) -> bool:
return not self.is_set(p)
def copy(self) -> "PixelMap":
return PixelMap(self._proto, in_bits=self._in_bits)
def flood_fill(self, start_point: Point2, pred: Callable[[int], bool]) -> Set[Point2]:
nodes: Set[Point2] = set()
queue: List[Point2] = [start_point]
while queue:
x, y = queue.pop()
if not (0 <= x < self.width and 0 <= y < self.height):
continue
if Point2((x, y)) in nodes:
continue
if pred(self[x, y]):
nodes.add(Point2((x, y)))
queue += [Point2((x + a, y + b)) for a in [-1, 0, 1] for b in [-1, 0, 1] if not (a == 0 and b == 0)]
return nodes
def flood_fill_all(self, pred: Callable[[int], bool]) -> Set[FrozenSet[Point2]]:
groups: Set[FrozenSet[Point2]] = set()
for x in range(self.width):
for y in range(self.height):
if any((x, y) in g for g in groups):
continue
if pred(self[x, y]):
groups.add(frozenset(self.flood_fill(Point2((x, y)), pred)))
return groups
def print(self, wide: bool = False) -> None:
for y in range(self.height):
for x in range(self.width):
print("#" if self.is_set((x, y)) else " ", end=(" " if wide else ""))
print("")

View File

@@ -0,0 +1,193 @@
from abc import ABC
from pathlib import Path
from typing import List, Union
from .bot_ai import BotAI
from .data import AIBuild, Difficulty, PlayerType, Race
class AbstractPlayer(ABC):
def __init__(
self,
p_type: PlayerType,
race: Race = None,
name: str = None,
difficulty=None,
ai_build=None,
fullscreen=False
):
assert isinstance(p_type, PlayerType), f"p_type is of type {type(p_type)}"
assert name is None or isinstance(name, str), f"name is of type {type(name)}"
self.name = name
self.type = p_type
self.fullscreen = fullscreen
if race is not None:
self.race = race
if p_type == PlayerType.Computer:
assert isinstance(difficulty, Difficulty), f"difficulty is of type {type(difficulty)}"
# Workaround, proto information does not carry ai_build info
# We cant set that in the Player classmethod
assert ai_build is None or isinstance(ai_build, AIBuild), f"ai_build is of type {type(ai_build)}"
self.difficulty = difficulty
self.ai_build = ai_build
elif p_type == PlayerType.Observer:
assert race is None
assert difficulty is None
assert ai_build is None
else:
assert isinstance(race, Race), f"race is of type {type(race)}"
assert difficulty is None
assert ai_build is None
@property
def needs_sc2(self):
return not isinstance(self, Computer)
class Human(AbstractPlayer):
def __init__(self, race, name=None, fullscreen=False):
super().__init__(PlayerType.Participant, race, name=name, fullscreen=fullscreen)
def __str__(self):
if self.name is not None:
return f"Human({self.race._name_}, name={self.name !r})"
return f"Human({self.race._name_})"
class Bot(AbstractPlayer):
def __init__(self, race, ai, name=None, fullscreen=False):
"""
AI can be None if this player object is just used to inform the
server about player types.
"""
assert isinstance(ai, BotAI) or ai is None, f"ai is of type {type(ai)}, inherit BotAI from bot_ai.py"
super().__init__(PlayerType.Participant, race, name=name, fullscreen=fullscreen)
self.ai = ai
def __str__(self):
if self.name is not None:
return f"Bot {self.ai.__class__.__name__}({self.race._name_}), name={self.name !r})"
return f"Bot {self.ai.__class__.__name__}({self.race._name_})"
class Computer(AbstractPlayer):
def __init__(self, race, difficulty=Difficulty.Easy, ai_build=AIBuild.RandomBuild):
super().__init__(PlayerType.Computer, race, difficulty=difficulty, ai_build=ai_build)
def __str__(self):
return f"Computer {self.difficulty._name_}({self.race._name_}, {self.ai_build.name})"
class Observer(AbstractPlayer):
def __init__(self):
super().__init__(PlayerType.Observer)
def __str__(self):
return "Observer"
class Player(AbstractPlayer):
def __init__(self, player_id, p_type, requested_race, difficulty=None, actual_race=None, name=None, ai_build=None):
super().__init__(p_type, requested_race, difficulty=difficulty, name=name, ai_build=ai_build)
self.id: int = player_id
self.actual_race: Race = actual_race
@classmethod
def from_proto(cls, proto):
if PlayerType(proto.type) == PlayerType.Observer:
return cls(proto.player_id, PlayerType(proto.type), None, None, None)
return cls(
proto.player_id,
PlayerType(proto.type),
Race(proto.race_requested),
Difficulty(proto.difficulty) if proto.HasField("difficulty") else None,
Race(proto.race_actual) if proto.HasField("race_actual") else None,
proto.player_name if proto.HasField("player_name") else None,
)
class BotProcess(AbstractPlayer):
"""
Class for handling bots launched externally, including non-python bots.
Default parameters comply with sc2ai and aiarena ladders.
:param path: the executable file's path
:param launch_list: list of strings that launches the bot e.g. ["python", "run.py"] or ["run.exe"]
:param race: bot's race
:param name: bot's name
:param sc2port_arg: the accepted argument name for the port of the sc2 instance to listen to
:param hostaddress_arg: the accepted argument name for the address of the sc2 instance to listen to
:param match_arg: the accepted argument name for the starting port to generate a portconfig from
:param realtime_arg: the accepted argument name for specifying realtime
:param other_args: anything else that is needed
e.g. to call a bot capable of running on the bot ladders:
BotProcess(os.getcwd(), "python run.py", Race.Terran, "INnoVation")
"""
def __init__(
self,
path: Union[str, Path],
launch_list: List[str],
race: Race,
name=None,
sc2port_arg="--GamePort",
hostaddress_arg="--LadderServer",
match_arg="--StartPort",
realtime_arg="--RealTime",
other_args: str = None,
stdout: str = None,
):
super().__init__(PlayerType.Participant, race, name=name)
assert Path(path).exists()
self.path = path
self.launch_list = launch_list
self.sc2port_arg = sc2port_arg
self.match_arg = match_arg
self.hostaddress_arg = hostaddress_arg
self.realtime_arg = realtime_arg
self.other_args = other_args
self.stdout = stdout
def __repr__(self):
if self.name is not None:
return f"Bot {self.name}({self.race.name} from {self.launch_list})"
return f"Bot({self.race.name} from {self.launch_list})"
def cmd_line(self,
sc2port: Union[int, str],
matchport: Union[int, str],
hostaddress: str,
realtime: bool = False) -> List[str]:
"""
:param sc2port: the port that the launched sc2 instance listens to
:param matchport: some starting port that both bots use to generate identical portconfigs.
Note: This will not be sent if playing vs computer
:param hostaddress: the address the sc2 instances used
:param realtime: 1 or 0, indicating whether the match is played in realtime or not
:return: string that will be used to start the bot's process
"""
cmd_line = [
*self.launch_list,
self.sc2port_arg,
str(sc2port),
self.hostaddress_arg,
hostaddress,
]
if matchport is not None:
cmd_line.extend([self.match_arg, str(matchport)])
if self.other_args is not None:
cmd_line.append(self.other_args)
if realtime:
cmd_line.extend([self.realtime_arg])
return cmd_line

View File

@@ -0,0 +1,69 @@
import json
import portpicker
class Portconfig:
"""
A data class for ports used by participants to join a match.
EVERY participant joining the match must send the same sets of ports to join successfully.
SC2 needs 2 ports per connection (one for data, one as a 'header'), which is why the ports come in pairs.
:param guests: number of non-hosting participants in a match (i.e. 1 less than the number of participants)
:param server_ports: [int portA, int portB]
:param player_ports: [[int port1A, int port1B], [int port2A, int port2B], ... ]
.shared is deprecated, and should TODO be removed soon (once ladderbots' __init__.py doesnt specify them).
.server contains the pair of ports used by the participant 'hosting' the match
.players contains a pair of ports for every 'guest' (non-hosting participants) in the match
E.g. for 1v1, there will be only 1 guest. For 2v2 (coming soonTM), there would be 3 guests.
"""
def __init__(self, guests=1, server_ports=None, player_ports=None):
self.shared = None
self._picked_ports = []
if server_ports:
self.server = server_ports
else:
self.server = [portpicker.pick_unused_port() for _ in range(2)]
self._picked_ports.extend(self.server)
if player_ports:
self.players = player_ports
else:
self.players = [[portpicker.pick_unused_port() for _ in range(2)] for _ in range(guests)]
self._picked_ports.extend(port for player in self.players for port in player)
def clean(self):
while self._picked_ports:
portpicker.return_port(self._picked_ports.pop())
def __str__(self):
return f"Portconfig(shared={self.shared}, server={self.server}, players={self.players})"
@property
def as_json(self):
return json.dumps({"shared": self.shared, "server": self.server, "players": self.players})
@classmethod
def contiguous_ports(cls, guests=1, attempts=40):
"""Returns a Portconfig with adjacent ports"""
for _ in range(attempts):
start = portpicker.pick_unused_port()
others = [start + j for j in range(1, 2 + guests * 2)]
if all(portpicker.is_port_free(p) for p in others):
server_ports = [start, others.pop(0)]
player_ports = []
while others:
player_ports.append([others.pop(0), others.pop(0)])
pc = cls(server_ports=server_ports, player_ports=player_ports)
pc._picked_ports.append(start)
return pc
raise portpicker.NoFreePortFoundError()
@classmethod
def from_json(cls, json_data):
data = json.loads(json_data)
return cls(server_ports=data["server"], player_ports=data["players"])

View File

@@ -0,0 +1,411 @@
from __future__ import annotations
import itertools
import math
import random
from typing import TYPE_CHECKING, Iterable, List, Set, Tuple, Union
from s2clientprotocol import common_pb2 as common_pb
if TYPE_CHECKING:
from .unit import Unit
from .units import Units
EPSILON = 10**-8
def _sign(num):
return math.copysign(1, num)
class Pointlike(tuple):
@property
def position(self) -> Pointlike:
return self
def distance_to(self, target: Union[Unit, Point2]) -> float:
"""Calculate a single distance from a point or unit to another point or unit
:param target:"""
p = target.position
return math.hypot(self[0] - p[0], self[1] - p[1])
def distance_to_point2(self, p: Union[Point2, Tuple[float, float]]) -> float:
"""Same as the function above, but should be a bit faster because of the dropped asserts
and conversion.
:param p:"""
return math.hypot(self[0] - p[0], self[1] - p[1])
def _distance_squared(self, p2: Point2) -> float:
"""Function used to not take the square root as the distances will stay proportionally the same.
This is to speed up the sorting process.
:param p2:"""
return (self[0] - p2[0])**2 + (self[1] - p2[1])**2
def sort_by_distance(self, ps: Union[Units, Iterable[Point2]]) -> List[Point2]:
"""This returns the target points sorted as list.
You should not pass a set or dict since those are not sortable.
If you want to sort your units towards a point, use 'units.sorted_by_distance_to(point)' instead.
:param ps:"""
return sorted(ps, key=lambda p: self.distance_to_point2(p.position))
def closest(self, ps: Union[Units, Iterable[Point2]]) -> Union[Unit, Point2]:
"""This function assumes the 2d distance is meant
:param ps:"""
assert ps, "ps is empty"
# pylint: disable=W0108
return min(ps, key=lambda p: self.distance_to(p))
def distance_to_closest(self, ps: Union[Units, Iterable[Point2]]) -> float:
"""This function assumes the 2d distance is meant
:param ps:"""
assert ps, "ps is empty"
closest_distance = math.inf
for p2 in ps:
p2 = p2.position
distance = self.distance_to(p2)
if distance <= closest_distance:
closest_distance = distance
return closest_distance
def furthest(self, ps: Union[Units, Iterable[Point2]]) -> Union[Unit, Pointlike]:
"""This function assumes the 2d distance is meant
:param ps: Units object, or iterable of Unit or Point2"""
assert ps, "ps is empty"
# pylint: disable=W0108
return max(ps, key=lambda p: self.distance_to(p))
def distance_to_furthest(self, ps: Union[Units, Iterable[Point2]]) -> float:
"""This function assumes the 2d distance is meant
:param ps:"""
assert ps, "ps is empty"
furthest_distance = -math.inf
for p2 in ps:
p2 = p2.position
distance = self.distance_to(p2)
if distance >= furthest_distance:
furthest_distance = distance
return furthest_distance
def offset(self, p) -> Pointlike:
"""
:param p:
"""
return self.__class__(a + b for a, b in itertools.zip_longest(self, p[:len(self)], fillvalue=0))
def unit_axes_towards(self, p):
"""
:param p:
"""
return self.__class__(_sign(b - a) for a, b in itertools.zip_longest(self, p[:len(self)], fillvalue=0))
def towards(self, p: Union[Unit, Pointlike], distance: Union[int, float] = 1, limit: bool = False) -> Pointlike:
"""
:param p:
:param distance:
:param limit:
"""
p = p.position
# assert self != p, f"self is {self}, p is {p}"
# TODO test and fix this if statement
if self == p:
return self
# end of test
d = self.distance_to(p)
if limit:
distance = min(d, distance)
return self.__class__(
a + (b - a) / d * distance for a, b in itertools.zip_longest(self, p[:len(self)], fillvalue=0)
)
def __eq__(self, other):
try:
return all(abs(a - b) <= EPSILON for a, b in itertools.zip_longest(self, other, fillvalue=0))
except TypeError:
return False
def __hash__(self):
return hash(tuple(self))
# pylint: disable=R0904
class Point2(Pointlike):
@classmethod
def from_proto(cls, data) -> Point2:
"""
:param data:
"""
return cls((data.x, data.y))
@property
def as_Point2D(self) -> common_pb.Point2D:
return common_pb.Point2D(x=self.x, y=self.y)
@property
def as_PointI(self) -> common_pb.PointI:
"""Represents points on the minimap. Values must be between 0 and 64."""
return common_pb.PointI(x=self.x, y=self.y)
@property
def rounded(self) -> Point2:
return Point2((math.floor(self[0]), math.floor(self[1])))
@property
def length(self) -> float:
""" This property exists in case Point2 is used as a vector. """
return math.hypot(self[0], self[1])
@property
def normalized(self) -> Point2:
""" This property exists in case Point2 is used as a vector. """
length = self.length
# Cannot normalize if length is zero
assert length
return self.__class__((self[0] / length, self[1] / length))
@property
def x(self) -> float:
return self[0]
@property
def y(self) -> float:
return self[1]
@property
def to2(self) -> Point2:
return Point2(self[:2])
@property
def to3(self) -> Point3:
return Point3((*self, 0))
def round(self, decimals: int) -> Point2:
"""Rounds each number in the tuple to the amount of given decimals."""
return Point2((round(self[0], decimals), round(self[1], decimals)))
def offset(self, p: Point2) -> Point2:
return Point2((self[0] + p[0], self[1] + p[1]))
def random_on_distance(self, distance) -> Point2:
if isinstance(distance, (tuple, list)): # interval
distance = distance[0] + random.random() * (distance[1] - distance[0])
assert distance > 0, "Distance is not greater than 0"
angle = random.random() * 2 * math.pi
dx, dy = math.cos(angle), math.sin(angle)
return Point2((self.x + dx * distance, self.y + dy * distance))
def towards_with_random_angle(
self,
p: Union[Point2, Point3],
distance: Union[int, float] = 1,
max_difference: Union[int, float] = (math.pi / 4),
) -> Point2:
tx, ty = self.to2.towards(p.to2, 1)
angle = math.atan2(ty - self.y, tx - self.x)
angle = (angle - max_difference) + max_difference * 2 * random.random()
return Point2((self.x + math.cos(angle) * distance, self.y + math.sin(angle) * distance))
def circle_intersection(self, p: Point2, r: Union[int, float]) -> Set[Point2]:
"""self is point1, p is point2, r is the radius for circles originating in both points
Used in ramp finding
:param p:
:param r:"""
assert self != p, "self is equal to p"
distanceBetweenPoints = self.distance_to(p)
assert r >= distanceBetweenPoints / 2
# remaining distance from center towards the intersection, using pythagoras
remainingDistanceFromCenter = (r**2 - (distanceBetweenPoints / 2)**2)**0.5
# center of both points
offsetToCenter = Point2(((p.x - self.x) / 2, (p.y - self.y) / 2))
center = self.offset(offsetToCenter)
# stretch offset vector in the ratio of remaining distance from center to intersection
vectorStretchFactor = remainingDistanceFromCenter / (distanceBetweenPoints / 2)
v = offsetToCenter
offsetToCenterStretched = Point2((v.x * vectorStretchFactor, v.y * vectorStretchFactor))
# rotate vector by 90° and -90°
vectorRotated1 = Point2((offsetToCenterStretched.y, -offsetToCenterStretched.x))
vectorRotated2 = Point2((-offsetToCenterStretched.y, offsetToCenterStretched.x))
intersect1 = center.offset(vectorRotated1)
intersect2 = center.offset(vectorRotated2)
return {intersect1, intersect2}
@property
def neighbors4(self) -> set:
return {
Point2((self.x - 1, self.y)),
Point2((self.x + 1, self.y)),
Point2((self.x, self.y - 1)),
Point2((self.x, self.y + 1)),
}
@property
def neighbors8(self) -> set:
return self.neighbors4 | {
Point2((self.x - 1, self.y - 1)),
Point2((self.x - 1, self.y + 1)),
Point2((self.x + 1, self.y - 1)),
Point2((self.x + 1, self.y + 1)),
}
def negative_offset(self, other: Point2) -> Point2:
return self.__class__((self[0] - other[0], self[1] - other[1]))
def __add__(self, other: Point2) -> Point2:
return self.offset(other)
def __sub__(self, other: Point2) -> Point2:
return self.negative_offset(other)
def __neg__(self) -> Point2:
return self.__class__(-a for a in self)
def __abs__(self) -> float:
return math.hypot(self.x, self.y)
def __bool__(self) -> bool:
if self.x != 0 or self.y != 0:
return True
return False
def __mul__(self, other: Union[int, float, Point2]) -> Point2:
try:
return self.__class__((self.x * other.x, self.y * other.y))
except AttributeError:
return self.__class__((self.x * other, self.y * other))
def __rmul__(self, other: Union[int, float, Point2]) -> Point2:
return self.__mul__(other)
def __truediv__(self, other: Union[int, float, Point2]) -> Point2:
if isinstance(other, self.__class__):
return self.__class__((self.x / other.x, self.y / other.y))
return self.__class__((self.x / other, self.y / other))
def is_same_as(self, other: Point2, dist=0.001) -> bool:
return self.distance_to_point2(other) <= dist
def direction_vector(self, other: Point2) -> Point2:
""" Converts a vector to a direction that can face vertically, horizontally or diagonal or be zero, e.g. (0, 0), (1, -1), (1, 0) """
return self.__class__((_sign(other.x - self.x), _sign(other.y - self.y)))
def manhattan_distance(self, other: Point2) -> float:
"""
:param other:
"""
return abs(other.x - self.x) + abs(other.y - self.y)
@staticmethod
def center(points: List[Point2]) -> Point2:
"""Returns the central point for points in list
:param points:"""
s = Point2((0, 0))
for p in points:
s += p
return s / len(points)
class Point3(Point2):
@classmethod
def from_proto(cls, data) -> Point3:
"""
:param data:
"""
return cls((data.x, data.y, data.z))
@property
def as_Point(self) -> common_pb.Point:
return common_pb.Point(x=self.x, y=self.y, z=self.z)
@property
def rounded(self) -> Point3:
return Point3((math.floor(self[0]), math.floor(self[1]), math.floor(self[2])))
@property
def z(self) -> float:
return self[2]
@property
def to3(self) -> Point3:
return Point3(self)
def __add__(self, other: Union[Point2, Point3]) -> Point3:
if not isinstance(other, Point3) and isinstance(other, Point2):
return Point3((self.x + other.x, self.y + other.y, self.z))
return Point3((self.x + other.x, self.y + other.y, self.z + other.z))
class Size(Point2):
@property
def width(self) -> float:
return self[0]
@property
def height(self) -> float:
return self[1]
class Rect(tuple):
@classmethod
def from_proto(cls, data):
"""
:param data:
"""
assert data.p0.x < data.p1.x and data.p0.y < data.p1.y
return cls((data.p0.x, data.p0.y, data.p1.x - data.p0.x, data.p1.y - data.p0.y))
@property
def x(self) -> float:
return self[0]
@property
def y(self) -> float:
return self[1]
@property
def width(self) -> float:
return self[2]
@property
def height(self) -> float:
return self[3]
@property
def right(self) -> float:
""" Returns the x-coordinate of the rectangle of its right side. """
return self.x + self.width
@property
def top(self) -> float:
""" Returns the y-coordinate of the rectangle of its top side. """
return self.y + self.height
@property
def size(self) -> Size:
return Size((self[2], self[3]))
@property
def center(self) -> Point2:
return Point2((self.x + self.width / 2, self.y + self.height / 2))
def offset(self, p):
return self.__class__((self[0] + p[0], self[1] + p[1], self[2], self[3]))

View File

@@ -0,0 +1,36 @@
from dataclasses import dataclass
from typing import List
from .position import Point2
@dataclass
class PowerSource:
position: Point2
radius: float
unit_tag: int
def __post_init__(self):
assert self.radius > 0
@classmethod
def from_proto(cls, proto):
return PowerSource(Point2.from_proto(proto.pos), proto.radius, proto.tag)
def covers(self, position: Point2) -> bool:
return self.position.distance_to(position) <= self.radius
def __repr__(self):
return f"PowerSource({self.position}, {self.radius})"
@dataclass
class PsionicMatrix:
sources: List[PowerSource]
@classmethod
def from_proto(cls, proto):
return PsionicMatrix([PowerSource.from_proto(p) for p in proto])
def covers(self, position: Point2) -> bool:
return any(source.covers(position) for source in self.sources)

View File

@@ -0,0 +1,87 @@
import asyncio
import sys
from contextlib import suppress
from aiohttp import ClientWebSocketResponse
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .data import Status
class ProtocolError(Exception):
@property
def is_game_over_error(self) -> bool:
return self.args[0] in ["['Game has already ended']", "['Not supported if game has already ended']"]
class ConnectionAlreadyClosed(ProtocolError):
pass
class Protocol:
def __init__(self, ws):
"""
A class for communicating with an SCII application.
:param ws: the websocket (type: aiohttp.ClientWebSocketResponse) used to communicate with a specific SCII app
"""
assert ws
self._ws: ClientWebSocketResponse = ws
self._status: Status = None
async def __request(self, request):
logger.debug(f"Sending request: {request !r}")
try:
await self._ws.send_bytes(request.SerializeToString())
except TypeError as exc:
logger.exception("Cannot send: Connection already closed.")
raise ConnectionAlreadyClosed("Connection already closed.") from exc
logger.debug("Request sent")
response = sc_pb.Response()
try:
response_bytes = await self._ws.receive_bytes()
except TypeError as exc:
if self._status == Status.ended:
logger.info("Cannot receive: Game has already ended.")
raise ConnectionAlreadyClosed("Game has already ended") from exc
logger.error("Cannot receive: Connection already closed.")
raise ConnectionAlreadyClosed("Connection already closed.") from exc
except asyncio.CancelledError:
# If request is sent, the response must be received before reraising cancel
try:
await self._ws.receive_bytes()
except asyncio.CancelledError:
logger.critical("Requests must not be cancelled multiple times")
sys.exit(2)
raise
response.ParseFromString(response_bytes)
logger.debug("Response received")
return response
async def _execute(self, **kwargs):
assert len(kwargs) == 1, "Only one request allowed by the API"
response = await self.__request(sc_pb.Request(**kwargs))
new_status = Status(response.status)
if new_status != self._status:
logger.info(f"Client status changed to {new_status} (was {self._status})")
self._status = new_status
if response.error:
logger.debug(f"Response contained an error: {response.error}")
raise ProtocolError(f"{response.error}")
return response
async def ping(self):
result = await self._execute(ping=sc_pb.RequestPing())
return result
async def quit(self):
with suppress(ConnectionAlreadyClosed, ConnectionResetError):
await self._execute(quit=sc_pb.RequestQuit())

View File

@@ -0,0 +1,233 @@
# pylint: disable=W0212
import asyncio
import os
import platform
import subprocess
import time
import traceback
from aiohttp import WSMsgType, web
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .controller import Controller
from .data import Result, Status
from .player import BotProcess
class Proxy:
"""
Class for handling communication between sc2 and an external bot.
This "middleman" is needed for enforcing time limits, collecting results, and closing things properly.
"""
def __init__(
self,
controller: Controller,
player: BotProcess,
proxyport: int,
game_time_limit: int = None,
realtime: bool = False,
):
self.controller = controller
self.player = player
self.port = proxyport
self.timeout_loop = game_time_limit * 22.4 if game_time_limit else None
self.realtime = realtime
logger.debug(
f"Proxy Inited with ctrl {controller}({controller._process._port}), player {player}, proxyport {proxyport}, lim {game_time_limit}"
)
self.result = None
self.player_id: int = None
self.done = False
async def parse_request(self, msg):
request = sc_pb.Request()
request.ParseFromString(msg.data)
if request.HasField("quit"):
request = sc_pb.Request(leave_game=sc_pb.RequestLeaveGame())
if request.HasField("leave_game"):
if self.controller._status == Status.in_game:
logger.info(f"Proxy: player {self.player.name}({self.player_id}) surrenders")
self.result = {self.player_id: Result.Defeat}
elif self.controller._status == Status.ended:
await self.get_response()
elif request.HasField("join_game") and not request.join_game.HasField("player_name"):
request.join_game.player_name = self.player.name
await self.controller._ws.send_bytes(request.SerializeToString())
# TODO Catching too general exception Exception (broad-except)
# pylint: disable=W0703
async def get_response(self):
response_bytes = None
try:
response_bytes = await self.controller._ws.receive_bytes()
except TypeError as e:
logger.exception("Cannot receive: SC2 Connection already closed.")
tb = traceback.format_exc()
logger.error(f"Exception {e}: {tb}")
except asyncio.CancelledError:
logger.info(f"Proxy({self.player.name}), caught receive from sc2")
try:
x = await self.controller._ws.receive_bytes()
if response_bytes is None:
response_bytes = x
except (asyncio.CancelledError, asyncio.TimeoutError, Exception) as e:
logger.exception(f"Exception {e}")
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
return response_bytes
async def parse_response(self, response_bytes):
response = sc_pb.Response()
response.ParseFromString(response_bytes)
if not response.HasField("status"):
logger.critical("Proxy: RESPONSE HAS NO STATUS {response}")
else:
new_status = Status(response.status)
if new_status != self.controller._status:
logger.info(f"Controller({self.player.name}): {self.controller._status}->{new_status}")
self.controller._status = new_status
if self.player_id is None:
if response.HasField("join_game"):
self.player_id = response.join_game.player_id
logger.info(f"Proxy({self.player.name}): got join_game for {self.player_id}")
if self.result is None:
if response.HasField("observation"):
obs: sc_pb.ResponseObservation = response.observation
if obs.player_result:
self.result = {pr.player_id: Result(pr.result) for pr in obs.player_result}
elif (
self.timeout_loop and obs.HasField("observation") and obs.observation.game_loop > self.timeout_loop
):
self.result = {i: Result.Tie for i in range(1, 3)}
logger.info(f"Proxy({self.player.name}) timing out")
act = [sc_pb.Action(action_chat=sc_pb.ActionChat(message="Proxy: Timing out"))]
await self.controller._execute(action=sc_pb.RequestAction(actions=act))
return response
async def get_result(self):
try:
res = await self.controller.ping()
if res.status in {Status.in_game, Status.in_replay, Status.ended}:
res = await self.controller._execute(observation=sc_pb.RequestObservation())
if res.HasField("observation") and res.observation.player_result:
self.result = {pr.player_id: Result(pr.result) for pr in res.observation.player_result}
# pylint: disable=W0703
# TODO Catching too general exception Exception (broad-except)
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
async def proxy_handler(self, request):
bot_ws = web.WebSocketResponse(receive_timeout=30)
await bot_ws.prepare(request)
try:
async for msg in bot_ws:
if msg.data is None:
raise TypeError(f"data is None, {msg}")
if msg.data and msg.type == WSMsgType.BINARY:
await self.parse_request(msg)
response_bytes = await self.get_response()
if response_bytes is None:
raise ConnectionError("Could not get response_bytes")
new_response = await self.parse_response(response_bytes)
await bot_ws.send_bytes(new_response.SerializeToString())
elif msg.type == WSMsgType.CLOSED:
logger.error("Client shutdown")
else:
logger.error("Incorrect message type")
# pylint: disable=W0703
# TODO Catching too general exception Exception (broad-except)
except Exception as e:
logger.exception(f"Caught unknown exception: {e}")
ignored_errors = {ConnectionError, asyncio.CancelledError}
if not any(isinstance(e, E) for E in ignored_errors):
tb = traceback.format_exc()
logger.info(f"Proxy({self.player.name}): Caught {e} traceback: {tb}")
finally:
try:
if self.controller._status in {Status.in_game, Status.in_replay}:
await self.controller._execute(leave_game=sc_pb.RequestLeaveGame())
await bot_ws.close()
# pylint: disable=W0703
# TODO Catching too general exception Exception (broad-except)
except Exception as e:
logger.exception(f"Caught unknown exception during surrender: {e}")
self.done = True
return bot_ws
# pylint: disable=R0912
async def play_with_proxy(self, startport):
logger.info(f"Proxy({self.port}): Starting app")
app = web.Application()
app.router.add_route("GET", "/sc2api", self.proxy_handler)
apprunner = web.AppRunner(app, access_log=None)
await apprunner.setup()
appsite = web.TCPSite(apprunner, self.controller._process._host, self.port)
await appsite.start()
subproc_args = {"cwd": str(self.player.path), "stderr": subprocess.STDOUT}
if platform.system() == "Linux":
subproc_args["preexec_fn"] = os.setpgrp
elif platform.system() == "Windows":
subproc_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
player_command_line = self.player.cmd_line(self.port, startport, self.controller._process._host, self.realtime)
logger.info(f"Starting bot with command: {' '.join(player_command_line)}")
if self.player.stdout is None:
bot_process = subprocess.Popen(player_command_line, stdout=subprocess.DEVNULL, **subproc_args)
else:
with open(self.player.stdout, "w+") as out:
bot_process = subprocess.Popen(player_command_line, stdout=out, **subproc_args)
while self.result is None:
bot_alive = bot_process and bot_process.poll() is None
sc2_alive = self.controller.running
if self.done or not (bot_alive and sc2_alive):
logger.info(
f"Proxy({self.port}): {self.player.name} died, "
f"bot{(not bot_alive) * ' not'} alive, sc2{(not sc2_alive) * ' not'} alive"
)
# Maybe its still possible to retrieve a result
if sc2_alive and not self.done:
await self.get_response()
logger.info(f"Proxy({self.port}): breaking, result {self.result}")
break
await asyncio.sleep(5)
# cleanup
logger.info(f"({self.port}): cleaning up {self.player !r}")
for _i in range(3):
if isinstance(bot_process, subprocess.Popen):
if bot_process.stdout and not bot_process.stdout.closed: # should not run anymore
logger.info(f"==================output for player {self.player.name}")
for l in bot_process.stdout.readlines():
logger.opt(raw=True).info(l.decode("utf-8"))
bot_process.stdout.close()
logger.info("==================")
bot_process.terminate()
bot_process.wait()
time.sleep(0.5)
if not bot_process or bot_process.poll() is not None:
break
else:
bot_process.terminate()
bot_process.wait()
try:
await apprunner.cleanup()
# pylint: disable=W0703
# TODO Catching too general exception Exception (broad-except)
except Exception as e:
logger.exception(f"Caught unknown exception during cleaning: {e}")
if isinstance(self.result, dict):
self.result[None] = None
return self.result[self.player_id]
return self.result

View File

@@ -0,0 +1,154 @@
import datetime
from s2clientprotocol import score_pb2 as score_pb
from .position import Point2
class Renderer:
def __init__(self, client, map_size, minimap_size):
self._client = client
self._window = None
self._map_size = map_size
self._map_image = None
self._minimap_size = minimap_size
self._minimap_image = None
self._mouse_x, self._mouse_y = None, None
self._text_supply = None
self._text_vespene = None
self._text_minerals = None
self._text_score = None
self._text_time = None
async def render(self, observation):
render_data = observation.observation.render_data
map_size = render_data.map.size
map_data = render_data.map.data
minimap_size = render_data.minimap.size
minimap_data = render_data.minimap.data
map_width, map_height = map_size.x, map_size.y
map_pitch = -map_width * 3
minimap_width, minimap_height = minimap_size.x, minimap_size.y
minimap_pitch = -minimap_width * 3
if not self._window:
# pylint: disable=C0415
from pyglet.image import ImageData
from pyglet.text import Label
from pyglet.window import Window
self._window = Window(width=map_width, height=map_height)
self._window.on_mouse_press = self._on_mouse_press
self._window.on_mouse_release = self._on_mouse_release
self._window.on_mouse_drag = self._on_mouse_drag
self._map_image = ImageData(map_width, map_height, "RGB", map_data, map_pitch)
self._minimap_image = ImageData(minimap_width, minimap_height, "RGB", minimap_data, minimap_pitch)
self._text_supply = Label(
"",
font_name="Arial",
font_size=16,
anchor_x="right",
anchor_y="top",
x=self._map_size[0] - 10,
y=self._map_size[1] - 10,
color=(200, 200, 200, 255),
)
self._text_vespene = Label(
"",
font_name="Arial",
font_size=16,
anchor_x="right",
anchor_y="top",
x=self._map_size[0] - 130,
y=self._map_size[1] - 10,
color=(28, 160, 16, 255),
)
self._text_minerals = Label(
"",
font_name="Arial",
font_size=16,
anchor_x="right",
anchor_y="top",
x=self._map_size[0] - 200,
y=self._map_size[1] - 10,
color=(68, 140, 255, 255),
)
self._text_score = Label(
"",
font_name="Arial",
font_size=16,
anchor_x="left",
anchor_y="top",
x=10,
y=self._map_size[1] - 10,
color=(219, 30, 30, 255),
)
self._text_time = Label(
"",
font_name="Arial",
font_size=16,
anchor_x="right",
anchor_y="bottom",
x=self._minimap_size[0] - 10,
y=self._minimap_size[1] + 10,
color=(255, 255, 255, 255),
)
else:
self._map_image.set_data("RGB", map_pitch, map_data)
self._minimap_image.set_data("RGB", minimap_pitch, minimap_data)
self._text_time.text = str(datetime.timedelta(seconds=(observation.observation.game_loop * 0.725) // 16))
if observation.observation.HasField("player_common"):
self._text_supply.text = f"{observation.observation.player_common.food_used} / {observation.observation.player_common.food_cap}"
self._text_vespene.text = str(observation.observation.player_common.vespene)
self._text_minerals.text = str(observation.observation.player_common.minerals)
if observation.observation.HasField("score"):
# pylint: disable=W0212
self._text_score.text = f"{score_pb._SCORE_SCORETYPE.values_by_number[observation.observation.score.score_type].name} score: {observation.observation.score.score}"
await self._update_window()
if self._client.in_game and (not observation.player_result) and self._mouse_x and self._mouse_y:
await self._client.move_camera_spatial(Point2((self._mouse_x, self._minimap_size[0] - self._mouse_y)))
self._mouse_x, self._mouse_y = None, None
async def _update_window(self):
self._window.switch_to()
self._window.dispatch_events()
self._window.clear()
self._map_image.blit(0, 0)
self._minimap_image.blit(0, 0)
self._text_time.draw()
self._text_score.draw()
self._text_minerals.draw()
self._text_vespene.draw()
self._text_supply.draw()
self._window.flip()
def _on_mouse_press(self, x, y, button, _modifiers):
if button != 1: # 1: mouse.LEFT
return
if x > self._minimap_size[0] or y > self._minimap_size[1]:
return
self._mouse_x, self._mouse_y = x, y
def _on_mouse_release(self, x, y, button, _modifiers):
if button != 1: # 1: mouse.LEFT
return
if x > self._minimap_size[0] or y > self._minimap_size[1]:
return
self._mouse_x, self._mouse_y = x, y
def _on_mouse_drag(self, x, y, _dx, _dy, buttons, _modifiers):
if not buttons & 1: # 1: mouse.LEFT
return
if x > self._minimap_size[0] or y > self._minimap_size[1]:
return
self._mouse_x, self._mouse_y = x, y

View File

@@ -0,0 +1,275 @@
import asyncio
import os
import os.path
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from contextlib import suppress
from typing import Any, Dict, List, Optional, Tuple, Union
import aiohttp
import portpicker
from worlds._sc2common.bot import logger
from . import paths, wsl
from .controller import Controller
from .paths import Paths
from .versions import VERSIONS
class kill_switch:
_to_kill: List[Any] = []
@classmethod
def add(cls, value):
logger.debug("kill_switch: Add switch")
cls._to_kill.append(value)
@classmethod
def kill_all(cls):
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
for p in cls._to_kill:
# pylint: disable=W0212
p._clean(verbose=False)
class SC2Process:
"""
A class for handling SCII applications.
:param host: hostname for the url the SCII application will listen to
:param port: the websocket port the SCII application will listen to
:param fullscreen: whether to launch the SCII application in fullscreen or not, defaults to False
:param resolution: (window width, window height) in pixels, defaults to (1024, 768)
:param placement: (x, y) the distances of the SCII app's top left corner from the top left corner of the screen
e.g. (20, 30) is 20 to the right of the screen's left border, and 30 below the top border
:param render:
:param sc2_version:
:param base_build:
:param data_hash:
"""
def __init__(
self,
host: Optional[str] = None,
port: Optional[int] = None,
fullscreen: bool = False,
resolution: Optional[Union[List[int], Tuple[int, int]]] = None,
placement: Optional[Union[List[int], Tuple[int, int]]] = None,
render: bool = False,
sc2_version: str = None,
base_build: str = None,
data_hash: str = None,
) -> None:
assert isinstance(host, str) or host is None
assert isinstance(port, int) or port is None
self._render = render
self._arguments: Dict[str, str] = {"-displayMode": str(int(fullscreen))}
if not fullscreen:
if resolution and len(resolution) == 2:
self._arguments["-windowwidth"] = str(resolution[0])
self._arguments["-windowheight"] = str(resolution[1])
if placement and len(placement) == 2:
self._arguments["-windowx"] = str(placement[0])
self._arguments["-windowy"] = str(placement[1])
self._host = host or os.environ.get("SC2CLIENTHOST", "127.0.0.1")
self._serverhost = os.environ.get("SC2SERVERHOST", self._host)
if port is None:
self._port = portpicker.pick_unused_port()
else:
self._port = port
self._used_portpicker = bool(port is None)
self._tmp_dir = tempfile.mkdtemp(prefix="SC2_")
self._process: subprocess = None
self._session = None
self._ws = None
self._sc2_version = sc2_version
self._base_build = base_build
self._data_hash = data_hash
async def __aenter__(self) -> Controller:
kill_switch.add(self)
def signal_handler(*_args):
# unused arguments: signal handling library expects all signal
# callback handlers to accept two positional arguments
kill_switch.kill_all()
signal.signal(signal.SIGINT, signal_handler)
try:
self._process = self._launch()
self._ws = await self._connect()
except:
await self._close_connection()
self._clean()
raise
return Controller(self._ws, self)
async def __aexit__(self, *args):
logger.exception("async exit")
await self._close_connection()
kill_switch.kill_all()
signal.signal(signal.SIGINT, signal.SIG_DFL)
@property
def ws_url(self):
return f"ws://{self._host}:{self._port}/sc2api"
@property
def versions(self):
"""Opens the versions.json file which origins from
https://github.com/Blizzard/s2client-proto/blob/master/buildinfo/versions.json"""
return VERSIONS
def find_data_hash(self, target_sc2_version: str) -> Optional[str]:
""" Returns the data hash from the matching version string. """
version: dict
for version in self.versions:
if version["label"] == target_sc2_version:
return version["data-hash"]
return None
def _launch(self):
if self._base_build:
executable = str(paths.latest_executeble(Paths.BASE / "Versions", self._base_build))
else:
executable = str(Paths.EXECUTABLE)
if self._port is None:
self._port = portpicker.pick_unused_port()
self._used_portpicker = True
args = paths.get_runner_args(Paths.CWD) + [
executable,
"-listen",
self._serverhost,
"-port",
str(self._port),
"-dataDir",
str(Paths.BASE),
"-tempDir",
self._tmp_dir,
]
for arg, value in self._arguments.items():
args.append(arg)
args.append(value)
if self._sc2_version:
def special_match(strg: str):
""" Tests if the specified version is in the versions.py dict. """
for version in self.versions:
if version["label"] == strg:
return True
return False
valid_version_string = special_match(self._sc2_version)
if valid_version_string:
self._data_hash = self.find_data_hash(self._sc2_version)
assert (
self._data_hash is not None
), f"StarCraft 2 Client version ({self._sc2_version}) was not found inside sc2/versions.py file. Please check your spelling or check the versions.py file."
else:
logger.warning(
f'The submitted version string in sc2.rungame() function call (sc2_version="{self._sc2_version}") was not found in versions.py. Running latest version instead.'
)
if self._data_hash:
args.extend(["-dataVersion", self._data_hash])
if self._render:
args.extend(["-eglpath", "libEGL.so"])
# if logger.getEffectiveLevel() <= logging.DEBUG:
args.append("-verbose")
sc2_cwd = str(Paths.CWD) if Paths.CWD else None
if paths.PF in {"WSL1", "WSL2"}:
return wsl.run(args, sc2_cwd)
return subprocess.Popen(
args,
cwd=sc2_cwd,
# Suppress Wine error messages
stderr=subprocess.DEVNULL
# , env=run_config.env
)
async def _connect(self):
# How long it waits for SC2 to start (in seconds)
for i in range(180):
if self._process is None:
# The ._clean() was called, clearing the process
logger.debug("Process cleanup complete, exit")
sys.exit()
await asyncio.sleep(1)
try:
self._session = aiohttp.ClientSession()
ws = await self._session.ws_connect(self.ws_url, timeout=120)
# FIXME fix deprecation warning in for future aiohttp version
# ws = await self._session.ws_connect(
# self.ws_url, timeout=aiohttp.client_ws.ClientWSTimeout(ws_close=120)
# )
logger.debug("Websocket connection ready")
return ws
except aiohttp.client_exceptions.ClientConnectorError:
await self._session.close()
if i > 15:
logger.debug("Connection refused (startup not complete (yet))")
logger.debug("Websocket connection to SC2 process timed out")
raise TimeoutError("Websocket")
async def _close_connection(self):
logger.info(f"Closing connection at {self._port}...")
if self._ws is not None:
await self._ws.close()
if self._session is not None:
await self._session.close()
# pylint: disable=R0912
def _clean(self, verbose=True):
if verbose:
logger.info("Cleaning up...")
if self._process is not None:
if paths.PF in {"WSL1", "WSL2"}:
if wsl.kill(self._process):
logger.error("KILLED")
elif self._process.poll() is None:
for _ in range(3):
self._process.terminate()
time.sleep(0.5)
if not self._process or self._process.poll() is not None:
break
else:
self._process.kill()
self._process.wait()
logger.error("KILLED")
# Try to kill wineserver on linux
if paths.PF in {"Linux", "WineLinux"}:
# Command wineserver not detected
with suppress(FileNotFoundError):
with subprocess.Popen(["wineserver", "-k"]) as p:
p.wait()
if os.path.exists(self._tmp_dir):
shutil.rmtree(self._tmp_dir)
self._process = None
self._ws = None
if self._used_portpicker and self._port is not None:
portpicker.return_port(self._port)
self._port = None
if verbose:
logger.info("Cleanup complete")

View File

@@ -0,0 +1,424 @@
# pylint: disable=R0904
class ScoreDetails:
"""Accessable in self.state.score during step function
For more information, see https://github.com/Blizzard/s2client-proto/blob/master/s2clientprotocol/score.proto
"""
def __init__(self, proto):
self._data = proto
self._proto = proto.score_details
@property
def summary(self):
"""
TODO this is super ugly, how can we improve this summary?
Print summary to file with:
In on_step:
with open("stats.txt", "w+") as file:
for stat in self.state.score.summary:
file.write(f"{stat[0]:<35} {float(stat[1]):>35.3f}\n")
"""
values = [
"score_type",
"score",
"idle_production_time",
"idle_worker_time",
"total_value_units",
"total_value_structures",
"killed_value_units",
"killed_value_structures",
"collected_minerals",
"collected_vespene",
"collection_rate_minerals",
"collection_rate_vespene",
"spent_minerals",
"spent_vespene",
"food_used_none",
"food_used_army",
"food_used_economy",
"food_used_technology",
"food_used_upgrade",
"killed_minerals_none",
"killed_minerals_army",
"killed_minerals_economy",
"killed_minerals_technology",
"killed_minerals_upgrade",
"killed_vespene_none",
"killed_vespene_army",
"killed_vespene_economy",
"killed_vespene_technology",
"killed_vespene_upgrade",
"lost_minerals_none",
"lost_minerals_army",
"lost_minerals_economy",
"lost_minerals_technology",
"lost_minerals_upgrade",
"lost_vespene_none",
"lost_vespene_army",
"lost_vespene_economy",
"lost_vespene_technology",
"lost_vespene_upgrade",
"friendly_fire_minerals_none",
"friendly_fire_minerals_army",
"friendly_fire_minerals_economy",
"friendly_fire_minerals_technology",
"friendly_fire_minerals_upgrade",
"friendly_fire_vespene_none",
"friendly_fire_vespene_army",
"friendly_fire_vespene_economy",
"friendly_fire_vespene_technology",
"friendly_fire_vespene_upgrade",
"used_minerals_none",
"used_minerals_army",
"used_minerals_economy",
"used_minerals_technology",
"used_minerals_upgrade",
"used_vespene_none",
"used_vespene_army",
"used_vespene_economy",
"used_vespene_technology",
"used_vespene_upgrade",
"total_used_minerals_none",
"total_used_minerals_army",
"total_used_minerals_economy",
"total_used_minerals_technology",
"total_used_minerals_upgrade",
"total_used_vespene_none",
"total_used_vespene_army",
"total_used_vespene_economy",
"total_used_vespene_technology",
"total_used_vespene_upgrade",
"total_damage_dealt_life",
"total_damage_dealt_shields",
"total_damage_dealt_energy",
"total_damage_taken_life",
"total_damage_taken_shields",
"total_damage_taken_energy",
"total_healed_life",
"total_healed_shields",
"total_healed_energy",
"current_apm",
"current_effective_apm",
]
return [[value, getattr(self, value)] for value in values]
@property
def score_type(self):
return self._data.score_type
@property
def score(self):
return self._data.score
@property
def idle_production_time(self):
return self._proto.idle_production_time
@property
def idle_worker_time(self):
return self._proto.idle_worker_time
@property
def total_value_units(self):
return self._proto.total_value_units
@property
def total_value_structures(self):
return self._proto.total_value_structures
@property
def killed_value_units(self):
return self._proto.killed_value_units
@property
def killed_value_structures(self):
return self._proto.killed_value_structures
@property
def collected_minerals(self):
return self._proto.collected_minerals
@property
def collected_vespene(self):
return self._proto.collected_vespene
@property
def collection_rate_minerals(self):
return self._proto.collection_rate_minerals
@property
def collection_rate_vespene(self):
return self._proto.collection_rate_vespene
@property
def spent_minerals(self):
return self._proto.spent_minerals
@property
def spent_vespene(self):
return self._proto.spent_vespene
@property
def food_used_none(self):
return self._proto.food_used.none
@property
def food_used_army(self):
return self._proto.food_used.army
@property
def food_used_economy(self):
return self._proto.food_used.economy
@property
def food_used_technology(self):
return self._proto.food_used.technology
@property
def food_used_upgrade(self):
return self._proto.food_used.upgrade
@property
def killed_minerals_none(self):
return self._proto.killed_minerals.none
@property
def killed_minerals_army(self):
return self._proto.killed_minerals.army
@property
def killed_minerals_economy(self):
return self._proto.killed_minerals.economy
@property
def killed_minerals_technology(self):
return self._proto.killed_minerals.technology
@property
def killed_minerals_upgrade(self):
return self._proto.killed_minerals.upgrade
@property
def killed_vespene_none(self):
return self._proto.killed_vespene.none
@property
def killed_vespene_army(self):
return self._proto.killed_vespene.army
@property
def killed_vespene_economy(self):
return self._proto.killed_vespene.economy
@property
def killed_vespene_technology(self):
return self._proto.killed_vespene.technology
@property
def killed_vespene_upgrade(self):
return self._proto.killed_vespene.upgrade
@property
def lost_minerals_none(self):
return self._proto.lost_minerals.none
@property
def lost_minerals_army(self):
return self._proto.lost_minerals.army
@property
def lost_minerals_economy(self):
return self._proto.lost_minerals.economy
@property
def lost_minerals_technology(self):
return self._proto.lost_minerals.technology
@property
def lost_minerals_upgrade(self):
return self._proto.lost_minerals.upgrade
@property
def lost_vespene_none(self):
return self._proto.lost_vespene.none
@property
def lost_vespene_army(self):
return self._proto.lost_vespene.army
@property
def lost_vespene_economy(self):
return self._proto.lost_vespene.economy
@property
def lost_vespene_technology(self):
return self._proto.lost_vespene.technology
@property
def lost_vespene_upgrade(self):
return self._proto.lost_vespene.upgrade
@property
def friendly_fire_minerals_none(self):
return self._proto.friendly_fire_minerals.none
@property
def friendly_fire_minerals_army(self):
return self._proto.friendly_fire_minerals.army
@property
def friendly_fire_minerals_economy(self):
return self._proto.friendly_fire_minerals.economy
@property
def friendly_fire_minerals_technology(self):
return self._proto.friendly_fire_minerals.technology
@property
def friendly_fire_minerals_upgrade(self):
return self._proto.friendly_fire_minerals.upgrade
@property
def friendly_fire_vespene_none(self):
return self._proto.friendly_fire_vespene.none
@property
def friendly_fire_vespene_army(self):
return self._proto.friendly_fire_vespene.army
@property
def friendly_fire_vespene_economy(self):
return self._proto.friendly_fire_vespene.economy
@property
def friendly_fire_vespene_technology(self):
return self._proto.friendly_fire_vespene.technology
@property
def friendly_fire_vespene_upgrade(self):
return self._proto.friendly_fire_vespene.upgrade
@property
def used_minerals_none(self):
return self._proto.used_minerals.none
@property
def used_minerals_army(self):
return self._proto.used_minerals.army
@property
def used_minerals_economy(self):
return self._proto.used_minerals.economy
@property
def used_minerals_technology(self):
return self._proto.used_minerals.technology
@property
def used_minerals_upgrade(self):
return self._proto.used_minerals.upgrade
@property
def used_vespene_none(self):
return self._proto.used_vespene.none
@property
def used_vespene_army(self):
return self._proto.used_vespene.army
@property
def used_vespene_economy(self):
return self._proto.used_vespene.economy
@property
def used_vespene_technology(self):
return self._proto.used_vespene.technology
@property
def used_vespene_upgrade(self):
return self._proto.used_vespene.upgrade
@property
def total_used_minerals_none(self):
return self._proto.total_used_minerals.none
@property
def total_used_minerals_army(self):
return self._proto.total_used_minerals.army
@property
def total_used_minerals_economy(self):
return self._proto.total_used_minerals.economy
@property
def total_used_minerals_technology(self):
return self._proto.total_used_minerals.technology
@property
def total_used_minerals_upgrade(self):
return self._proto.total_used_minerals.upgrade
@property
def total_used_vespene_none(self):
return self._proto.total_used_vespene.none
@property
def total_used_vespene_army(self):
return self._proto.total_used_vespene.army
@property
def total_used_vespene_economy(self):
return self._proto.total_used_vespene.economy
@property
def total_used_vespene_technology(self):
return self._proto.total_used_vespene.technology
@property
def total_used_vespene_upgrade(self):
return self._proto.total_used_vespene.upgrade
@property
def total_damage_dealt_life(self):
return self._proto.total_damage_dealt.life
@property
def total_damage_dealt_shields(self):
return self._proto.total_damage_dealt.shields
@property
def total_damage_dealt_energy(self):
return self._proto.total_damage_dealt.energy
@property
def total_damage_taken_life(self):
return self._proto.total_damage_taken.life
@property
def total_damage_taken_shields(self):
return self._proto.total_damage_taken.shields
@property
def total_damage_taken_energy(self):
return self._proto.total_damage_taken.energy
@property
def total_healed_life(self):
return self._proto.total_healed.life
@property
def total_healed_shields(self):
return self._proto.total_healed.shields
@property
def total_healed_energy(self):
return self._proto.total_healed.energy
@property
def current_apm(self):
return self._proto.current_apm
@property
def current_effective_apm(self):
return self._proto.current_effective_apm

View File

@@ -0,0 +1,692 @@
# pylint: disable=W0212
from __future__ import annotations
import math
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple, Union
from .cache import CacheDict
from .constants import (
CAN_BE_ATTACKED,
IS_ARMORED,
IS_BIOLOGICAL,
IS_CLOAKED,
IS_ENEMY,
IS_LIGHT,
IS_MASSIVE,
IS_MECHANICAL,
IS_MINE,
IS_PLACEHOLDER,
IS_PSIONIC,
IS_REVEALED,
IS_SNAPSHOT,
IS_STRUCTURE,
IS_VISIBLE,
)
from .data import Alliance, Attribute, CloakState, Race
from .position import Point2, Point3
if TYPE_CHECKING:
from .bot_ai import BotAI
from .game_data import AbilityData, UnitTypeData
@dataclass
class RallyTarget:
point: Point2
tag: Optional[int] = None
@classmethod
def from_proto(cls, proto: Any) -> RallyTarget:
return cls(
Point2.from_proto(proto.point),
proto.tag if proto.HasField("tag") else None,
)
@dataclass
class UnitOrder:
ability: AbilityData # TODO: Should this be AbilityId instead?
target: Optional[Union[int, Point2]] = None
progress: float = 0
@classmethod
def from_proto(cls, proto: Any, bot_object: BotAI) -> UnitOrder:
target: Optional[Union[int, Point2]] = proto.target_unit_tag
if proto.HasField("target_world_space_pos"):
target = Point2.from_proto(proto.target_world_space_pos)
elif proto.HasField("target_unit_tag"):
target = proto.target_unit_tag
return cls(
ability=bot_object.game_data.abilities[proto.ability_id],
target=target,
progress=proto.progress,
)
def __repr__(self) -> str:
return f"UnitOrder({self.ability}, {self.target}, {self.progress})"
# pylint: disable=R0904
class Unit:
class_cache = CacheDict()
def __init__(
self,
proto_data,
bot_object: BotAI,
distance_calculation_index: int = -1,
base_build: int = -1,
):
"""
:param proto_data:
:param bot_object:
:param distance_calculation_index:
:param base_build:
"""
self._proto = proto_data
self._bot_object: BotAI = bot_object
self.game_loop: int = bot_object.state.game_loop
self.base_build = base_build
# Index used in the 2D numpy array to access the 2D distance between two units
self.distance_calculation_index: int = distance_calculation_index
def __repr__(self) -> str:
""" Returns string of this form: Unit(name='SCV', tag=4396941328). """
return f"Unit(name={self.name !r}, tag={self.tag})"
@cached_property
def _type_data(self) -> UnitTypeData:
""" Provides the unit type data. """
return self._bot_object.game_data.units[self._proto.unit_type]
@cached_property
def _creation_ability(self) -> AbilityData:
""" Provides the AbilityData of the creation ability of this unit. """
return self._type_data.creation_ability
@property
def name(self) -> str:
""" Returns the name of the unit. """
return self._type_data.name
@cached_property
def race(self) -> Race:
""" Returns the race of the unit """
return Race(self._type_data._proto.race)
@property
def tag(self) -> int:
""" Returns the unique tag of the unit. """
return self._proto.tag
@property
def is_structure(self) -> bool:
""" Checks if the unit is a structure. """
return IS_STRUCTURE in self._type_data.attributes
@property
def is_light(self) -> bool:
""" Checks if the unit has the 'light' attribute. """
return IS_LIGHT in self._type_data.attributes
@property
def is_armored(self) -> bool:
""" Checks if the unit has the 'armored' attribute. """
return IS_ARMORED in self._type_data.attributes
@property
def is_biological(self) -> bool:
""" Checks if the unit has the 'biological' attribute. """
return IS_BIOLOGICAL in self._type_data.attributes
@property
def is_mechanical(self) -> bool:
""" Checks if the unit has the 'mechanical' attribute. """
return IS_MECHANICAL in self._type_data.attributes
@property
def is_massive(self) -> bool:
""" Checks if the unit has the 'massive' attribute. """
return IS_MASSIVE in self._type_data.attributes
@property
def is_psionic(self) -> bool:
""" Checks if the unit has the 'psionic' attribute. """
return IS_PSIONIC in self._type_data.attributes
@cached_property
def _weapons(self):
""" Returns the weapons of the unit. """
return self._type_data._proto.weapons
@cached_property
def bonus_damage(self) -> Optional[Tuple[int, str]]:
"""Returns a tuple of form '(bonus damage, armor type)' if unit does 'bonus damage' against 'armor type'.
Possible armor typs are: 'Light', 'Armored', 'Biological', 'Mechanical', 'Psionic', 'Massive', 'Structure'."""
# TODO: Consider units with ability attacks (Oracle, Baneling) or multiple attacks (Thor).
if self._weapons:
for weapon in self._weapons:
if weapon.damage_bonus:
b = weapon.damage_bonus[0]
return b.bonus, Attribute(b.attribute).name
return None
@property
def armor(self) -> float:
""" Returns the armor of the unit. Does not include upgrades """
return self._type_data._proto.armor
@property
def sight_range(self) -> float:
""" Returns the sight range of the unit. """
return self._type_data._proto.sight_range
@property
def movement_speed(self) -> float:
"""Returns the movement speed of the unit.
This is the unit movement speed on game speed 'normal'. To convert it to 'faster' movement speed, multiply it by a factor of '1.4'. E.g. reaper movement speed is listed here as 3.75, but should actually be 5.25.
Does not include upgrades or buffs."""
return self._type_data._proto.movement_speed
@property
def is_mineral_field(self) -> bool:
""" Checks if the unit is a mineral field. """
return self._type_data.has_minerals
@property
def is_vespene_geyser(self) -> bool:
""" Checks if the unit is a non-empty vespene geyser or gas extraction building. """
return self._type_data.has_vespene
@property
def health(self) -> float:
""" Returns the health of the unit. Does not include shields. """
return self._proto.health
@property
def health_max(self) -> float:
""" Returns the maximum health of the unit. Does not include shields. """
return self._proto.health_max
@cached_property
def health_percentage(self) -> float:
""" Returns the percentage of health the unit has. Does not include shields. """
if not self._proto.health_max:
return 0
return self._proto.health / self._proto.health_max
@property
def shield(self) -> float:
""" Returns the shield points the unit has. Returns 0 for non-protoss units. """
return self._proto.shield
@property
def shield_max(self) -> float:
""" Returns the maximum shield points the unit can have. Returns 0 for non-protoss units. """
return self._proto.shield_max
@cached_property
def shield_percentage(self) -> float:
""" Returns the percentage of shield points the unit has. Returns 0 for non-protoss units. """
if not self._proto.shield_max:
return 0
return self._proto.shield / self._proto.shield_max
@cached_property
def shield_health_percentage(self) -> float:
"""Returns the percentage of combined shield + hp points the unit has.
Also takes build progress into account."""
max_ = (self._proto.shield_max + self._proto.health_max) * self.build_progress
if max_ == 0:
return 0
return (self._proto.shield + self._proto.health) / max_
@property
def energy(self) -> float:
""" Returns the amount of energy the unit has. Returns 0 for units without energy. """
return self._proto.energy
@property
def energy_max(self) -> float:
""" Returns the maximum amount of energy the unit can have. Returns 0 for units without energy. """
return self._proto.energy_max
@cached_property
def energy_percentage(self) -> float:
""" Returns the percentage of amount of energy the unit has. Returns 0 for units without energy. """
if not self._proto.energy_max:
return 0
return self._proto.energy / self._proto.energy_max
@property
def age_in_frames(self) -> int:
""" Returns how old the unit object data is (in game frames). This age does not reflect the unit was created / trained / morphed! """
return self._bot_object.state.game_loop - self.game_loop
@property
def age(self) -> float:
""" Returns how old the unit object data is (in game seconds). This age does not reflect when the unit was created / trained / morphed! """
return (self._bot_object.state.game_loop - self.game_loop) / 22.4
@property
def is_memory(self) -> bool:
""" Returns True if this Unit object is referenced from the future and is outdated. """
return self.game_loop != self._bot_object.state.game_loop
@cached_property
def is_snapshot(self) -> bool:
"""Checks if the unit is only available as a snapshot for the bot.
Enemy buildings that have been scouted and are in the fog of war or
attacking enemy units on higher, not visible ground appear this way."""
if self.base_build >= 82457:
return self._proto.display_type == IS_SNAPSHOT
# TODO: Fixed in version 5.0.4, remove if a new linux binary is released: https://github.com/Blizzard/s2client-proto/issues/167
position = self.position.rounded
return self._bot_object.state.visibility.data_numpy[position[1], position[0]] != 2
@cached_property
def is_visible(self) -> bool:
"""Checks if the unit is visible for the bot.
NOTE: This means the bot has vision of the position of the unit!
It does not give any information about the cloak status of the unit."""
if self.base_build >= 82457:
return self._proto.display_type == IS_VISIBLE
# TODO: Remove when a new linux binary (5.0.4 or newer) is released
return self._proto.display_type == IS_VISIBLE and not self.is_snapshot
@property
def is_placeholder(self) -> bool:
"""Checks if the unit is a placerholder for the bot.
Raw information about placeholders:
display_type: Placeholder
alliance: Self
unit_type: 86
owner: 1
pos {
x: 29.5
y: 53.5
z: 7.98828125
}
radius: 2.75
is_on_screen: false
"""
return self._proto.display_type == IS_PLACEHOLDER
@property
def alliance(self) -> Alliance:
""" Returns the team the unit belongs to. """
return self._proto.alliance
@property
def is_mine(self) -> bool:
""" Checks if the unit is controlled by the bot. """
return self._proto.alliance == IS_MINE
@property
def is_enemy(self) -> bool:
""" Checks if the unit is hostile. """
return self._proto.alliance == IS_ENEMY
@property
def owner_id(self) -> int:
""" Returns the owner of the unit. This is a value of 1 or 2 in a two player game. """
return self._proto.owner
@property
def position_tuple(self) -> Tuple[float, float]:
""" Returns the 2d position of the unit as tuple without conversion to Point2. """
return self._proto.pos.x, self._proto.pos.y
@cached_property
def position(self) -> Point2:
""" Returns the 2d position of the unit. """
return Point2.from_proto(self._proto.pos)
@cached_property
def position3d(self) -> Point3:
""" Returns the 3d position of the unit. """
return Point3.from_proto(self._proto.pos)
def distance_to(self, p: Union[Unit, Point2]) -> float:
"""Using the 2d distance between self and p.
To calculate the 3d distance, use unit.position3d.distance_to(p)
:param p:
"""
if isinstance(p, Unit):
return self._bot_object._distance_squared_unit_to_unit(self, p)**0.5
return self._bot_object.distance_math_hypot(self.position_tuple, p)
def distance_to_squared(self, p: Union[Unit, Point2]) -> float:
"""Using the 2d distance squared between self and p. Slightly faster than distance_to, so when filtering a lot of units, this function is recommended to be used.
To calculate the 3d distance, use unit.position3d.distance_to(p)
:param p:
"""
if isinstance(p, Unit):
return self._bot_object._distance_squared_unit_to_unit(self, p)
return self._bot_object.distance_math_hypot_squared(self.position_tuple, p)
@property
def facing(self) -> float:
"""Returns direction the unit is facing as a float in range [0,2π). 0 is in direction of x axis."""
return self._proto.facing
def is_facing(self, other_unit: Unit, angle_error: float = 0.05) -> bool:
"""Check if this unit is facing the target unit. If you make angle_error too small, there might be rounding errors. If you make angle_error too big, this function might return false positives.
:param other_unit:
:param angle_error:
"""
# TODO perhaps return default True for units that cannot 'face' another unit? e.g. structures (planetary fortress, bunker, missile turret, photon cannon, spine, spore) or sieged tanks
angle = math.atan2(
other_unit.position_tuple[1] - self.position_tuple[1], other_unit.position_tuple[0] - self.position_tuple[0]
)
if angle < 0:
angle += math.pi * 2
angle_difference = math.fabs(angle - self.facing)
return angle_difference < angle_error
@property
def footprint_radius(self) -> Optional[float]:
"""For structures only.
For townhalls this returns 2.5
For barracks, spawning pool, gateway, this returns 1.5
For supply depot, this returns 1
For sensor tower, creep tumor, this return 0.5
NOTE: This can be None if a building doesn't have a creation ability.
For rich vespene buildings, flying terran buildings, this returns None"""
return self._type_data.footprint_radius
@property
def radius(self) -> float:
""" Half of unit size. See https://liquipedia.net/starcraft2/Unit_Statistics_(Legacy_of_the_Void) """
return self._proto.radius
@property
def build_progress(self) -> float:
""" Returns completion in range [0,1]."""
return self._proto.build_progress
@property
def is_ready(self) -> bool:
""" Checks if the unit is completed. """
return self.build_progress == 1
@property
def cloak(self) -> CloakState:
"""Returns cloak state.
See https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188fbe9dbf/include/sc2api/sc2_unit.h#L95
"""
return CloakState(self._proto.cloak)
@property
def is_cloaked(self) -> bool:
""" Checks if the unit is cloaked. """
return self._proto.cloak in IS_CLOAKED
@property
def is_revealed(self) -> bool:
""" Checks if the unit is revealed. """
return self._proto.cloak == IS_REVEALED
@property
def can_be_attacked(self) -> bool:
""" Checks if the unit is revealed or not cloaked and therefore can be attacked. """
return self._proto.cloak in CAN_BE_ATTACKED
@property
def detect_range(self) -> float:
""" Returns the detection distance of the unit. """
return self._proto.detect_range
@property
def radar_range(self) -> float:
return self._proto.radar_range
@property
def is_selected(self) -> bool:
""" Checks if the unit is currently selected. """
return self._proto.is_selected
@property
def is_on_screen(self) -> bool:
""" Checks if the unit is on the screen. """
return self._proto.is_on_screen
@property
def is_blip(self) -> bool:
""" Checks if the unit is detected by a sensor tower. """
return self._proto.is_blip
@property
def is_powered(self) -> bool:
""" Checks if the unit is powered by a pylon or warppism. """
return self._proto.is_powered
@property
def is_active(self) -> bool:
""" Checks if the unit has an order (e.g. unit is currently moving or attacking, structure is currently training or researching). """
return self._proto.is_active
# PROPERTIES BELOW THIS COMMENT ARE NOT POPULATED FOR SNAPSHOTS
@property
def mineral_contents(self) -> int:
""" Returns the amount of minerals remaining in a mineral field. """
return self._proto.mineral_contents
@property
def vespene_contents(self) -> int:
""" Returns the amount of gas remaining in a geyser. """
return self._proto.vespene_contents
@property
def has_vespene(self) -> bool:
"""Checks if a geyser has any gas remaining.
You can't build extractors on empty geysers."""
return bool(self._proto.vespene_contents)
@property
def is_burrowed(self) -> bool:
""" Checks if the unit is burrowed. """
return self._proto.is_burrowed
@property
def is_hallucination(self) -> bool:
""" Returns True if the unit is your own hallucination or detected. """
return self._proto.is_hallucination
@property
def attack_upgrade_level(self) -> int:
"""Returns the upgrade level of the units attack.
# NOTE: Returns 0 for units without a weapon."""
return self._proto.attack_upgrade_level
@property
def armor_upgrade_level(self) -> int:
""" Returns the upgrade level of the units armor. """
return self._proto.armor_upgrade_level
@property
def shield_upgrade_level(self) -> int:
"""Returns the upgrade level of the units shield.
# NOTE: Returns 0 for units without a shield."""
return self._proto.shield_upgrade_level
@property
def buff_duration_remain(self) -> int:
"""Returns the amount of remaining frames of the visible timer bar.
# NOTE: Returns 0 for units without a timer bar."""
return self._proto.buff_duration_remain
@property
def buff_duration_max(self) -> int:
"""Returns the maximum amount of frames of the visible timer bar.
# NOTE: Returns 0 for units without a timer bar."""
return self._proto.buff_duration_max
# PROPERTIES BELOW THIS COMMENT ARE NOT POPULATED FOR ENEMIES
@cached_property
def orders(self) -> List[UnitOrder]:
""" Returns the a list of the current orders. """
# TODO: add examples on how to use unit orders
return [UnitOrder.from_proto(order, self._bot_object) for order in self._proto.orders]
@cached_property
def order_target(self) -> Optional[Union[int, Point2]]:
"""Returns the target tag (if it is a Unit) or Point2 (if it is a Position)
from the first order, returns None if the unit is idle"""
if self.orders:
target = self.orders[0].target
if isinstance(target, int):
return target
return Point2.from_proto(target)
return None
@property
def is_idle(self) -> bool:
""" Checks if unit is idle. """
return not self._proto.orders
@property
def add_on_tag(self) -> int:
"""Returns the tag of the addon of unit. If the unit has no addon, returns 0."""
return self._proto.add_on_tag
@property
def has_add_on(self) -> bool:
""" Checks if unit has an addon attached. """
return bool(self._proto.add_on_tag)
@cached_property
def has_techlab(self) -> bool:
"""Check if a structure is connected to a techlab addon. This should only ever return True for BARRACKS, FACTORY, STARPORT. """
return self.add_on_tag in self._bot_object.techlab_tags
@cached_property
def has_reactor(self) -> bool:
"""Check if a structure is connected to a reactor addon. This should only ever return True for BARRACKS, FACTORY, STARPORT. """
return self.add_on_tag in self._bot_object.reactor_tags
@cached_property
def add_on_land_position(self) -> Point2:
"""If this unit is an addon (techlab, reactor), returns the position
where a terran building (BARRACKS, FACTORY, STARPORT) has to land to connect to this addon.
Why offset (-2.5, 0.5)? See description in 'add_on_position'
"""
return self.position.offset(Point2((-2.5, 0.5)))
@cached_property
def add_on_position(self) -> Point2:
"""If this unit is a terran production building (BARRACKS, FACTORY, STARPORT),
this property returns the position of where the addon should be, if it should build one or has one attached.
Why offset (2.5, -0.5)?
A barracks is of size 3x3. The distance from the center to the edge is 1.5.
An addon is 2x2 and the distance from the edge to center is 1.
The total distance from center to center on the x-axis is 2.5.
The distance from center to center on the y-axis is -0.5.
"""
return self.position.offset(Point2((2.5, -0.5)))
@cached_property
def passengers(self) -> Set[Unit]:
""" Returns the units inside a Bunker, CommandCenter, PlanetaryFortress, Medivac, Nydus, Overlord or WarpPrism. """
return {Unit(unit, self._bot_object) for unit in self._proto.passengers}
@cached_property
def passengers_tags(self) -> Set[int]:
""" Returns the tags of the units inside a Bunker, CommandCenter, PlanetaryFortress, Medivac, Nydus, Overlord or WarpPrism. """
return {unit.tag for unit in self._proto.passengers}
@property
def cargo_used(self) -> int:
"""Returns how much cargo space is currently used in the unit.
Note that some units take up more than one space."""
return self._proto.cargo_space_taken
@property
def has_cargo(self) -> bool:
""" Checks if this unit has any units loaded. """
return bool(self._proto.cargo_space_taken)
@property
def cargo_size(self) -> int:
""" Returns the amount of cargo space the unit needs. """
return self._type_data.cargo_size
@property
def cargo_max(self) -> int:
""" How much cargo space is available at maximum. """
return self._proto.cargo_space_max
@property
def cargo_left(self) -> int:
""" Returns how much cargo space is currently left in the unit. """
return self._proto.cargo_space_max - self._proto.cargo_space_taken
@property
def assigned_harvesters(self) -> int:
""" Returns the number of workers currently gathering resources at a geyser or mining base."""
return self._proto.assigned_harvesters
@property
def ideal_harvesters(self) -> int:
"""Returns the ideal harverster count for unit.
3 for gas buildings, 2*n for n mineral patches on that base."""
return self._proto.ideal_harvesters
@property
def surplus_harvesters(self) -> int:
"""Returns a positive int if unit has too many harvesters mining,
a negative int if it has too few mining.
Will only works on townhalls, and gas buildings.
"""
return self._proto.assigned_harvesters - self._proto.ideal_harvesters
@property
def weapon_cooldown(self) -> float:
"""Returns the time until the unit can fire again,
returns -1 for units that can't attack.
Usage:
if unit.weapon_cooldown == 0:
unit.attack(target)
elif unit.weapon_cooldown < 0:
unit.move(closest_allied_unit_because_cant_attack)
else:
unit.move(retreatPosition)"""
if self.can_attack:
return self._proto.weapon_cooldown
return -1
@property
def weapon_ready(self) -> bool:
"""Checks if the weapon is ready to be fired."""
return self.weapon_cooldown == 0
@property
def engaged_target_tag(self) -> int:
# TODO What does this do?
return self._proto.engaged_target_tag
@cached_property
def rally_targets(self) -> List[RallyTarget]:
""" Returns the queue of rallytargets of the structure. """
return [RallyTarget.from_proto(rally_target) for rally_target in self._proto.rally_targets]
# Unit functions
def __hash__(self) -> int:
return self.tag
def __eq__(self, other: Union[Unit, Any]) -> bool:
"""
:param other:
"""
return self.tag == getattr(other, "tag", -1)

View File

@@ -0,0 +1,633 @@
# pylint: disable=W0212
from __future__ import annotations
import random
from itertools import chain
from typing import TYPE_CHECKING, Any, Callable, Generator, Iterable, List, Optional, Set, Tuple, Union
from .position import Point2
from .unit import Unit
if TYPE_CHECKING:
from .bot_ai import BotAI
# pylint: disable=R0904
class Units(list):
"""A collection of Unit objects. Makes it easy to select units by selectors."""
@classmethod
def from_proto(cls, units, bot_object: BotAI):
# pylint: disable=E1120
return cls((Unit(raw_unit, bot_object=bot_object) for raw_unit in units))
def __init__(self, units: Iterable[Unit], bot_object: BotAI):
"""
:param units:
:param bot_object:
"""
super().__init__(units)
self._bot_object = bot_object
def __call__(self) -> Units:
"""Creates a new mutable Units object from Units or list object.
:param unit_types:
"""
return self
def __iter__(self) -> Generator[Unit, None, None]:
return (item for item in super().__iter__())
def copy(self) -> Units:
"""Creates a new mutable Units object from Units or list object.
:param units:
"""
return Units(self, self._bot_object)
def __or__(self, other: Units) -> Units:
"""
:param other:
"""
return Units(
chain(
iter(self),
(other_unit for other_unit in other if other_unit.tag not in (self_unit.tag for self_unit in self)),
),
self._bot_object,
)
def __add__(self, other: Units) -> Units:
"""
:param other:
"""
return Units(
chain(
iter(self),
(other_unit for other_unit in other if other_unit.tag not in (self_unit.tag for self_unit in self)),
),
self._bot_object,
)
def __and__(self, other: Units) -> Units:
"""
:param other:
"""
return Units(
(other_unit for other_unit in other if other_unit.tag in (self_unit.tag for self_unit in self)),
self._bot_object,
)
def __sub__(self, other: Units) -> Units:
"""
:param other:
"""
return Units(
(self_unit for self_unit in self if self_unit.tag not in (other_unit.tag for other_unit in other)),
self._bot_object,
)
def __hash__(self) -> int:
return hash(unit.tag for unit in self)
@property
def amount(self) -> int:
return len(self)
@property
def empty(self) -> bool:
return not bool(self)
@property
def exists(self) -> bool:
return bool(self)
def find_by_tag(self, tag: int) -> Optional[Unit]:
"""
:param tag:
"""
for unit in self:
if unit.tag == tag:
return unit
return None
def by_tag(self, tag: int) -> Unit:
"""
:param tag:
"""
unit = self.find_by_tag(tag)
if unit is None:
raise KeyError("Unit not found")
return unit
@property
def first(self) -> Unit:
assert self, "Units object is empty"
return self[0]
def take(self, n: int) -> Units:
"""
:param n:
"""
if n >= self.amount:
return self
return self.subgroup(self[:n])
@property
def random(self) -> Unit:
assert self, "Units object is empty"
return random.choice(self)
def random_or(self, other: any) -> Unit:
return random.choice(self) if self else other
def random_group_of(self, n: int) -> Units:
""" Returns self if n >= self.amount. """
if n < 1:
return Units([], self._bot_object)
if n >= self.amount:
return self
return self.subgroup(random.sample(self, n))
def in_attack_range_of(self, unit: Unit, bonus_distance: float = 0) -> Units:
"""Filters units that are in attack range of the given unit.
This uses the unit and target unit.radius when calculating the distance, so it should be accurate.
Caution: This may not work well for static structures (bunker, sieged tank, planetary fortress, photon cannon, spine and spore crawler) because it seems attack ranges differ for static / immovable units.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
all_zerglings_my_marine_can_attack = enemy_zerglings.in_attack_range_of(my_marine)
Example::
enemy_mutalisks = self.enemy_units(UnitTypeId.MUTALISK)
my_marauder = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARAUDER), None)
if my_marauder:
all_mutalisks_my_marauder_can_attack = enemy_mutaliskss.in_attack_range_of(my_marauder)
# Is empty because mutalisk are flying and marauder cannot attack air
:param unit:
:param bonus_distance:
"""
return self.filter(lambda x: unit.target_in_range(x, bonus_distance=bonus_distance))
def closest_distance_to(self, position: Union[Unit, Point2]) -> float:
"""Returns the distance between the closest unit from this group to the target unit.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
closest_zergling_distance = enemy_zerglings.closest_distance_to(my_marine)
# Contains the distance between the marine and the closest zergling
:param position:
"""
assert self, "Units object is empty"
if isinstance(position, Unit):
return min(self._bot_object._distance_squared_unit_to_unit(unit, position) for unit in self)**0.5
return min(self._bot_object._distance_units_to_pos(self, position))
def furthest_distance_to(self, position: Union[Unit, Point2]) -> float:
"""Returns the distance between the furthest unit from this group to the target unit
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
furthest_zergling_distance = enemy_zerglings.furthest_distance_to(my_marine)
# Contains the distance between the marine and the furthest away zergling
:param position:
"""
assert self, "Units object is empty"
if isinstance(position, Unit):
return max(self._bot_object._distance_squared_unit_to_unit(unit, position) for unit in self)**0.5
return max(self._bot_object._distance_units_to_pos(self, position))
def closest_to(self, position: Union[Unit, Point2]) -> Unit:
"""Returns the closest unit (from this Units object) to the target unit or position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
closest_zergling = enemy_zerglings.closest_to(my_marine)
# Contains the zergling that is closest to the target marine
:param position:
"""
assert self, "Units object is empty"
if isinstance(position, Unit):
return min(
(unit1 for unit1 in self),
key=lambda unit2: self._bot_object._distance_squared_unit_to_unit(unit2, position),
)
distances = self._bot_object._distance_units_to_pos(self, position)
return min(((unit, dist) for unit, dist in zip(self, distances)), key=lambda my_tuple: my_tuple[1])[0]
def furthest_to(self, position: Union[Unit, Point2]) -> Unit:
"""Returns the furhest unit (from this Units object) to the target unit or position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
furthest_zergling = enemy_zerglings.furthest_to(my_marine)
# Contains the zergling that is furthest away to the target marine
:param position:
"""
assert self, "Units object is empty"
if isinstance(position, Unit):
return max(
(unit1 for unit1 in self),
key=lambda unit2: self._bot_object._distance_squared_unit_to_unit(unit2, position),
)
distances = self._bot_object._distance_units_to_pos(self, position)
return max(((unit, dist) for unit, dist in zip(self, distances)), key=lambda my_tuple: my_tuple[1])[0]
def closer_than(self, distance: float, position: Union[Unit, Point2]) -> Units:
"""Returns all units (from this Units object) that are closer than 'distance' away from target unit or position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
close_zerglings = enemy_zerglings.closer_than(3, my_marine)
# Contains all zerglings that are distance 3 or less away from the marine (does not include unit radius in calculation)
:param distance:
:param position:
"""
if not self:
return self
if isinstance(position, Unit):
distance_squared = distance**2
return self.subgroup(
unit for unit in self
if self._bot_object._distance_squared_unit_to_unit(unit, position) < distance_squared
)
distances = self._bot_object._distance_units_to_pos(self, position)
return self.subgroup(unit for unit, dist in zip(self, distances) if dist < distance)
def further_than(self, distance: float, position: Union[Unit, Point2]) -> Units:
"""Returns all units (from this Units object) that are further than 'distance' away from target unit or position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
far_zerglings = enemy_zerglings.further_than(3, my_marine)
# Contains all zerglings that are distance 3 or more away from the marine (does not include unit radius in calculation)
:param distance:
:param position:
"""
if not self:
return self
if isinstance(position, Unit):
distance_squared = distance**2
return self.subgroup(
unit for unit in self
if distance_squared < self._bot_object._distance_squared_unit_to_unit(unit, position)
)
distances = self._bot_object._distance_units_to_pos(self, position)
return self.subgroup(unit for unit, dist in zip(self, distances) if distance < dist)
def in_distance_between(
self, position: Union[Unit, Point2, Tuple[float, float]], distance1: float, distance2: float
) -> Units:
"""Returns units that are further than distance1 and closer than distance2 to unit or position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
zerglings_filtered = enemy_zerglings.in_distance_between(my_marine, 3, 5)
# Contains all zerglings that are between distance 3 and 5 away from the marine (does not include unit radius in calculation)
:param position:
:param distance1:
:param distance2:
"""
if not self:
return self
if isinstance(position, Unit):
distance1_squared = distance1**2
distance2_squared = distance2**2
return self.subgroup(
unit for unit in self if
distance1_squared < self._bot_object._distance_squared_unit_to_unit(unit, position) < distance2_squared
)
distances = self._bot_object._distance_units_to_pos(self, position)
return self.subgroup(unit for unit, dist in zip(self, distances) if distance1 < dist < distance2)
def closest_n_units(self, position: Union[Unit, Point2], n: int) -> Units:
"""Returns the n closest units in distance to position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
zerglings_filtered = enemy_zerglings.closest_n_units(my_marine, 5)
# Contains 5 zerglings that are the closest to the marine
:param position:
:param n:
"""
if not self:
return self
return self.subgroup(self._list_sorted_by_distance_to(position)[:n])
def furthest_n_units(self, position: Union[Unit, Point2], n: int) -> Units:
"""Returns the n furhest units in distance to position.
Example::
enemy_zerglings = self.enemy_units(UnitTypeId.ZERGLING)
my_marine = next((unit for unit in self.units if unit.type_id == UnitTypeId.MARINE), None)
if my_marine:
zerglings_filtered = enemy_zerglings.furthest_n_units(my_marine, 5)
# Contains 5 zerglings that are the furthest to the marine
:param position:
:param n:
"""
if not self:
return self
return self.subgroup(self._list_sorted_by_distance_to(position)[-n:])
def in_distance_of_group(self, other_units: Units, distance: float) -> Units:
"""Returns units that are closer than distance from any unit in the other units object.
:param other_units:
:param distance:
"""
assert other_units, "Other units object is empty"
# Return self because there are no enemies
if not self:
return self
distance_squared = distance**2
if len(self) == 1:
if any(
self._bot_object._distance_squared_unit_to_unit(self[0], target) < distance_squared
for target in other_units
):
return self
return self.subgroup([])
return self.subgroup(
self_unit for self_unit in self if any(
self._bot_object._distance_squared_unit_to_unit(self_unit, other_unit) < distance_squared
for other_unit in other_units
)
)
def in_closest_distance_to_group(self, other_units: Units) -> Unit:
"""Returns unit in shortest distance from any unit in self to any unit in group.
Loops over all units in self, then loops over all units in other_units and calculates the shortest distance. Returns the units that is closest to any unit of 'other_units'.
:param other_units:
"""
assert self, "Units object is empty"
assert other_units, "Given units object is empty"
return min(
self,
key=lambda self_unit:
min(self._bot_object._distance_squared_unit_to_unit(self_unit, other_unit) for other_unit in other_units),
)
def _list_sorted_closest_to_distance(self, position: Union[Unit, Point2], distance: float) -> List[Unit]:
"""This function should be a bit faster than using units.sorted(key=lambda u: u.distance_to(position))
:param position:
:param distance:
"""
if isinstance(position, Unit):
return sorted(
self,
key=lambda unit: abs(self._bot_object._distance_squared_unit_to_unit(unit, position) - distance),
reverse=True,
)
distances = self._bot_object._distance_units_to_pos(self, position)
unit_dist_dict = {unit.tag: dist for unit, dist in zip(self, distances)}
return sorted(self, key=lambda unit2: abs(unit_dist_dict[unit2.tag] - distance), reverse=True)
def n_closest_to_distance(self, position: Point2, distance: float, n: int) -> Units:
"""Returns n units that are the closest to distance away.
For example if the distance is set to 5 and you want 3 units, from units with distance [3, 4, 5, 6, 7] to position,
the units with distance [4, 5, 6] will be returned
:param position:
:param distance:
"""
return self.subgroup(self._list_sorted_closest_to_distance(position=position, distance=distance)[:n])
def n_furthest_to_distance(self, position: Point2, distance: float, n: int) -> Units:
"""Inverse of the function 'n_closest_to_distance', returns the furthest units instead
:param position:
:param distance:
"""
return self.subgroup(self._list_sorted_closest_to_distance(position=position, distance=distance)[-n:])
def subgroup(self, units: Iterable[Unit]) -> Units:
"""Creates a new mutable Units object from Units or list object.
:param units:
"""
return Units(units, self._bot_object)
def filter(self, pred: Callable[[Unit], Any]) -> Units:
"""Filters the current Units object and returns a new Units object.
Example::
from sc2.ids.unit_typeid import UnitTypeId
my_marines = self.units.filter(lambda unit: unit.type_id == UnitTypeId.MARINE)
completed_structures = self.structures.filter(lambda structure: structure.is_ready)
queens_with_energy_to_inject = self.units.filter(lambda unit: unit.type_id == UnitTypeId.QUEEN and unit.energy >= 25)
orbitals_with_energy_to_mule = self.structures.filter(lambda structure: structure.type_id == UnitTypeId.ORBITALCOMMAND and structure.energy >= 50)
my_units_that_can_shoot_up = self.units.filter(lambda unit: unit.can_attack_air)
See more unit properties in unit.py
:param pred:
"""
assert callable(pred), "Function is not callable"
return self.subgroup(filter(pred, self))
def sorted(self, key: Callable[[Unit], Any], reverse: bool = False) -> Units:
return self.subgroup(sorted(self, key=key, reverse=reverse))
def _list_sorted_by_distance_to(self, position: Union[Unit, Point2], reverse: bool = False) -> List[Unit]:
"""This function should be a bit faster than using units.sorted(key=lambda u: u.distance_to(position))
:param position:
:param reverse:
"""
if isinstance(position, Unit):
return sorted(
self, key=lambda unit: self._bot_object._distance_squared_unit_to_unit(unit, position), reverse=reverse
)
distances = self._bot_object._distance_units_to_pos(self, position)
unit_dist_dict = {unit.tag: dist for unit, dist in zip(self, distances)}
return sorted(self, key=lambda unit2: unit_dist_dict[unit2.tag], reverse=reverse)
def sorted_by_distance_to(self, position: Union[Unit, Point2], reverse: bool = False) -> Units:
"""This function should be a bit faster than using units.sorted(key=lambda u: u.distance_to(position))
:param position:
:param reverse:
"""
return self.subgroup(self._list_sorted_by_distance_to(position, reverse=reverse))
def tags_in(self, other: Iterable[int]) -> Units:
"""Filters all units that have their tags in the 'other' set/list/dict
Example::
my_inject_queens = self.units.tags_in(self.queen_tags_assigned_to_do_injects)
# Do not use the following as it is slower because it first loops over all units to filter out if they are queens and loops over those again to check if their tags are in the list/set
my_inject_queens_slow = self.units(QUEEN).tags_in(self.queen_tags_assigned_to_do_injects)
:param other:
"""
return self.filter(lambda unit: unit.tag in other)
def tags_not_in(self, other: Iterable[int]) -> Units:
"""Filters all units that have their tags not in the 'other' set/list/dict
Example::
my_non_inject_queens = self.units.tags_not_in(self.queen_tags_assigned_to_do_injects)
# Do not use the following as it is slower because it first loops over all units to filter out if they are queens and loops over those again to check if their tags are in the list/set
my_non_inject_queens_slow = self.units(QUEEN).tags_not_in(self.queen_tags_assigned_to_do_injects)
:param other:
"""
return self.filter(lambda unit: unit.tag not in other)
@property
def center(self) -> Point2:
""" Returns the central position of all units. """
assert self, "Units object is empty"
return Point2(
(
sum(unit._proto.pos.x for unit in self) / self.amount,
sum(unit._proto.pos.y for unit in self) / self.amount,
)
)
@property
def selected(self) -> Units:
""" Returns all units that are selected by the human player. """
return self.filter(lambda unit: unit.is_selected)
@property
def tags(self) -> Set[int]:
""" Returns all unit tags as a set. """
return {unit.tag for unit in self}
@property
def ready(self) -> Units:
""" Returns all structures that are ready (construction complete). """
return self.filter(lambda unit: unit.is_ready)
@property
def not_ready(self) -> Units:
""" Returns all structures that are not ready (construction not complete). """
return self.filter(lambda unit: not unit.is_ready)
@property
def idle(self) -> Units:
""" Returns all units or structures that are doing nothing (unit is standing still, structure is doing nothing). """
return self.filter(lambda unit: unit.is_idle)
@property
def owned(self) -> Units:
""" Deprecated: All your units. """
return self.filter(lambda unit: unit.is_mine)
@property
def enemy(self) -> Units:
""" Deprecated: All enemy units."""
return self.filter(lambda unit: unit.is_enemy)
@property
def flying(self) -> Units:
""" Returns all units that are flying. """
return self.filter(lambda unit: unit.is_flying)
@property
def not_flying(self) -> Units:
""" Returns all units that not are flying. """
return self.filter(lambda unit: not unit.is_flying)
@property
def structure(self) -> Units:
""" Deprecated: All structures. """
return self.filter(lambda unit: unit.is_structure)
@property
def not_structure(self) -> Units:
""" Deprecated: All units that are not structures. """
return self.filter(lambda unit: not unit.is_structure)
@property
def gathering(self) -> Units:
""" Returns all workers that are mining minerals or vespene (gather command). """
return self.filter(lambda unit: unit.is_gathering)
@property
def returning(self) -> Units:
""" Returns all workers that are carrying minerals or vespene and are returning to a townhall. """
return self.filter(lambda unit: unit.is_returning)
@property
def collecting(self) -> Units:
""" Returns all workers that are mining or returning resources. """
return self.filter(lambda unit: unit.is_collecting)
@property
def visible(self) -> Units:
"""Returns all units or structures that are visible.
TODO: add proper description on which units are exactly visible (not snapshots?)"""
return self.filter(lambda unit: unit.is_visible)
@property
def mineral_field(self) -> Units:
""" Returns all units that are mineral fields. """
return self.filter(lambda unit: unit.is_mineral_field)
@property
def vespene_geyser(self) -> Units:
""" Returns all units that are vespene geysers. """
return self.filter(lambda unit: unit.is_vespene_geyser)
@property
def prefer_idle(self) -> Units:
""" Sorts units based on if they are idle. Idle units come first. """
return self.sorted(lambda unit: unit.is_idle, reverse=True)

View File

@@ -0,0 +1,472 @@
VERSIONS = [
{
"base-version": 52910,
"data-hash": "8D9FEF2E1CF7C6C9CBE4FBCA830DDE1C",
"fixed-hash": "009BC85EF547B51EBF461C83A9CBAB30",
"label": "3.13",
"replay-hash": "47BFE9D10F26B0A8B74C637D6327BF3C",
"version": 52910
}, {
"base-version": 53644,
"data-hash": "CA275C4D6E213ED30F80BACCDFEDB1F5",
"fixed-hash": "29198786619C9011735BCFD378E49CB6",
"label": "3.14",
"replay-hash": "5AF236FC012ADB7289DB493E63F73FD5",
"version": 53644
}, {
"base-version": 54518,
"data-hash": "BBF619CCDCC80905350F34C2AF0AB4F6",
"fixed-hash": "D5963F25A17D9E1EA406FF6BBAA9B736",
"label": "3.15",
"replay-hash": "43530321CF29FD11482AB9CBA3EB553D",
"version": 54518
}, {
"base-version": 54518,
"data-hash": "6EB25E687F8637457538F4B005950A5E",
"fixed-hash": "D5963F25A17D9E1EA406FF6BBAA9B736",
"label": "3.15.1",
"replay-hash": "43530321CF29FD11482AB9CBA3EB553D",
"version": 54724
}, {
"base-version": 55505,
"data-hash": "60718A7CA50D0DF42987A30CF87BCB80",
"fixed-hash": "0189B2804E2F6BA4C4591222089E63B2",
"label": "3.16",
"replay-hash": "B11811B13F0C85C29C5D4597BD4BA5A4",
"version": 55505
}, {
"base-version": 55958,
"data-hash": "5BD7C31B44525DAB46E64C4602A81DC2",
"fixed-hash": "717B05ACD26C108D18A219B03710D06D",
"label": "3.16.1",
"replay-hash": "21C8FA403BB1194E2B6EB7520016B958",
"version": 55958
}, {
"base-version": 56787,
"data-hash": "DFD1F6607F2CF19CB4E1C996B2563D9B",
"fixed-hash": "4E1C17AB6A79185A0D87F68D1C673CD9",
"label": "3.17",
"replay-hash": "D0296961C9EA1356F727A2468967A1E2",
"version": 56787
}, {
"base-version": 56787,
"data-hash": "3F2FCED08798D83B873B5543BEFA6C4B",
"fixed-hash": "4474B6B7B0D1423DAA76B9623EF2E9A9",
"label": "3.17.1",
"replay-hash": "D0296961C9EA1356F727A2468967A1E2",
"version": 57218
}, {
"base-version": 56787,
"data-hash": "C690FC543082D35EA0AAA876B8362BEA",
"fixed-hash": "4474B6B7B0D1423DAA76B9623EF2E9A9",
"label": "3.17.2",
"replay-hash": "D0296961C9EA1356F727A2468967A1E2",
"version": 57490
}, {
"base-version": 57507,
"data-hash": "1659EF34997DA3470FF84A14431E3A86",
"fixed-hash": "95666060F129FD267C5A8135A8920AA2",
"label": "3.18",
"replay-hash": "06D650F850FDB2A09E4B01D2DF8C433A",
"version": 57507
}, {
"base-version": 58400,
"data-hash": "2B06AEE58017A7DF2A3D452D733F1019",
"fixed-hash": "2CFE1B8757DA80086DD6FD6ECFF21AC6",
"label": "3.19",
"replay-hash": "227B6048D55535E0FF5607746EBCC45E",
"version": 58400
}, {
"base-version": 58400,
"data-hash": "D9B568472880CC4719D1B698C0D86984",
"fixed-hash": "CE1005E9B145BDFC8E5E40CDEB5E33BB",
"label": "3.19.1",
"replay-hash": "227B6048D55535E0FF5607746EBCC45E",
"version": 58600
}, {
"base-version": 59587,
"data-hash": "9B4FD995C61664831192B7DA46F8C1A1",
"fixed-hash": "D5D5798A9CCD099932C8F855C8129A7C",
"label": "4.0",
"replay-hash": "BB4DA41B57D490BD13C13A594E314BA4",
"version": 59587
}, {
"base-version": 60196,
"data-hash": "1B8ACAB0C663D5510941A9871B3E9FBE",
"fixed-hash": "9327F9AF76CF11FC43D20E3E038B1B7A",
"label": "4.1",
"replay-hash": "AEA0C2A9D56E02C6B7D21E889D6B9B2F",
"version": 60196
}, {
"base-version": 60321,
"data-hash": "5C021D8A549F4A776EE9E9C1748FFBBC",
"fixed-hash": "C53FA3A7336EDF320DCEB0BC078AEB0A",
"label": "4.1.1",
"replay-hash": "8EE054A8D98C7B0207E709190A6F3953",
"version": 60321
}, {
"base-version": 60321,
"data-hash": "33D9FE28909573253B7FC352CE7AEA40",
"fixed-hash": "FEE6F86A211380DF509F3BBA58A76B87",
"label": "4.1.2",
"replay-hash": "8EE054A8D98C7B0207E709190A6F3953",
"version": 60604
}, {
"base-version": 60321,
"data-hash": "F486693E00B2CD305B39E0AB254623EB",
"fixed-hash": "AF7F5499862F497C7154CB59167FEFB3",
"label": "4.1.3",
"replay-hash": "8EE054A8D98C7B0207E709190A6F3953",
"version": 61021
}, {
"base-version": 60321,
"data-hash": "2E2A3F6E0BAFE5AC659C4D39F13A938C",
"fixed-hash": "F9A68CF1FBBF867216FFECD9EAB72F4A",
"label": "4.1.4",
"replay-hash": "8EE054A8D98C7B0207E709190A6F3953",
"version": 61545
}, {
"base-version": 62347,
"data-hash": "C0C0E9D37FCDBC437CE386C6BE2D1F93",
"fixed-hash": "A5C4BE991F37F1565097AAD2A707FC4C",
"label": "4.2",
"replay-hash": "2167A7733637F3AFC49B210D165219A7",
"version": 62347
}, {
"base-version": 62848,
"data-hash": "29BBAC5AFF364B6101B661DB468E3A37",
"fixed-hash": "ABAF9318FE79E84485BEC5D79C31262C",
"label": "4.2.1",
"replay-hash": "A7ACEC5759ADB459A5CEC30A575830EC",
"version": 62848
}, {
"base-version": 63454,
"data-hash": "3CB54C86777E78557C984AB1CF3494A0",
"fixed-hash": "A9DCDAA97F7DA07F6EF29C0BF4DFC50D",
"label": "4.2.2",
"replay-hash": "A7ACEC5759ADB459A5CEC30A575830EC",
"version": 63454
}, {
"base-version": 64469,
"data-hash": "C92B3E9683D5A59E08FC011F4BE167FF",
"fixed-hash": "DDF3E0A6C00DC667F59BF90F793C71B8",
"label": "4.3",
"replay-hash": "6E80072968515101AF08D3953FE3EEBA",
"version": 64469
}, {
"base-version": 65094,
"data-hash": "E5A21037AA7A25C03AC441515F4E0644",
"fixed-hash": "09EF8E9B96F14C5126F1DB5378D15F3A",
"label": "4.3.1",
"replay-hash": "DD9B57C516023B58F5B588377880D93A",
"version": 65094
}, {
"base-version": 65384,
"data-hash": "B6D73C85DFB70F5D01DEABB2517BF11C",
"fixed-hash": "615C1705E4C7A5FD8690B3FD376C1AFE",
"label": "4.3.2",
"replay-hash": "DD9B57C516023B58F5B588377880D93A",
"version": 65384
}, {
"base-version": 65895,
"data-hash": "BF41339C22AE2EDEBEEADC8C75028F7D",
"fixed-hash": "C622989A4C0AF7ED5715D472C953830B",
"label": "4.4",
"replay-hash": "441BBF1A222D5C0117E85B118706037F",
"version": 65895
}, {
"base-version": 66668,
"data-hash": "C094081D274A39219061182DBFD7840F",
"fixed-hash": "1C236A42171AAC6DD1D5E50D779C522D",
"label": "4.4.1",
"replay-hash": "21D5B4B4D5175C562CF4C4A803C995C6",
"version": 66668
}, {
"base-version": 67188,
"data-hash": "2ACF84A7ECBB536F51FC3F734EC3019F",
"fixed-hash": "2F0094C990E0D4E505570195F96C2A0C",
"label": "4.5",
"replay-hash": "E9873B3A3846F5878CEE0D1E2ADD204A",
"version": 67188
}, {
"base-version": 67188,
"data-hash": "6D239173B8712461E6A7C644A5539369",
"fixed-hash": "A1BC35751ACC34CF887321A357B40158",
"label": "4.5.1",
"replay-hash": "E9873B3A3846F5878CEE0D1E2ADD204A",
"version": 67344
}, {
"base-version": 67926,
"data-hash": "7DE59231CBF06F1ECE9A25A27964D4AE",
"fixed-hash": "570BEB69151F40D010E89DE1825AE680",
"label": "4.6",
"replay-hash": "DA662F9091DF6590A5E323C21127BA5A",
"version": 67926
}, {
"base-version": 67926,
"data-hash": "BEA99B4A8E7B41E62ADC06D194801BAB",
"fixed-hash": "309E45F53690F8D1108F073ABB4D4734",
"label": "4.6.1",
"replay-hash": "DA662F9091DF6590A5E323C21127BA5A",
"version": 68195
}, {
"base-version": 69232,
"data-hash": "B3E14058F1083913B80C20993AC965DB",
"fixed-hash": "21935E776237EF12B6CC73E387E76D6E",
"label": "4.6.2",
"replay-hash": "A230717B315D83ACC3697B6EC28C3FF6",
"version": 69232
}, {
"base-version": 70154,
"data-hash": "8E216E34BC61ABDE16A59A672ACB0F3B",
"fixed-hash": "09CD819C667C67399F5131185334243E",
"label": "4.7",
"replay-hash": "9692B04D6E695EF08A2FB920979E776C",
"version": 70154
}, {
"base-version": 70154,
"data-hash": "94596A85191583AD2EBFAE28C5D532DB",
"fixed-hash": "0AE50F82AC1A7C0DCB6A290D7FBA45DB",
"label": "4.7.1",
"replay-hash": "D74FBB3CB0897A3EE8F44E78119C4658",
"version": 70326
}, {
"base-version": 71061,
"data-hash": "760581629FC458A1937A05ED8388725B",
"fixed-hash": "815C099DF1A17577FDC186FDB1381B16",
"label": "4.8",
"replay-hash": "BD692311442926E1F0B7C17E9ABDA34B",
"version": 71061
}, {
"base-version": 71523,
"data-hash": "FCAF3F050B7C0CC7ADCF551B61B9B91E",
"fixed-hash": "4593CC331691620509983E92180A309A",
"label": "4.8.1",
"replay-hash": "BD692311442926E1F0B7C17E9ABDA34B",
"version": 71523
}, {
"base-version": 71663,
"data-hash": "FE90C92716FC6F8F04B74268EC369FA5",
"fixed-hash": "1DBF3819F3A7367592648632CC0D5BFD",
"label": "4.8.2",
"replay-hash": "E43A9885B3EFAE3D623091485ECCCB6C",
"version": 71663
}, {
"base-version": 72282,
"data-hash": "0F14399BBD0BA528355FF4A8211F845B",
"fixed-hash": "E9958B2CB666DCFE101D23AF87DB8140",
"label": "4.8.3",
"replay-hash": "3AF3657F55AB961477CE268F5CA33361",
"version": 72282
}, {
"base-version": 73286,
"data-hash": "CD040C0675FD986ED37A4CA3C88C8EB5",
"fixed-hash": "62A146F7A0D19A8DD05BF011631B31B8",
"label": "4.8.4",
"replay-hash": "EE3A89F443BE868EBDA33A17C002B609",
"version": 73286
}, {
"base-version": 73559,
"data-hash": "B2465E73AED597C74D0844112D582595",
"fixed-hash": "EF0A43C33413613BC7343B86C0A7CC92",
"label": "4.8.5",
"replay-hash": "147388D35E76861BD4F590F8CC5B7B0B",
"version": 73559
}, {
"base-version": 73620,
"data-hash": "AA18FEAD6573C79EF707DF44ABF1BE61",
"fixed-hash": "4D76491CCAE756F0498D1C5B2973FF9C",
"label": "4.8.6",
"replay-hash": "147388D35E76861BD4F590F8CC5B7B0B",
"version": 73620
}, {
"base-version": 74071,
"data-hash": "70C74A2DCA8A0D8E7AE8647CAC68ACCA",
"fixed-hash": "C4A3F01B4753245296DC94BC1B5E9B36",
"label": "4.9",
"replay-hash": "19D15E5391FACB379BFCA262CA8FD208",
"version": 74071
}, {
"base-version": 74456,
"data-hash": "218CB2271D4E2FA083470D30B1A05F02",
"fixed-hash": "E82051387C591CAB1212B64073759826",
"label": "4.9.1",
"replay-hash": "1586ADF060C26219FF3404673D70245B",
"version": 74456
}, {
"base-version": 74741,
"data-hash": "614480EF79264B5BD084E57F912172FF",
"fixed-hash": "500CC375B7031C8272546B78E9BE439F",
"label": "4.9.2",
"replay-hash": "A7FAC56F940382E05157EAB19C932E3A",
"version": 74741
}, {
"base-version": 75025,
"data-hash": "C305368C63621480462F8F516FB64374",
"fixed-hash": "DEE7842C8BCB6874EC254AA3D45365F7",
"label": "4.9.3",
"replay-hash": "A7FAC56F940382E05157EAB19C932E3A",
"version": 75025
}, {
"base-version": 75689,
"data-hash": "B89B5D6FA7CBF6452E721311BFBC6CB2",
"fixed-hash": "2B2097DC4AD60A2D1E1F38691A1FF111",
"label": "4.10",
"replay-hash": "6A60E59031A7DB1B272EE87E51E4C7CD",
"version": 75689
}, {
"base-version": 75800,
"data-hash": "DDFFF9EC4A171459A4F371C6CC189554",
"fixed-hash": "1FB8FAF4A87940621B34F0B8F6FDDEA6",
"label": "4.10.1",
"replay-hash": "6A60E59031A7DB1B272EE87E51E4C7CD",
"version": 75800
}, {
"base-version": 76052,
"data-hash": "D0F1A68AA88BA90369A84CD1439AA1C3",
"fixed-hash": "",
"label": "4.10.2",
"replay-hash": "",
"version": 76052
}, {
"base-version": 76114,
"data-hash": "CDB276D311F707C29BA664B7754A7293",
"fixed-hash": "",
"label": "4.10.3",
"replay-hash": "",
"version": 76114
}, {
"base-version": 76811,
"data-hash": "FF9FA4EACEC5F06DEB27BD297D73ED67",
"fixed-hash": "",
"label": "4.10.4",
"replay-hash": "",
"version": 76811
}, {
"base-version": 77379,
"data-hash": "70E774E722A58287EF37D487605CD384",
"fixed-hash": "",
"label": "4.11.0",
"replay-hash": "",
"version": 77379
}, {
"base-version": 77379,
"data-hash": "F92D1127A291722120AC816F09B2E583",
"fixed-hash": "",
"label": "4.11.1",
"replay-hash": "",
"version": 77474
}, {
"base-version": 77535,
"data-hash": "FC43E0897FCC93E4632AC57CBC5A2137",
"fixed-hash": "",
"label": "4.11.2",
"replay-hash": "",
"version": 77535
}, {
"base-version": 77661,
"data-hash": "A15B8E4247434B020086354F39856C51",
"fixed-hash": "",
"label": "4.11.3",
"replay-hash": "",
"version": 77661
}, {
"base-version": 78285,
"data-hash": "69493AFAB5C7B45DDB2F3442FD60F0CF",
"fixed-hash": "21D2EBD5C79DECB3642214BAD4A7EF56",
"label": "4.11.4",
"replay-hash": "CAB5C056EDBDA415C552074BF363CC85",
"version": 78285
}, {
"base-version": 79998,
"data-hash": "B47567DEE5DC23373BFF57194538DFD3",
"fixed-hash": "0A698A1B072BC4B087F44DDEF0BE361E",
"label": "4.12.0",
"replay-hash": "9E15AA09E15FE3AF3655126CEEC7FF42",
"version": 79998
}, {
"base-version": 80188,
"data-hash": "44DED5AED024D23177C742FC227C615A",
"fixed-hash": "0A698A1B072BC4B087F44DDEF0BE361E",
"label": "4.12.1",
"replay-hash": "9E15AA09E15FE3AF3655126CEEC7FF42",
"version": 80188
}, {
"base-version": 80949,
"data-hash": "9AE39C332883B8BF6AA190286183ED72",
"fixed-hash": "DACEAFAB8B983C08ACD31ABC085A0052",
"label": "5.0.0",
"replay-hash": "28C41277C5837AABF9838B64ACC6BDCF",
"version": 80949
}, {
"base-version": 81009,
"data-hash": "0D28678BC32E7F67A238F19CD3E0A2CE",
"fixed-hash": "DACEAFAB8B983C08ACD31ABC085A0052",
"label": "5.0.1",
"replay-hash": "28C41277C5837AABF9838B64ACC6BDCF",
"version": 81009
}, {
"base-version": 81102,
"data-hash": "DC0A1182FB4ABBE8E29E3EC13CF46F68",
"fixed-hash": "0C193BD5F63BBAB79D798278F8B2548E",
"label": "5.0.2",
"replay-hash": "08BB9D4CAE25B57160A6E4AD7B8E1A5A",
"version": 81102
}, {
"base-version": 81433,
"data-hash": "5FD8D4B6B52723B44862DF29F232CF31",
"fixed-hash": "4FC35CEA63509AB06AA80AACC1B3B700",
"label": "5.0.3",
"replay-hash": "0920F1BD722655B41DA096B98CC0912D",
"version": 81433
}, {
"base-version": 82457,
"data-hash": "D2707E265785612D12B381AF6ED9DBF4",
"fixed-hash": "ED05F0DB335D003FBC3C7DEF69911114",
"label": "5.0.4",
"replay-hash": "7D9EE968AAD81761334BD9076BFD9EFF",
"version": 82457
}, {
"base-version": 82893,
"data-hash": "D795328C01B8A711947CC62AA9750445",
"fixed-hash": "ED05F0DB335D003FBC3C7DEF69911114",
"label": "5.0.5",
"replay-hash": "7D9EE968AAD81761334BD9076BFD9EFF",
"version": 82893
}, {
"base-version": 83830,
"data-hash": "B4745D6A4F982A3143C183D8ACB6C3E3",
"fixed-hash": "ed05f0db335d003fbc3c7def69911114",
"label": "5.0.6",
"replay-hash": "7D9EE968AAD81761334BD9076BFD9EFF",
"version": 83830
}, {
"base-version": 84643,
"data-hash": "A389D1F7DF9DD792FBE980533B7119FF",
"fixed-hash": "368DE29820A74F5BE747543AC02DB3F8",
"label": "5.0.7",
"replay-hash": "7D9EE968AAD81761334BD9076BFD9EFF",
"version": 84643
}, {
"base-version": 86383,
"data-hash": "22EAC562CD0C6A31FB2C2C21E3AA3680",
"fixed-hash": "B19F4D8B87A2835F9447CA17EDD40C1E",
"label": "5.0.8",
"replay-hash": "7D9EE968AAD81761334BD9076BFD9EFF",
"version": 86383
}, {
"base-version": 87702,
"data-hash": "F799E093428D419FD634CCE9B925218C",
"fixed-hash": "B19F4D8B87A2835F9447CA17EDD40C1E",
"label": "5.0.9",
"replay-hash": "7D9EE968AAD81761334BD9076BFD9EFF",
"version": 87702
}, {
"base-version": 88500,
"data-hash": "F38043A301B034A78AD13F558257DCF8",
"fixed-hash": "F3853B6E3B6013415CAC30EF3B27564B",
"label": "5.0.10",
"replay-hash": "A79CD3B6C6DADB0ECAEFA06E6D18E47B",
"version": 88500
}
]

View File

@@ -0,0 +1,117 @@
# pylint: disable=R0911,W1510
import os
import re
import subprocess
from pathlib import Path, PureWindowsPath
from worlds._sc2common.bot import logger
## This file is used for compatibility with WSL and shouldn't need to be
## accessed directly by any bot clients
def win_path_to_wsl_path(path):
"""Convert a path like C:\\foo to /mnt/c/foo"""
return Path("/mnt") / PureWindowsPath(re.sub("^([A-Z]):", lambda m: m.group(1).lower(), path))
def wsl_path_to_win_path(path):
"""Convert a path like /mnt/c/foo to C:\\foo"""
return PureWindowsPath(re.sub("^/mnt/([a-z])", lambda m: m.group(1).upper() + ":", path))
def get_wsl_home():
"""Get home directory of from Windows, even if run in WSL"""
proc = subprocess.run(["powershell.exe", "-Command", "Write-Host -NoNewLine $HOME"], capture_output=True)
if proc.returncode != 0:
return None
return win_path_to_wsl_path(proc.stdout.decode("utf-8"))
RUN_SCRIPT = """$proc = Start-Process -NoNewWindow -PassThru "%s" "%s"
if ($proc) {
Write-Host $proc.id
exit $proc.ExitCode
} else {
exit 1
}"""
def run(popen_args, sc2_cwd):
"""Run SC2 in Windows and get the pid so that it can be killed later."""
path = wsl_path_to_win_path(popen_args[0])
args = " ".join(popen_args[1:])
return subprocess.Popen(
["powershell.exe", "-Command", RUN_SCRIPT % (path, args)],
cwd=sc2_cwd,
stdout=subprocess.PIPE,
universal_newlines=True,
bufsize=1,
)
def kill(wsl_process):
"""Needed to kill a process started with WSL. Returns true if killed successfully."""
# HACK: subprocess and WSL1 appear to have a nasty interaction where
# any streams are never closed and the process is never considered killed,
# despite having an exit code (this works on WSL2 as well, but isn't
# necessary). As a result,
# 1: We need to read using readline (to make sure we block long enough to
# get the exit code in the rare case where the user immediately hits ^C)
out = wsl_process.stdout.readline().rstrip()
# 2: We need to use __exit__, since kill() calls send_signal(), which thinks
# the process has already exited!
wsl_process.__exit__(None, None, None)
proc = subprocess.run(["taskkill.exe", "-f", "-pid", out], capture_output=True)
return proc.returncode == 0 # Returns 128 on failure
def detect():
"""Detect the current running version of WSL, and bail out if it doesn't exist"""
# Allow disabling WSL detection with an environment variable
if os.getenv("SC2_WSL_DETECT", "1") == "0":
return None
wsl_name = os.environ.get("WSL_DISTRO_NAME")
if not wsl_name:
return None
try:
wsl_proc = subprocess.run(["wsl.exe", "--list", "--running", "--verbose"], capture_output=True)
except (OSError, ValueError):
return None
if wsl_proc.returncode != 0:
return None
# WSL.exe returns a bunch of null characters for some reason, as well as
# windows-style linebreaks. It's inconsistent about how many \rs it uses
# and this could change in the future, so strip out all junk and split by
# Unix-style newlines for safety's sake.
lines = re.sub(r"\000|\r", "", wsl_proc.stdout.decode("utf-8")).split("\n")
def line_has_proc(ln):
return re.search("^\\s*[*]?\\s+" + wsl_name, ln)
def line_version(ln):
return re.sub("^.*\\s+(\\d+)\\s*$", "\\1", ln)
versions = [line_version(ln) for ln in lines if line_has_proc(ln)]
try:
version = versions[0]
if int(version) not in [1, 2]:
return None
except (ValueError, IndexError):
return None
logger.info(f"WSL version {version} detected")
if version == "2" and not (os.environ.get("SC2CLIENTHOST") and os.environ.get("SC2SERVERHOST")):
logger.warning("You appear to be running WSL2 without your hosts configured correctly.")
logger.warning("This may result in SC2 staying on a black screen and not connecting to your bot.")
logger.warning("Please see the python-sc2 README for WSL2 configuration instructions.")
return "WSL" + version

View File

@@ -0,0 +1,6 @@
s2clientprotocol>=5.0.11.90136.0
mpyq>=0.2.5
portpicker>=1.5.2
aiohttp>=3.8.4
loguru>=0.7.0
protobuf==3.20.3

View File

@@ -175,7 +175,7 @@ class RhindleMinimumSpeed(Range):
class ConnectorMultiSlot(Toggle):
"""If true, the client and lua connector will add lowest 8 bits of the player slot
to the port number used to connect to each other, to simplify connecting multiple local
clients to local BizHawks.
clients to local EmuHawk instances.
Set in the yaml, since the connector has to read this out of the rom file before connecting.
"""
display_name = "Connector Multi-Slot"

View File

@@ -8,7 +8,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
LocationProgressType
from Main import __version__
from Utils import __version__
from Options import AssembleOptions
from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive
@@ -57,7 +57,7 @@ class AdventureWeb(WebWorld):
def get_item_position_data_start(table_index: int):
item_ram_address = item_ram_addresses[table_index];
item_ram_address = item_ram_addresses[table_index]
return item_position_table + item_ram_address - items_ram_start

View File

@@ -2,32 +2,32 @@
## Important
As we are using Bizhawk, this guide is only applicable to Windows and Linux systems.
As we are using BizHawk, this guide is only applicable to Windows and Linux systems.
## Required Software
- Bizhawk: [Bizhawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
- BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
- Version 2.3.1 and later are supported. Version 2.7 is recommended for stability.
- Detailed installation instructions for Bizhawk can be found at the above link.
- Detailed installation instructions for BizHawk can be found at the above link.
- Windows users must run the prereq installer first, which can also be found at the above link.
- The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
(select `Adventure Client` during installation).
- An Adventure NTSC ROM file. The Archipelago community cannot provide these.
## Configuring Bizhawk
## Configuring BizHawk
Once Bizhawk has been installed, open Bizhawk and change the following settings:
Once BizHawk has been installed, open EmuHawk and change the following settings:
- Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
"Lua+LuaInterface". Then restart Bizhawk. This is required for the Lua script to function correctly.
- (≤ 2.8) Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
"Lua+LuaInterface". Then restart EmuHawk. This is required for the Lua script to function correctly.
**NOTE: Even if "Lua+LuaInterface" is already selected, toggle between the two options and reselect it. Fresh installs**
**of newer versions of Bizhawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load**
**of newer versions of EmuHawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load**
**"NLua+KopiLua" until this step is done.**
- Under Config > Customize, check the "Run in background" box. This will prevent disconnecting from the client while
BizHawk is running in the background.
EmuHawk is running in the background.
- It is recommended that you provide a path to BizHawk in your host.yaml for Adventure so the client can start it automatically
- At the same time, you can set an option to automatically load the connector_adventure.lua script when launching BizHawk
- It is recommended that you provide a path to EmuHawk in your host.yaml for Adventure so the client can start it automatically
- At the same time, you can set an option to automatically load the connector_adventure.lua script when launching EmuHawk
from AdventureClient.
Default Windows install example:
```rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"```
@@ -63,11 +63,10 @@ path as recommended).
### Connect to the Multiserver
Once both the client and the emulator are started, you must connect them. Within the emulator click on the "Tools"
menu and select "Lua Console". Click the folder button or press Ctrl+O to open a Lua script.
Navigate to your Archipelago install folder and open `data/lua/connector_adventure.lua`, if it is not
configured to do this automatically.
Once both the client and the emulator are started, you must connect them, assuming you didn't set it up to be automatic.
Navigate to your Archipelago install folder, then to `data/lua`, and drag+drop the `connector_adventure.lua` script onto
the main EmuHawk window. (You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate
to `connector_adventure.lua` with the file picker.)
To connect the client to the multiserver simply put `<address>:<port>` on the textfield on top and press enter (if the
server uses password, type in the bottom textfield `/connect <address>:<port> [password]`)

View File

@@ -1,10 +1,29 @@
import logging
from typing import Optional, Union, List, Tuple, Callable, Dict
from __future__ import annotations
import logging
from typing import Optional, Union, List, Tuple, Callable, Dict, TYPE_CHECKING
from BaseClasses import Boss
from Fill import FillError
from .Options import LTTPBosses as Bosses
from .StateHelpers import can_shoot_arrows, can_extend_magic, can_get_good_bee, has_sword, has_beam_sword, has_melee_weapon, has_fire_source
from .StateHelpers import can_shoot_arrows, can_extend_magic, can_get_good_bee, has_sword, has_beam_sword, \
has_melee_weapon, has_fire_source
if TYPE_CHECKING:
from . import ALTTPWorld
class Boss:
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
self.name = name
self.enemizer_name = enemizer_name
self.defeat_rule = defeat_rule
self.player = player
def can_defeat(self, state) -> bool:
return self.defeat_rule(state, self.player)
def __repr__(self):
return f"Boss({self.name})"
def BossFactory(boss: str, player: int) -> Optional[Boss]:
@@ -166,10 +185,10 @@ boss_location_table: List[Tuple[str, str]] = [
]
def place_plando_bosses(bosses: List[str], world, player: int) -> Tuple[List[str], List[Tuple[str, str]]]:
def place_plando_bosses(world: "ALTTPWorld", bosses: List[str]) -> Tuple[List[str], List[Tuple[str, str]]]:
# Most to least restrictive order
boss_locations = boss_location_table.copy()
world.random.shuffle(boss_locations)
world.multiworld.random.shuffle(boss_locations)
boss_locations.sort(key=lambda location: -int(restrictive_boss_locations[location]))
already_placed_bosses: List[str] = []
@@ -184,12 +203,12 @@ def place_plando_bosses(bosses: List[str], world, player: int) -> Tuple[List[str
level = loc[-1]
loc = " ".join(loc[:-1])
loc = loc.title().replace("Of", "of")
place_boss(world, player, boss, loc, level)
place_boss(world, boss, loc, level)
already_placed_bosses.append(boss)
boss_locations.remove((loc, level))
else: # boss chosen with no specified locations
boss = boss.title()
boss_locations, already_placed_bosses = place_where_possible(world, player, boss, boss_locations)
boss_locations, already_placed_bosses = place_where_possible(world, boss, boss_locations)
return already_placed_bosses, boss_locations
@@ -224,20 +243,23 @@ for location in boss_location_table:
for boss in boss_table if not boss.startswith("Agahnim"))
def place_boss(world, player: int, boss: str, location: str, level: Optional[str]) -> None:
if location == 'Ganons Tower' and world.mode[player] == 'inverted':
def place_boss(world: "ALTTPWorld", boss: str, location: str, level: Optional[str]) -> None:
player = world.player
if location == 'Ganons Tower' and world.multiworld.mode[player] == 'inverted':
location = 'Inverted Ganons Tower'
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
world.get_dungeon(location, player).bosses[level] = BossFactory(boss, player)
world.dungeons[location].bosses[level] = BossFactory(boss, player)
def format_boss_location(location: str, level: str) -> str:
return location + (' (' + level + ')' if level else '')
def format_boss_location(location_name: str, level: str) -> str:
return location_name + (' (' + level + ')' if level else '')
def place_bosses(world, player: int) -> None:
def place_bosses(world: "ALTTPWorld") -> None:
multiworld = world.multiworld
player = world.player
# will either be an int or a lower case string with ';' between options
boss_shuffle: Union[str, int] = world.boss_shuffle[player].value
boss_shuffle: Union[str, int] = multiworld.boss_shuffle[player].value
already_placed_bosses: List[str] = []
remaining_locations: List[Tuple[str, str]] = []
# handle plando
@@ -246,14 +268,14 @@ def place_bosses(world, player: int) -> None:
options = boss_shuffle.split(";")
boss_shuffle = Bosses.options[options.pop()]
# place our plando bosses
already_placed_bosses, remaining_locations = place_plando_bosses(options, world, player)
already_placed_bosses, remaining_locations = place_plando_bosses(world, options)
if boss_shuffle == Bosses.option_none: # vanilla boss locations
return
# Most to least restrictive order
if not remaining_locations and not already_placed_bosses:
remaining_locations = boss_location_table.copy()
world.random.shuffle(remaining_locations)
multiworld.random.shuffle(remaining_locations)
remaining_locations.sort(key=lambda location: -int(restrictive_boss_locations[location]))
all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons
@@ -263,7 +285,7 @@ def place_bosses(world, player: int) -> None:
if boss_shuffle == Bosses.option_basic: # vanilla bosses shuffled
bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm']
else: # all bosses present, the three duplicates chosen at random
bosses = placeable_bosses + world.random.sample(placeable_bosses, 3)
bosses = placeable_bosses + multiworld.random.sample(placeable_bosses, 3)
# there is probably a better way to do this
while already_placed_bosses:
@@ -275,7 +297,7 @@ def place_bosses(world, player: int) -> None:
logging.debug('Bosses chosen %s', bosses)
world.random.shuffle(bosses)
multiworld.random.shuffle(bosses)
for loc, level in remaining_locations:
for _ in range(len(bosses)):
boss = bosses.pop()
@@ -288,39 +310,39 @@ def place_bosses(world, player: int) -> None:
else:
raise FillError(f'Could not place boss for location {format_boss_location(loc, level)}')
place_boss(world, player, boss, loc, level)
place_boss(world, boss, loc, level)
elif boss_shuffle == Bosses.option_chaos: # all bosses chosen at random
for loc, level in remaining_locations:
try:
boss = world.random.choice(
boss = multiworld.random.choice(
[b for b in placeable_bosses if can_place_boss(b, loc, level)])
except IndexError:
raise FillError(f'Could not place boss for location {format_boss_location(loc, level)}')
else:
place_boss(world, player, boss, loc, level)
place_boss(world, boss, loc, level)
elif boss_shuffle == Bosses.option_singularity:
primary_boss = world.random.choice(placeable_bosses)
remaining_boss_locations, _ = place_where_possible(world, player, primary_boss, remaining_locations)
primary_boss = multiworld.random.choice(placeable_bosses)
remaining_boss_locations, _ = place_where_possible(world, primary_boss, remaining_locations)
if remaining_boss_locations:
# pick a boss to go into the remaining locations
remaining_boss = world.random.choice([boss for boss in placeable_bosses if all(
remaining_boss = multiworld.random.choice([boss for boss in placeable_bosses if all(
can_place_boss(boss, loc, level) for loc, level in remaining_boss_locations)])
remaining_boss_locations, _ = place_where_possible(world, player, remaining_boss, remaining_boss_locations)
remaining_boss_locations, _ = place_where_possible(world, remaining_boss, remaining_boss_locations)
if remaining_boss_locations:
raise Exception("Unfilled boss locations!")
else:
raise FillError(f"Could not find boss shuffle mode {boss_shuffle}")
def place_where_possible(world, player: int, boss: str, boss_locations) -> Tuple[List[Tuple[str, str]], List[str]]:
def place_where_possible(world: "ALTTPWorld", boss: str, boss_locations) -> Tuple[List[Tuple[str, str]], List[str]]:
remainder: List[Tuple[str, str]] = []
placed_bosses: List[str] = []
for loc, level in boss_locations:
# place that boss where it can go
if can_place_boss(boss, loc, level):
place_boss(world, player, boss, loc, level)
place_boss(world, boss, loc, level)
placed_bosses.append(boss)
else:
remainder.append((loc, level))

View File

@@ -579,19 +579,27 @@ class ALTTPSNIClient(SNIClient):
def get_alttp_settings(romfile: str):
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
adjustedromfile = ''
if lastSettings:
choice = 'no'
if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply:
import LttPAdjuster
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink", "allowcollect"}
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
if hasattr(lastSettings, "sprite_pool"):
last_settings = Utils.get_adjuster_settings(GAME_ALTTP)
base_settings = LttPAdjuster.get_argparser().parse_known_args(args=[])[0]
allow_list = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink", "allowcollect", "oof"}
for option_name in allow_list:
# set new defaults since last_settings were created
if not hasattr(last_settings, option_name):
setattr(last_settings, option_name, getattr(base_settings, option_name))
adjustedromfile = ''
if last_settings:
choice = 'no'
if not hasattr(last_settings, 'auto_apply') or 'ask' in last_settings.auto_apply:
printed_options = {name: value for name, value in vars(last_settings).items() if name in allow_list}
if hasattr(last_settings, "sprite_pool"):
sprite_pool = {}
for sprite in lastSettings.sprite_pool:
for sprite in last_settings.sprite_pool:
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
@@ -663,35 +671,35 @@ def get_alttp_settings(romfile: str):
choice = 'yes'
elif choice and "never" in choice:
choice = 'no'
lastSettings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings)
last_settings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, last_settings)
elif choice and "always" in choice:
choice = 'yes'
lastSettings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings)
last_settings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, last_settings)
else:
choice = 'no'
elif 'never' in lastSettings.auto_apply:
elif 'never' in last_settings.auto_apply:
choice = 'no'
elif 'always' in lastSettings.auto_apply:
elif 'always' in last_settings.auto_apply:
choice = 'yes'
if 'yes' in choice:
from worlds.alttp.Rom import get_base_rom_path
lastSettings.rom = romfile
lastSettings.baserom = get_base_rom_path()
lastSettings.world = None
last_settings.rom = romfile
last_settings.baserom = get_base_rom_path()
last_settings.world = None
if hasattr(lastSettings, "sprite_pool"):
if hasattr(last_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool"))
last_settings.world = AdjusterWorld(getattr(last_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, adjustedromfile = LttPAdjuster.adjust(lastSettings)
_, adjustedromfile = LttPAdjuster.adjust(last_settings)
if hasattr(lastSettings, "world"):
delattr(lastSettings, "world")
if hasattr(last_settings, "world"):
delattr(last_settings, "world")
else:
adjusted = False
if adjusted:

View File

@@ -1,28 +1,83 @@
import typing
from __future__ import annotations
from BaseClasses import CollectionState, Dungeon
import typing
from typing import List, Optional
from BaseClasses import CollectionState, Region, MultiWorld
from Fill import fill_restrictive
from .Bosses import BossFactory
from .Bosses import BossFactory, Boss
from .Items import ItemFactory
from .Regions import lookup_boss_drops
from .Options import smallkey_shuffle
if typing.TYPE_CHECKING:
from .SubClasses import ALttPLocation
from .SubClasses import ALttPLocation, ALttPItem
from . import ALTTPWorld
def create_dungeons(world, player):
class Dungeon:
def __init__(self, name: str, regions: List[Region], big_key: ALttPItem, small_keys: List[ALttPItem],
dungeon_items: List[ALttPItem], player: int):
self.name = name
self.regions = regions
self.big_key = big_key
self.small_keys = small_keys
self.dungeon_items = dungeon_items
self.bosses = dict()
self.player = player
self.multiworld = None
@property
def boss(self) -> Optional[Boss]:
return self.bosses.get(None, None)
@boss.setter
def boss(self, value: Optional[Boss]):
self.bosses[None] = value
@property
def keys(self) -> List[ALttPItem]:
return self.small_keys + ([self.big_key] if self.big_key else [])
@property
def all_items(self) -> List[ALttPItem]:
return self.dungeon_items + self.keys
def is_dungeon_item(self, item: ALttPItem) -> bool:
return item.player == self.player and item.name in (dungeon_item.name for dungeon_item in self.all_items)
def __eq__(self, other: Dungeon) -> bool:
if not other:
return False
return self.name == other.name and self.player == other.player
def __repr__(self):
return self.__str__()
def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld \
else f'{self.name} (Player {self.player})'
def create_dungeons(world: "ALTTPWorld"):
multiworld = world.multiworld
player = world.player
def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dungeon_items):
dungeon = Dungeon(name, dungeon_regions, big_key,
[] if world.smallkey_shuffle[player] == smallkey_shuffle.option_universal else small_keys,
[] if multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal else small_keys,
dungeon_items, player)
for item in dungeon.all_items:
item.dungeon = dungeon
dungeon.boss = BossFactory(default_boss, player) if default_boss else None
for region in dungeon.regions:
world.get_region(region, player).dungeon = dungeon
dungeon.multiworld = world
regions = []
for region_name in dungeon.regions:
region = multiworld.get_region(region_name, player)
region.dungeon = dungeon
regions.append(region)
dungeon.multiworld = multiworld
dungeon.regions = regions
return dungeon
ES = make_dungeon('Hyrule Castle', None, ['Hyrule Castle', 'Sewers', 'Sewer Drop', 'Sewers (Dark)', 'Sanctuary'],
@@ -83,7 +138,7 @@ def create_dungeons(world, player):
ItemFactory(['Small Key (Turtle Rock)'] * 4, player),
ItemFactory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], player))
if world.mode[player] != 'inverted':
if multiworld.mode[player] != 'inverted':
AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None,
ItemFactory(['Small Key (Agahnims Tower)'] * 2, player), [])
GT = make_dungeon('Ganons Tower', 'Agahnim2',
@@ -111,26 +166,34 @@ def create_dungeons(world, player):
GT.bosses['top'] = BossFactory('Moldorm', player)
for dungeon in [ES, EP, DP, ToH, AT, PoD, TT, SW, SP, IP, MM, TR, GT]:
world.dungeons[dungeon.name, dungeon.player] = dungeon
world.dungeons[dungeon.name] = dungeon
def get_dungeon_item_pool(world) -> typing.List:
return [item for dungeon in world.dungeons.values() for item in dungeon.all_items]
def get_dungeon_item_pool(multiworld: MultiWorld) -> typing.List[ALttPItem]:
return [item
for world in multiworld.get_game_worlds("A Link to the Past")
for item in get_dungeon_item_pool_player(world)]
def get_dungeon_item_pool_player(world, player) -> typing.List:
return [item for dungeon in world.dungeons.values() if dungeon.player == player for item in dungeon.all_items]
def get_dungeon_item_pool_player(world) -> typing.List[ALttPItem]:
return [item
for dungeon in world.dungeons.values()
for item in dungeon.all_items]
def get_unfilled_dungeon_locations(multiworld) -> typing.List:
return [location for location in multiworld.get_locations() if not location.item and location.parent_region.dungeon]
def get_unfilled_dungeon_locations(multiworld: MultiWorld) -> typing.List[ALttPLocation]:
return [location
for world in multiworld.get_game_worlds("A Link to the Past")
for dungeon in world.dungeons.values()
for region in dungeon.regions
for location in region.locations if not location.item]
def fill_dungeons_restrictive(world):
def fill_dungeons_restrictive(multiworld: MultiWorld):
"""Places dungeon-native items into their dungeons, places nothing if everything is shuffled outside."""
localized: set = set()
dungeon_specific: set = set()
for subworld in world.get_game_worlds("A Link to the Past"):
for subworld in multiworld.get_game_worlds("A Link to the Past"):
player = subworld.player
localized |= {(player, item_name) for item_name in
subworld.dungeon_local_item_names}
@@ -138,12 +201,12 @@ def fill_dungeons_restrictive(world):
subworld.dungeon_specific_item_names}
if localized:
in_dungeon_items = [item for item in get_dungeon_item_pool(world) if (item.player, item.name) in localized]
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
if in_dungeon_items:
restricted_players = {player for player, restricted in world.restrict_dungeon_item_on_boss.items() if
restricted_players = {player for player, restricted in multiworld.restrict_dungeon_item_on_boss.items() if
restricted}
locations: typing.List["ALttPLocation"] = [
location for location in get_unfilled_dungeon_locations(world)
location for location in get_unfilled_dungeon_locations(multiworld)
# filter boss
if not (location.player in restricted_players and location.name in lookup_boss_drops)]
if dungeon_specific:
@@ -153,7 +216,7 @@ def fill_dungeons_restrictive(world):
location.item_rule = lambda item, dungeon=dungeon, orig_rule=orig_rule: \
(not (item.player, item.name) in dungeon_specific or item.dungeon is dungeon) and orig_rule(item)
world.random.shuffle(locations)
multiworld.random.shuffle(locations)
# Dungeon-locked items have to be placed first, to not run out of spaces for dungeon-locked items
# subsort in the order Big Key, Small Key, Other before placing dungeon items
@@ -162,14 +225,15 @@ def fill_dungeons_restrictive(world):
key=lambda item: sort_order.get(item.type, 1) +
(5 if (item.player, item.name) in dungeon_specific else 0))
# Construct a partial all_state which contains only the items from get_pre_fill_items which aren't in_dungeon
# Construct a partial all_state which contains only the items from get_pre_fill_items,
# which aren't in_dungeon
in_dungeon_player_ids = {item.player for item in in_dungeon_items}
all_state_base = CollectionState(world)
for item in world.itempool:
world.worlds[item.player].collect(all_state_base, item)
all_state_base = CollectionState(multiworld)
for item in multiworld.itempool:
multiworld.worlds[item.player].collect(all_state_base, item)
pre_fill_items = []
for player in in_dungeon_player_ids:
pre_fill_items += world.worlds[player].get_pre_fill_items()
pre_fill_items += multiworld.worlds[player].get_pre_fill_items()
for item in in_dungeon_items:
try:
pre_fill_items.remove(item)
@@ -177,16 +241,15 @@ def fill_dungeons_restrictive(world):
# pre_fill_items should be a subset of in_dungeon_items, but just in case
pass
for item in pre_fill_items:
world.worlds[item.player].collect(all_state_base, item)
multiworld.worlds[item.player].collect(all_state_base, item)
all_state_base.sweep_for_events()
# Remove completion condition so that minimal-accessibility worlds place keys properly
for player in {item.player for item in in_dungeon_items}:
if all_state_base.has("Triforce", player):
all_state_base.remove(world.worlds[player].create_item("Triforce"))
all_state_base.remove(multiworld.worlds[player].create_item("Triforce"))
fill_restrictive(world, all_state_base, locations, in_dungeon_items, True, True, allow_excluded=True)
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True, allow_excluded=True)
dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A],
@@ -200,3 +263,4 @@ dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A],
'Ice Palace - Prize': [0x155BF],
'Misery Mire - Prize': [0x155B9],
'Turtle Rock - Prize': [0x155C7, 0x155A7, 0x155AA, 0x155AB]}

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