Compare commits

...

135 Commits
0.2.0 ... 0.2.1

Author SHA1 Message Date
CaitSith2
98a038e39e Death link default true/false values for super metroid. 2021-12-04 14:04:28 -08:00
Fabian Dill
33477202b9 WebHost: remove outdated data 2021-12-04 22:12:09 +01:00
CaitSith2
9c74d648f8 Tie the need for satellite recipe to satellite goal, not max science pack. 2021-12-04 06:20:16 -08:00
Fabian Dill
feb2e0be03 Factorio: fix selecting wrong goal requirements due to convoluted if tree. 2021-12-04 10:54:11 +01:00
Fabian Dill
84e76eadd9 SM: rename death_link_survive and update docstring 2021-12-03 22:11:25 +01:00
Fabian Dill
c1a73e7839 WebHost: document how to bring up a slot tracker 2021-12-03 20:54:19 +01:00
espeon65536
75625b143c Core: better pretty-print for OptionList when the list is of non-strings 2021-12-03 18:15:10 +00:00
espeon65536
c10e17d24c Minecraft: remove bad default for StartingItems 2021-12-03 18:15:10 +00:00
Fabian Dill
21d465bcb8 CommonClient: add docstring to /ready 2021-12-03 07:04:17 +01:00
Fabian Dill
47c1300f30 Setup: move templates from /Players into /Players/Templates 2021-12-03 07:01:43 +01:00
Fabian Dill
e7d8149d74 LttP Docs: reword instructions to not accidentally overwrite the SNI Connector with an empty file. 2021-12-03 07:01:21 +01:00
eudaimonistic
a3220ac72d Add known safe MSU-1 list
List assembled for use in competitive Zelda restreams.  Permission sought and granted by author Amarith via DM.
2021-12-03 05:08:34 +00:00
Fabian Dill
994621372c MultiServer: finish removing prompt toolkit 2021-12-03 05:24:43 +01:00
Fabian Dill
9d3cbb19f9 Clients: add docstrings to /items and /locations 2021-12-03 05:14:44 +01:00
Fabian Dill
3110763052 WebHost: allow switching out "/tracker/" for "/generic_tracker/" in a tracker url to get the generic tracker for that slot.
No idea where a good place is to sick a link for it. Maybe on the individual trackers pages?
2021-12-03 02:41:56 +01:00
CaitSith2
6f12ed38d9 Add in whitelist for overriding blacklist. 2021-12-02 15:27:48 -08:00
CaitSith2
efb4e5a7b3 Use OptionSet for blacklist 2021-12-02 15:27:00 -08:00
CaitSith2
a15689e380 Allow explicit blacklisting (and whitelisting) of free samples from yaml 2021-12-02 09:26:51 -08:00
CaitSith2
548d893eaa Convenient runtime changing of death link status requires 0.2.1 2021-12-01 23:42:09 -08:00
Fabian Dill
1ec9ab5568 CommonClient: make the Server tooltip no longer fullscreen 2021-12-02 07:47:10 +01:00
Fabian Dill
a767d7723c FF1: update some client texts 2021-12-02 07:14:55 +01:00
Fabian Dill
a60c6176be SM: add client version check for DeathLink 2021-12-02 06:13:44 +01:00
lordlou
83cfd6ec05 SM update (#147)
* 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
2021-12-02 06:11:42 +01:00
black-sliver
f673dfb7cf SNIClient: add #server= to url for soe/wasm client 2021-12-02 04:44:19 +00:00
Fabian Dill
22d8b0ef30 Clients: add hint_location for autofill 2021-12-02 03:14:26 +01:00
CaitSith2
763edf00f2 Satellite now a possible goal for ALL science pack levels, chosen by option.
Satellite unlocks by respective science pack (or by automation in the case of automation science pack)
2021-11-30 23:18:17 -08:00
Fabian Dill
b7128e6ee2 FF1: add to setup 2021-12-01 02:47:08 +01:00
Fabian Dill
db56f4a6b7 Core: bump version to 0.2.1 2021-12-01 02:39:52 +01:00
espeon65536
3fa253bac5 MC: 1.17 support (#120)
* MC: add death_link option

* Minecraft: 1.17 advancements and logic support

* Update Minecraft tracker to 1.17

* Minecraft: add tests for new advancements

* removed jdk/forge download install out of iss and into MinecraftClient.py using flag --install

* Add required_bosses option
choices are none, ender_dragon, wither, both
postgame advancements are set according to the required boss for completion

* fix docstring for PostgameAdvancements

* Minecraft: add starting_items
List of dicts: item, amount, nbt

* Update descriptions for AdvancementGoal and EggShardsRequired

* Minecraft: fix tests for required_bosses attribute

* Minecraft: updated logic for various dragon-related advancements
Split the logic into can_respawn and can_kill dragon
Free the End, Monsters Hunted, The End Again still require both respawn and kill, since the player needs to kill and be credited with the kill
You Need a Mint and Is It a Plane now require only respawn, since the dragon need only be alive; if killed out of logic, it's ok
The Next Generation only requires kill, since the egg spawns regardless of whether the player was credited with the kill or not

* Minecraft client: ignore prereleases unless --prerelease flag is on

* explicitly state all defaults
change structure shuffle and structure compass defaults to true
update install tutorial to point to player-settings page, as well as removing instructions for manual install

* Minecraft client: add Minecraft version check
Adds a minecraft_version field in the apmc, and downloads only mods which contain that version in the name of the .jar file.
This ensures that the client remains compatible even if new mods are released for later versions, since they won't download a mod for a later version than the apmc says.

Co-authored-by: Kono Tyran <Kono.Tyran@gmail.com>
2021-12-01 02:37:11 +01:00
Fabian Dill
d7509972e4 SNIClient: fix apsoe handling 2021-12-01 01:01:41 +01:00
Fabian Dill
49a0f473ce Docs: add more explanation to text type of JSONMessagePart 2021-11-30 08:25:22 +01:00
Fabian Dill
520e5feefb Docs: add missed JSONMessagePart types 2021-11-30 06:41:50 +01:00
Fabian Dill
0992087e9a MultiServer: add not found to !hint response and color found text
Clients: text parsing fixes
2021-11-30 06:09:40 +01:00
Fabian Dill
246a5c568b Core: add some more types 2021-11-30 05:33:56 +01:00
black-sliver
c083716627 SoE: update tutorial for 0.2.1 2021-11-29 23:29:50 +00:00
alwaysintreble
31c15c257c Fix Military fortress filling with new location names 2021-11-29 23:29:25 +00:00
Fabian Dill
dcb6da30ef FF1: datapackage is no longer custom 2021-11-29 22:28:51 +01:00
Fabian Dill
c46abd7e65 Client UI: allow auto filling !getitem 2021-11-29 21:35:06 +01:00
black-sliver
f478b65815 SoE: update pyevermizer to 0.39.2
+ printf to debug channel
+ better error handling
+ more error checking
2021-11-29 07:25:58 +00:00
Jarno Westhof
8363d1749b [Timespinner] New seed options and new locations checks (#140) 2021-11-28 22:59:34 +01:00
alwaysintreble
b3ae4b86e4 TS: Rename various locations for clarity (#139)
* Rename various locations for clarity
2021-11-28 22:33:51 +01:00
jtoyoda
6566dde8d0 Initial FF1R implementation (#123)
FF1R
2021-11-28 22:32:08 +01:00
Fabian Dill
7b0b243607 MultiServer: remove promp_toolkit 2021-11-28 04:06:30 +01:00
Fabian Dill
d768379a8a CommonClient: move to explicit thread instead of thread executor to allow proper task cancelling. 2021-11-28 03:27:18 +01:00
Fabian Dill
5e84900ac4 Generate: provide version string under _Generator_Version instead of Archipelago 2021-11-28 02:57:15 +01:00
Fabian Dill
73ae180437 Settings: default collect to goal 2021-11-28 02:10:09 +01:00
Fabian Dill
2097164d32 Clients: handle "Too many close matches" for hint auto fill as well 2021-11-28 01:51:13 +01:00
Fabian Dill
9f0a8e6d48 LttP: add hint options "Vendors" and "Full"
LttP: fix hint grammar if a Location isn't an ALttPLocation
2021-11-27 22:58:12 +01:00
Fabian Dill
5ca737886b SoE: fix gameinfo typo 2021-11-27 22:58:12 +01:00
CaitSith2
11285fb0aa Fixed root cause of science-not-invited 9.223e+18 problem. 2021-11-26 09:16:42 -08:00
Fabian Dill
82de3c95e2 Clients: allow use of console input if stdin is available.
Such as unfrozen + gui
2021-11-26 06:02:03 +01:00
CaitSith2
b0bf66bdcb Factorio: more cleanup of code. Makes it easier to add a max liquids allowed option. 2021-11-25 18:28:07 -08:00
Fabian Dill
8af5855af6 Factorio: cleanup and optimize some requirement graph functions 2021-11-26 02:37:15 +01:00
CaitSith2
383d0f1a66 ensure the tech enabling chemical plant gets marked as advancement if required. 2021-11-25 17:04:22 -08:00
CaitSith2
4dfa1e3227 Merge remote-tracking branch 'origin/main' into main 2021-11-25 16:38:43 -08:00
CaitSith2
1a63ed970a fixed bug with not being able to use fluid barrels as last ingredient in balanced recipes.
fluid barrels don't have a direct recipe name to ingredient name match, but instead recipe name is fill-ingredient.
2021-11-25 16:38:33 -08:00
Fabian Dill
5b5d96971e WebHost: cleanup tracker.py #2 2021-11-25 21:10:28 +01:00
Fabian Dill
71767e8b79 WebHost: cleanup tracker.py 2021-11-25 21:04:02 +01:00
Fabian Dill
bd0b7ea80a WebHost: fix some PEP8 2021-11-25 20:48:58 +01:00
CaitSith2
744b12345a hard-code only steam. Water already appears at logistic-science pack, and crude-oil at chemical. 2021-11-25 10:17:23 -08:00
CaitSith2
2770014988 Merge remote-tracking branch 'origin/main' into main 2021-11-25 09:59:54 -08:00
CaitSith2
31b93dc2f4 Clarify not being able hand craft automation science if it has fluids. 2021-11-25 09:59:07 -08:00
Fabian Dill
81397936ef Merge pull request #141 from espeon65536/oot
Ocarina of Time updates
2021-11-25 17:57:31 +00:00
CaitSith2
722af0a3ca Now possible for randomized science packs/silo/satellite recipe to use fluids. 2021-11-25 09:44:01 -08:00
espeon65536
6641b13511 Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into oot 2021-11-24 17:57:06 -06:00
Fabian Dill
5a03c0edd6 WebHost: remove /hosted redirect, all current rooms should be migrated. 2021-11-24 23:49:00 +01:00
CaitSith2
9dbafd3b4b Factorio can now change death link state at runtime. 2021-11-24 01:55:36 -08:00
CaitSith2
1f5d1532e3 Move Death Link change tag to Common Client 2021-11-24 01:38:58 -08:00
Fabian Dill
1f61d8322c LttP: Attribute locations to dark/light world if they are directly present in them, ignoring routing requirements. 2021-11-23 22:47:41 +01:00
Fabian Dill
0c27dbe746 CommonClient: add /items and /locations 2021-11-23 21:47:23 +01:00
Fabian Dill
a3951c2621 Factorio: remove Desync detected message.
To my knowledge it has never warned about an actual desync, and even it did, the code right behind it fixes the desync.
2021-11-23 20:17:42 +01:00
Fabian Dill
c381df6563 MultiServer: filter new locations via sets, instead of if and only echo new checks 2021-11-23 20:16:48 +01:00
Fabian Dill
39ff471772 Factorio: add new Recipe Time randomize options 2021-11-23 19:10:26 +01:00
Chris Wilson
33c8d307ed Update Factorio Setup tutorial 2021-11-23 02:25:34 -05:00
Fabian Dill
26b336d6db MultiServer: fix IncompatibleVersion not triggering 2021-11-22 20:32:59 +01:00
Fabian Dill
fbd5bfd382 WebHost: remove duplicate zfile read 2021-11-22 17:57:23 +01:00
Fabian Dill
e0d6503590 Clients: allow accepting "Did you mean" by clicking on the question. 2021-11-22 17:44:14 +01:00
CaitSith2
b10d9040df Fix "could not randomize recipe" when both silo and satellite are...
...randomized recipes.
2021-11-21 18:25:28 -08:00
CaitSith2
415f045fd8 Fix a range bug on min_energy in make_balanced_recipe 2021-11-21 18:24:25 -08:00
Fabian Dill
f4e34372be Clients: remove color markup in clipboard copy 2021-11-21 23:45:15 +01:00
Fabian Dill
50264993b0 MultiServer: allow null exclusions on GetDataPackage 2021-11-21 18:11:51 +01:00
Fabian Dill
45a6598d18 Generate: return of the meta mystery 2021-11-21 18:09:06 +01:00
Fabian Dill
b205972e44 GitHub Hooks: update python 2021-11-21 17:50:20 +01:00
CaitSith2
3d19c39001 Include number of death_link connected clients in status. 2021-11-21 01:37:23 -08:00
espeon65536
428177bdca patch ROMs correctly with MQ spirit 2021-11-21 00:31:44 -06:00
CaitSith2
c21bd11b66 Merge branch 'satellite_victory' into main 2021-11-20 22:24:34 -08:00
CaitSith2
beb4949044 typo whoops 2021-11-20 21:44:16 -08:00
CaitSith2
1b4659276c Add randomized recipe for Satellite. 2021-11-20 21:44:16 -08:00
CaitSith2
affd707717 Add satellite recipe to needed_recipes if required. 2021-11-20 21:44:16 -08:00
CaitSith2
48ed394d02 Require sending a satellite for victory in space-science-pack seeds. 2021-11-20 21:44:16 -08:00
Fabian Dill
4f00f5509f CommonClient: keep command input focus after enter and allow tabbing between inputs 2021-11-21 05:47:19 +01:00
Fabian Dill
47c5c407ef CommonClient: consolidate Connect packet sending 2021-11-21 02:50:24 +01:00
Fabian Dill
a27d09f81a CommonClient: consolidate shutdown handling 2021-11-21 02:02:40 +01:00
espeon65536
2fb765455c OoT: change internal version number
Allows custom music to work with the ootrandomizer patcher for now
2021-11-20 16:34:50 -06:00
espeon65536
639e6f9a6c OoT: plando entrances 2021-11-20 15:36:57 -06:00
Fabian Dill
3e40de72b2 WebHost: add random choice to options 2021-11-20 17:37:08 +01:00
espeon65536
686812ee9e OoT: Add warp song text replacement 2021-11-20 09:49:33 -06:00
Fabian Dill
80c3b8bbca Factorio: always build dynamic advancement flag 2021-11-20 04:47:19 +01:00
Fabian Dill
824b932961 Clients: copyable log labels 2021-11-19 21:25:01 +01:00
Fabian Dill
7c3ba3bc42 Factorio: fix cumulative advancement flagging 2021-11-19 19:44:34 +01:00
Fabian Dill
c638a2cfb6 LttP: remove SM joke hint to reduce confusion 2021-11-18 18:57:31 +01:00
Fabian Dill
6e29101ecf Generate: remove duplicate .txt 2021-11-18 18:54:17 +01:00
CaitSith2
6b4445e122 move webhost configuration sample yaml to docs 2021-11-17 23:39:21 -08:00
CaitSith2
f7e89695e5 Comment the defaults, with instructions to uncomment and change the values. 2021-11-17 23:38:30 -08:00
Fabian Dill
9cb24280fa Clients: log exception to logfile 2021-11-17 22:46:32 +01:00
espeon65536
cf20c0781f OoT: fixed glitched not rolling
set internal value of shuffle_interior_entrances to False instead of 'off'
2021-11-17 17:05:46 +00:00
Fabian Dill
cd1c38515b WebHost: add remaining and collect to options page 2021-11-17 16:58:43 +01:00
Fabian Dill
a5ca4f1611 Options: document exclude locations and start location hints 2021-11-17 16:45:13 +01:00
alwaysintreble
fc022c98f2 Add example using the various options presented 2021-11-17 15:22:27 +00:00
alwaysintreble
52aebc3094 Add advanced settings guide; add additional info to setup guide 2021-11-17 15:22:27 +00:00
lordlou
2ef60c0cd9 [SM] added support for 65535 different player names in ROM (#133)
* added support for 65535 different player names in ROM
2021-11-17 02:31:46 +01:00
Fabian Dill
10411466d8 WebHost: make meta attribute LongStr instead of str 2021-11-16 23:59:40 +01:00
Fabian Dill
a6cfed0da2 reduce playerSettings.yaml to legacy LttP, remove when LttP transition is complete. 2021-11-16 21:39:08 +01:00
Fabian Dill
5d29184801 WebHost: retrieve PATCH_TARGET from config directly 2021-11-16 21:38:34 +01:00
CaitSith2
f4762cb3f2 Provide a sample webhost configuration yaml.
Not fully documented yet.
2021-11-16 11:01:16 -08:00
CaitSith2
899e9331fa Make /connect archipelago.gg:port reflect PATCH_TARGET. 2021-11-16 11:00:36 -08:00
espeon65536
cc3d5e60a1 OoT: ensure that the last entrance placed in a one-way pool doesn't assume the other targets are reachable 2021-11-16 08:24:30 -06:00
espeon65536
b217e734cb OoT: fixed Spirit compass chest and Silver Gauntlets chest being moved with wrong condition in CSMC 2021-11-15 10:26:13 -06:00
espeon65536
09fb956ba6 OoT Adjuster: remove -comp from patched output rom name 2021-11-15 08:46:23 -06:00
espeon65536
d8dedbe7fa OoT Adjuster: patch death_link flag 2021-11-15 08:45:30 -06:00
espeon65536
b07345cee7 OoT: actually make misc_hints changeable 2021-11-15 08:40:13 -06:00
espeon65536
4709902819 OoT: add misc_hints option 2021-11-15 08:38:32 -06:00
espeon65536
af9ab30bdf OoT: fix potion shop/cow ER validation being always active 2021-11-15 08:36:00 -06:00
espeon65536
f5e82c0417 Add oot adjuster to setup scripts 2021-11-15 08:32:54 -06:00
espeon65536
a9f6317032 clean up imports and errors 2021-11-15 08:14:55 -06:00
espeon65536
cf09c2aa3d OoT Adjuster: add support for adjusting patch files, outputting ROMs 2021-11-14 22:58:56 -06:00
espeon65536
a53d4219b3 OoT Adjuster source code 2021-11-14 16:50:49 -06:00
espeon65536
f9e1db41e9 OoT: implement decoupled entrance pools 2021-11-14 07:30:40 -06:00
espeon65536
61ffdff207 OoT: implement mixed entrance pools 2021-11-14 07:06:09 -06:00
espeon65536
3bcd85aa0a OoT: add options for mixed pools and decoupled entrances 2021-11-14 07:05:58 -06:00
espeon65536
8b60a9e2f0 OoT: Add display names to ER options 2021-11-14 06:55:32 -06:00
espeon65536
e90b2c3a5c OoT: kill door of time collision while it's opening 2021-11-13 14:07:17 -06:00
espeon65536
3d6c82861a OoT: give a full Slingshot, Bomb Bag, or Bow for skip_child_zelda 2021-11-13 13:52:50 -06:00
espeon65536
54cd32872e Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into main 2021-11-13 13:08:54 -06:00
espeon65536
2f9e530fd8 OoT: fixed a bug where free_scarecrow and entrance shuffles could not be rolled together 2021-11-12 08:20:40 -06:00
118 changed files with 13949 additions and 14024 deletions

View File

@@ -12,10 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@@ -12,10 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip

4
.gitignore vendored
View File

@@ -152,3 +152,7 @@ dmypy.json
cython_debug/
Archipelago.zip
#minecraft server stuff
jdk*/
minecraft*/

View File

@@ -255,14 +255,18 @@ class MultiWorld():
def get_items(self) -> list:
return [loc.item for loc in self.get_filled_locations()] + self.itempool
def find_items(self, item, player: int) -> List[Location]:
def find_item_locations(self, item, player: int) -> List[Location]:
return [location for location in self.get_locations() if
location.item is not None and location.item.name == item and location.item.player == player]
location.item and location.item.name == item and location.item.player == player]
def find_item(self, item, player: int) -> Location:
return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player)
def find_items_in_locations(self, items: Set[str], player: int) -> List[Location]:
return [location for location in self.get_locations() if
location.item and location.item.name in items and location.item.player == player]
def create_item(self, item_name: str, player: int) -> Item:
return self.worlds[player].create_item(item_name)
@@ -582,7 +586,7 @@ class CollectionState(object):
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
shop in self.world.shops)
def item_count(self, item, player: int) -> int:
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def has_triforce_pieces(self, count: int, player: int) -> bool:
@@ -709,23 +713,23 @@ class CollectionState(object):
def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.world.required_medallions[player][1], player)
def can_boots_clip_lw(self, player):
def can_boots_clip_lw(self, player: int):
if self.world.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player):
def can_boots_clip_dw(self, player: int):
if self.world.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player):
def can_get_glitched_speed_lw(self, player: int):
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_superbunny_mirror_with_sword(self, player):
def can_superbunny_mirror_with_sword(self, player: int):
return self.has('Magic Mirror', player) and self.has_sword(player)
def can_get_glitched_speed_dw(self, player: int):
@@ -754,7 +758,7 @@ class CollectionState(object):
return changed
def remove(self, item):
def remove(self, item: Item):
changed = self.world.worlds[item.player].remove(self, item)
if changed:
# invalidate caches, nothing can be trusted anymore now
@@ -772,14 +776,14 @@ class RegionType(int, Enum):
Dungeon = 4
@property
def is_indoors(self):
def is_indoors(self) -> bool:
"""Shorthand for checking if Cave or Dungeon"""
return self in (RegionType.Cave, RegionType.Dungeon)
class Region(object):
def __init__(self, name: str, type, hint, player: int, world: Optional[MultiWorld] = None):
def __init__(self, name: str, type: str, hint, player: int, world: Optional[MultiWorld] = None):
self.name = name
self.type = type
self.entrances = []
@@ -794,12 +798,12 @@ class Region(object):
self.hint_text = hint
self.player = player
def can_reach(self, state: CollectionState):
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player]
def can_reach_private(self, state: CollectionState):
def can_reach_private(self, state: CollectionState) -> bool:
for entrance in self.entrances:
if entrance.can_reach(state):
if not self in state.path:
@@ -827,7 +831,7 @@ class Entrance(object):
self.player = player
self.hide_path = False
def can_reach(self, state):
def can_reach(self, state: CollectionState) -> bool:
if self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and not self in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
@@ -835,7 +839,7 @@ class Entrance(object):
return False
def connect(self, region, addresses=None, target=None):
def connect(self, region: Region, addresses=None, target = None):
self.connected_region = region
self.target = target
self.addresses = addresses
@@ -861,11 +865,11 @@ class Dungeon(object):
self.world = None
@property
def boss(self):
def boss(self) -> Optional[Boss]:
return self.bosses.get(None, None)
@boss.setter
def boss(self, value):
def boss(self, value: Optional[Boss]):
self.bosses[None] = value
@property
@@ -892,7 +896,7 @@ class Dungeon(object):
class Boss():
def __init__(self, name, enemizer_name, defeat_rule, player: int):
def __init__(self, name: str, enemizer_name: str, defeat_rule, player: int):
self.name = name
self.enemizer_name = enemizer_name
self.defeat_rule = defeat_rule

View File

@@ -11,11 +11,11 @@ import websockets
import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient")
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from Utils import Version
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
logger = logging.getLogger("Client")
@@ -39,13 +39,13 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.connect(address if address else None))
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect())
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True
def _cmd_received(self) -> bool:
@@ -81,7 +81,20 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_items(self):
"""List all item names for the currently running game."""
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
state = ClientStatus.CLIENT_READY
@@ -89,10 +102,12 @@ class ClientCommandProcessor(CommandProcessor):
else:
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]))
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str):
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]))
raw = self.ctx.on_user_say(raw)
if raw:
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext():
@@ -149,7 +164,7 @@ class CommonContext():
self.set_getters(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self))
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def total_locations(self) -> typing.Optional[int]:
@@ -230,13 +245,24 @@ class CommonContext():
self.password = await self.console_input()
return self.password
async def send_connect(self, **kwargs):
payload = {
"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address=None):
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address))
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def on_print(self, args: dict):
logger.info(args["text"])
@@ -252,6 +278,11 @@ class CommonContext():
"""For custom package handling in subclasses."""
pass
def on_user_say(self, text: str) -> typing.Optional[str]:
"""Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned."""
return text
def update_permissions(self, permissions: typing.Dict[str, int]):
for permission_name, permission_flag in permissions.items():
try:
@@ -261,6 +292,20 @@ class CommonContext():
except Exception as e: # safeguard against permissions that may be implemented in the future
logger.exception(e)
async def shutdown(self):
self.server_address = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
await self.server_task
while self.input_requests > 0:
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
# DeathLink hooks
def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link)
@@ -271,7 +316,7 @@ class CommonContext():
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
logger.info("Sending death to your friends...")
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
await self.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"],
@@ -282,6 +327,15 @@ class CommonContext():
}
}])
async def update_death_link(self, death_link):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
else:
self.tags -= {"DeathLink"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
@@ -340,14 +394,14 @@ async def server_loop(ctx: CommonContext, address=None):
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx))
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2
async def server_autoreconnect(ctx: CommonContext):
await asyncio.sleep(ctx.current_reconnect_delay)
if ctx.server_address and ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx))
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
async def process_server_cmd(ctx: CommonContext, args: dict):
@@ -501,12 +555,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
async def console_loop(ctx: CommonContext):
import sys
commandprocessor = ctx.command_processor(ctx)
queue = asyncio.Queue()
stream_input(sys.stdin, queue)
while not ctx.exit_event.is_set():
try:
input_text = await asyncio.get_event_loop().run_in_executor(
None, sys.stdin.readline
)
input_text = input_text.strip()
input_text = await queue.get()
queue.task_done()
if ctx.input_requests > 0:
ctx.input_requests -= 1
@@ -534,6 +588,7 @@ if __name__ == '__main__':
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame"}
game = "Archipelago"
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -542,11 +597,7 @@ if __name__ == '__main__':
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}])
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
@@ -555,27 +606,19 @@ if __name__ == '__main__':
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
input_task = None
if gui_enabled:
input_task = None
from kvui import TextManager
ctx.ui = TextManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
await ctx.exit_event.wait()
ctx.server_address = None
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await ctx.shutdown()
if ui_task:
await ui_task
@@ -584,7 +627,7 @@ if __name__ == '__main__':
import colorama
parser = get_base_parser(description="Gameless Archipelago Client, for text interfaction.")
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()

255
FF1Client.py Normal file
View File

@@ -0,0 +1,255 @@
import asyncio
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
class FF1Context(CommonContext):
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'Final Fantasy'
self.awaiting_rom = False
command_processor = FF1CommandProcessor
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.game = self.games.get(self.slot, None)
asyncio.create_task(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == 'PrintJSON':
print_type = args['type']
item = args['item']
receiving_player_id = args['receiving']
receiving_player_name = self.player_names[receiving_player_id]
sending_player_id = item.player
sending_player_name = self.player_names[item.player]
if print_type == 'Hint':
msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \
f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}"
self._set_message(msg, item.item)
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
if sending_player_id == self.slot:
if receiving_player_id == self.slot:
msg = f"You found your own {self.item_name_getter(item.item)}"
else:
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
else:
if receiving_player_id == sending_player_id:
msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}"
else:
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
f"{receiving_player_name}"
self._set_message(msg, item.item)
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_name_getter(location)}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_name_getter(location) for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_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 two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
input_task = None
from kvui import FF1Manager
ctx.ui = FF1Manager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
parser = get_base_parser()
args, rest = parser.parse_known_args()
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
colorama.deinit()

View File

@@ -5,6 +5,7 @@ import json
import string
import copy
import subprocess
import sys
import time
import random
@@ -15,7 +16,7 @@ from queue import Queue
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient")
Utils.init_logging("FactorioClient", exception_logger="Client")
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
get_base_parser
@@ -65,22 +66,13 @@ class FactorioContext(CommonContext):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
if not self.auth:
if self.rcon_client:
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.")
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_msgs([{
"cmd": 'Connect',
'password': self.password,
'name': self.auth,
'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(),
'game': "Factorio"
}])
await self.send_connect()
def on_print(self, args: dict):
super(FactorioContext, self).on_print(args)
@@ -134,13 +126,15 @@ async def game_watcher(ctx: FactorioContext):
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
await ctx.update_death_link(data["death_link"])
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if ctx.locations_checked != research_data:
bridge_logger.info(
bridge_logger.debug(
f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
@@ -148,7 +142,8 @@ async def game_watcher(ctx: FactorioContext):
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
await ctx.send_death()
if "DeathLink" in ctx.tags:
await ctx.send_death()
await asyncio.sleep(0.1)
@@ -196,7 +191,8 @@ async def factorio_server_watcher(ctx: FactorioContext):
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
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:
@@ -205,7 +201,9 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
factorio_server_logger.debug(msg)
else:
factorio_server_logger.info(msg)
if ctx.rcon_client:
commands = {}
while ctx.send_index < len(ctx.items_received):
@@ -234,14 +232,13 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_process.wait(5)
def get_info(ctx, rcon_client):
async def get_info(ctx, rcon_client):
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))
if death_link:
ctx.tags.add("DeathLink")
await ctx.update_death_link(death_link)
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
@@ -280,7 +277,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
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.")
get_info(ctx, rcon_client)
await get_info(ctx, rcon_client)
await asyncio.sleep(0.01)
except Exception as e:
@@ -301,14 +298,15 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
input_task = None
if gui_enabled:
input_task = None
from kvui import FactorioManager
ctx.ui = FactorioManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
succesful_launch = await factorio_server_task
if succesful_launch:
@@ -322,14 +320,7 @@ async def main(args):
await progression_watcher
await factorio_server_task
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await ctx.shutdown()
if ui_task:
await ui_task

View File

@@ -90,7 +90,8 @@ def main(args=None, callback=ERmain):
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
del(meta_weights["meta_description"])
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
else:
@@ -126,7 +127,7 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
Utils.init_logging(f"Generate_{seed}.txt", loglevel=args.log_level)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
@@ -139,17 +140,17 @@ def main(args=None, callback=ERmain):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
if meta_weights:
for player, path in player_path_cache.items():
weights_cache[path].setdefault("meta_ignore", [])
for key in meta_weights:
option = get_choice(key, meta_weights)
if option is not None:
for player, path in player_path_cache.items():
players_meta = weights_cache[path].get("meta_ignore", [])
if key not in players_meta:
weights_cache[path][key] = option
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
weights_cache[path][key] = option
for category_name, category_dict in meta_weights.items():
for key in category_dict:
option = get_choice(key, category_dict)
if option is not None:
for player, path in player_path_cache.items():
if category_name is None:
weights_cache[path][key] = option
elif category_name not in weights_cache[path]:
raise Exception(f"Meta: Category {category_name} is not present in {path}.")
else:
weights_cache[path][category_name][key] = option
name_counter = Counter()
erargs.player_settings = {}
@@ -352,7 +353,7 @@ def roll_linked_options(weights: dict) -> dict:
def roll_triggers(weights: dict, triggers: list) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
weights["_Generator_Version"] = Utils.__version__
for i, option_set in enumerate(triggers):
try:
currently_targeted_weights = weights
@@ -493,7 +494,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
handle_option(ret, game_weights, option_key, option)
if "items" in plando_options:
ret.plando_items = roll_item_plando(world_type, game_weights)
if ret.game == "Minecraft":
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if "connections" in plando_options:
@@ -503,7 +504,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)

View File

@@ -197,8 +197,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
@@ -215,6 +213,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
@@ -319,8 +321,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occured.
if multidata_task:
multidata_task.result()
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures):
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')

View File

@@ -15,6 +15,7 @@ atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
forge_version = "1.17.1-37.0.109"
def prompt_yes_no(prompt):
@@ -30,15 +31,6 @@ def prompt_yes_no(prompt):
print('Please respond with "y" or "n".')
# Find Forge jar file; raise error if not found
def find_forge_jar(forge_dir):
for entry in os.scandir(forge_dir):
if ".jar" in entry.name and "forge" in entry.name:
logging.info(f"Found forge .jar: {entry.name}")
return entry.name
raise FileNotFoundError(f"Could not find forge .jar in {forge_dir}.")
# Create mods folder if needed; find AP randomizer jar; return None if not found.
def find_ap_randomizer_jar(forge_dir):
mods_dir = os.path.join(forge_dir, 'mods')
@@ -77,37 +69,57 @@ def replace_apmc_files(forge_dir, apmc_file):
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
import json
with open(apmc_file, 'r') as f:
data = json.loads(b64decode(f.read()))
return data
# Check mod version, download new mod from GitHub releases page if needed.
def update_mod(forge_dir):
def update_mod(forge_dir, apmc_file, get_prereleases=False):
ap_randomizer = find_ap_randomizer_jar(forge_dir)
if apmc_file is not None:
data = read_apmc_file(apmc_file)
minecraft_version = data.get('minecraft_version', '')
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
resp = requests.get(client_releases_endpoint)
if resp.status_code == 200: # OK
latest_release = resp.json()[0]
if ap_randomizer != latest_release['assets'][0]['name']:
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{latest_release['assets'][0]['name']}")
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
try:
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
(apmc_file is None or minecraft_version in release['assets'][0]['name']),
resp.json()))
if ap_randomizer != latest_release['assets'][0]['name']:
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{latest_release['assets'][0]['name']}")
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
logging.info(f"You do not have the AP randomizer mod installed.")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
except StopIteration:
logging.warning(f"No compatible mod version found for {minecraft_version}.")
if not prompt_yes_no("Run server anyway?"):
sys.exit(0)
else:
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.")
@@ -139,11 +151,69 @@ def check_eula(forge_dir):
sys.exit(0)
# Run the Forge server. Return process object
def run_forge_server(forge_dir, heap_arg):
forge_server = find_forge_jar(forge_dir)
# get the current JDK16
def find_jdk_dir() -> str:
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith("jdk16"):
return os.path.abspath(entry)
java_exe = os.path.abspath(os.path.join('jre8', 'bin', 'java.exe'))
# get the java exe location
def find_jdk() -> str:
jdk = find_jdk_dir()
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
# Download Corretto 16 (Amazon JDK)
def download_java():
jdk = find_jdk_dir()
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = "https://corretto.aws/downloads/latest/amazon-corretto-16-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
# download and install forge
def install_forge(directory: str):
jdk = find_jdk()
if jdk is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
install_process = Popen(argstring)
install_process.wait()
os.remove(forge_install_jar)
# Run the Forge server. Return process object
def run_forge_server(forge_dir: str, heap_arg):
java_exe = find_jdk()
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
@@ -152,7 +222,13 @@ def run_forge_server(forge_dir, heap_arg):
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
argstring = ' '.join([java_exe, heap_arg, "-jar", forge_server, "-nogui"])
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
win_args = []
with open(args_file) as argfile:
for line in argfile:
win_args.append(line.strip())
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
logging.info(f"Running Forge server: {argstring}")
os.chdir(forge_dir)
return Popen(argstring)
@@ -162,6 +238,10 @@ if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--prerelease', default=False, action='store_true',
help="Auto-update prerelease versions.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
@@ -173,6 +253,12 @@ if __name__ == '__main__':
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
if args.install:
print("Installing Java and Minecraft Forge")
download_java()
install_forge(forge_dir)
sys.exit(0)
if apmc_file is not None and not os.path.isfile(apmc_file):
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
if not os.path.isdir(forge_dir):
@@ -180,7 +266,7 @@ if __name__ == '__main__':
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir)
update_mod(forge_dir, apmc_file, args.prerelease)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, max_heap)

View File

@@ -22,8 +22,7 @@ ModuleUpdate.update()
import websockets
import colorama
import prompt_toolkit
from prompt_toolkit.patch_stdout import patch_stdout
from fuzzywuzzy import process as fuzzy_process
import NetUtils
@@ -119,7 +118,7 @@ class Context:
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
self.item_cheat = item_cheat
self.running = True
self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[
team_slot, datetime.datetime] = {} # datetime of last new item check
self.client_connection_timers: typing.Dict[
@@ -336,7 +335,7 @@ class Context:
if not self.auto_saver_thread:
def save_regularly():
import time
while self.running:
while not self.exit_event.is_set():
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving via thread.")
@@ -591,10 +590,12 @@ def get_status_string(ctx: Context, team: int):
text = "Player Status on your team:"
for slot in ctx.locations:
connected = len(ctx.clients[team][slot])
death_link = len([client for client in ctx.clients[team][slot] if "DeathLink" in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
death_text = f" {death_link} of which are death link" if connected else ""
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{goal_text} {completion_text}"
f"{death_text}{goal_text} {completion_text}"
return text
@@ -652,27 +653,27 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int]):
new_locations = set(locations) - ctx.location_checks[team, slot]
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if location in ctx.locations[slot]:
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx)
ctx.broadcast(ctx.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": locations, # duplicated data, but used for coop
"checked_locations": new_locations, # send back new checks only
}])
ctx.save()
@@ -1242,6 +1243,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
game = ctx.games[slot]
if "IgnoreGame" not in args["tags"] and args['game'] != game:
errors.add('InvalidGame')
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple:
@@ -1257,9 +1261,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.auth = False # swapping Team/Slot
client.team = team
client.slot = slot
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
ctx.client_ids[client.team, client.slot] = args["uuid"]
ctx.clients[team][slot].append(client)
client.version = args['version']
@@ -1283,8 +1285,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage":
exclusions = set(args.get("exclusions", []))
exclusions = args.get("exclusions", [])
if exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions}
package = network_data_package.copy()
@@ -1405,7 +1408,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
asyncio.create_task(self.ctx.server.ws_server._close())
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.running = False
self.ctx.exit_event.set()
return True
@mark_raw
@@ -1562,11 +1565,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
async def console(ctx: Context):
session = prompt_toolkit.PromptSession()
while ctx.running:
with patch_stdout():
input_text = await session.prompt_async()
import sys
queue = asyncio.Queue()
Utils.stream_input(sys.stdin, queue)
while not ctx.exit_event.is_set():
try:
# I don't get why this while loop is needed. Works fine without it on clients,
# but the queue.get() for server never fulfills if the queue is empty when entering the await.
while queue.qsize() == 0:
await asyncio.sleep(0.05)
input_text = await queue.get()
queue.task_done()
ctx.commandprocessor(input_text)
except:
import traceback
@@ -1632,10 +1641,10 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
while ctx.running:
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
asyncio.create_task(ctx.server.ws_server._close())
ctx.running = False
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
@@ -1646,7 +1655,7 @@ async def auto_shutdown(ctx, to_cancel=None):
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
asyncio.create_task(ctx.server.ws_server._close())
ctx.running = False
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
@@ -1690,7 +1699,8 @@ async def main(args: argparse.Namespace):
console_task = asyncio.create_task(console(ctx))
if ctx.auto_shutdown:
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
await console_task
await ctx.exit_event.wait()
console_task.cancel()
if ctx.shutdown_task:
await ctx.shutdown_task

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
import logging
import typing
import enum
from json import JSONEncoder, JSONDecoder
@@ -138,11 +140,11 @@ class HandlerMeta(type):
break
def __init__(self, *args, **kwargs):
if orig_init:
orig_init(self, *args, **kwargs)
# turn functions into bound methods
self.handlers = {name: method.__get__(self, type(self)) for name, method in
handlers.items()}
if orig_init:
orig_init(self, *args, **kwargs)
attrs['__init__'] = __init__
return super(HandlerMeta, mcs).__new__(mcs, name, bases, attrs)
@@ -192,11 +194,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node)
def _handle_item_name(self, node: JSONMessagePart):
# todo: use a better info source
from worlds.alttp.Items import progression_items
node["color"] = 'green' if node.get("found", False) else 'cyan'
if node["text"] in progression_items:
node["color"] += ";white_bg"
node["color"] = 'cyan'
return self._handle_color(node)
def _handle_item_id(self, node: JSONMessagePart):
@@ -205,13 +203,13 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart):
node["color"] = 'blue_bg;white'
node["color"] = 'green'
return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.location_name_getter(item_id)
return self._handle_item_name(node)
return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):
node["color"] = 'blue'
@@ -282,10 +280,11 @@ class Hint(typing.NamedTuple):
add_json_text(parts, self.entrance, type="entrance_name")
else:
add_json_text(parts, "'s World")
add_json_text(parts, ". ")
if self.found:
add_json_text(parts, ". (found)")
add_json_text(parts, "(found)", type="color", color="green")
else:
add_json_text(parts, ".")
add_json_text(parts, "(not found)", type="color", color="red")
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,

237
OoTAdjuster.py Normal file
View File

@@ -0,0 +1,237 @@
import tkinter as tk
import argparse
import logging
import random
import os
from itertools import chain
from BaseClasses import MultiWorld
from Options import Choice, Range, Toggle
from worlds.oot import OOTWorld
from worlds.oot.Cosmetics import patch_cosmetics
from worlds.oot.Options import cosmetic_options, sfx_options
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from Utils import local_path
logger = logging.getLogger('OoTAdjuster')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--rom', default='',
help='Path to an OoT randomized ROM to adjust.')
parser.add_argument('--vanilla_rom', default='',
help='Path to a vanilla OoT ROM for patching.')
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
parser.add_argument('--'+name, default=None,
help=option.__doc__)
parser.add_argument('--is_glitched', default=False, action='store_true',
help='Setting this to true will enable protection on kokiri tunic colors for weirdshot.')
parser.add_argument('--deathlink',
help='Enable DeathLink system', action='store_true')
args = parser.parse_args()
if not os.path.isfile(args.rom):
adjustGUI()
else:
adjust(args)
def adjustGUI():
from tkinter import Tk, LEFT, BOTTOM, TOP, E, W, \
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
OptionMenu, filedialog, messagebox, ttk
from argparse import Namespace
from Main import __version__ as MWVersion
window = tk.Tk()
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
set_icon(window)
opts = Namespace()
# Select ROM
romDialogFrame = Frame(window)
romLabel = Label(romDialogFrame, text='Rom/patch to adjust')
vanillaLabel = Label(romDialogFrame, text='OoT Base Rom')
opts.rom = StringVar()
opts.vanilla_rom = StringVar(value="The Legend of Zelda - Ocarina of Time.z64")
romEntry = Entry(romDialogFrame, textvariable=opts.rom)
vanillaEntry = Entry(romDialogFrame, textvariable=opts.vanilla_rom)
def RomSelect():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".z64", ".n64", ".apz5")), ("All Files", "*")])
opts.rom.set(rom)
def VanillaSelect():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".z64", ".n64")), ("All Files", "*")])
opts.vanilla_rom.set(rom)
romSelectButton = Button(romDialogFrame, text='Select Rom', command=RomSelect)
vanillaSelectButton = Button(romDialogFrame, text='Select Rom', command=VanillaSelect)
romDialogFrame.pack(side=TOP, expand=True, fill=X)
romLabel.pack(side=LEFT)
romEntry.pack(side=LEFT, expand=True, fill=X)
romSelectButton.pack(side=LEFT)
vanillaLabel.pack(side=LEFT)
vanillaEntry.pack(side=LEFT, expand=True, fill=X)
vanillaSelectButton.pack(side=LEFT)
# Cosmetic options
romSettingsFrame = Frame(window)
def dropdown_option(type, option_name, row, column):
if type == 'cosmetic':
option = cosmetic_options[option_name]
elif type == 'sfx':
option = sfx_options[option_name]
optionFrame = Frame(romSettingsFrame)
optionFrame.grid(row=row, column=column, sticky=E)
optionLabel = Label(optionFrame, text=option.displayname)
optionLabel.pack(side=LEFT)
setattr(opts, option_name, StringVar())
getattr(opts, option_name).set(option.name_lookup[option.default])
optionMenu = OptionMenu(optionFrame, getattr(opts, option_name), *option.name_lookup.values())
optionMenu.pack(side=LEFT)
dropdown_option('cosmetic', 'default_targeting', 0, 0)
dropdown_option('cosmetic', 'display_dpad', 0, 1)
dropdown_option('cosmetic', 'correct_model_colors', 0, 2)
dropdown_option('cosmetic', 'background_music', 1, 0)
dropdown_option('cosmetic', 'fanfares', 1, 1)
dropdown_option('cosmetic', 'ocarina_fanfares', 1, 2)
dropdown_option('cosmetic', 'kokiri_color', 2, 0)
dropdown_option('cosmetic', 'goron_color', 2, 1)
dropdown_option('cosmetic', 'zora_color', 2, 2)
dropdown_option('cosmetic', 'silver_gauntlets_color', 3, 0)
dropdown_option('cosmetic', 'golden_gauntlets_color', 3, 1)
dropdown_option('cosmetic', 'mirror_shield_frame_color', 3, 2)
dropdown_option('cosmetic', 'navi_color_default_inner', 4, 0)
dropdown_option('cosmetic', 'navi_color_default_outer', 4, 1)
dropdown_option('cosmetic', 'navi_color_enemy_inner', 5, 0)
dropdown_option('cosmetic', 'navi_color_enemy_outer', 5, 1)
dropdown_option('cosmetic', 'navi_color_npc_inner', 6, 0)
dropdown_option('cosmetic', 'navi_color_npc_outer', 6, 1)
dropdown_option('cosmetic', 'navi_color_prop_inner', 7, 0)
dropdown_option('cosmetic', 'navi_color_prop_outer', 7, 1)
# sword_trail_duration, 8, 2
dropdown_option('cosmetic', 'sword_trail_color_inner', 8, 0)
dropdown_option('cosmetic', 'sword_trail_color_outer', 8, 1)
dropdown_option('cosmetic', 'bombchu_trail_color_inner', 9, 0)
dropdown_option('cosmetic', 'bombchu_trail_color_outer', 9, 1)
dropdown_option('cosmetic', 'boomerang_trail_color_inner', 10, 0)
dropdown_option('cosmetic', 'boomerang_trail_color_outer', 10, 1)
dropdown_option('cosmetic', 'heart_color', 11, 0)
dropdown_option('cosmetic', 'magic_color', 12, 0)
dropdown_option('cosmetic', 'a_button_color', 11, 1)
dropdown_option('cosmetic', 'b_button_color', 11, 2)
dropdown_option('cosmetic', 'c_button_color', 12, 1)
dropdown_option('cosmetic', 'start_button_color', 12, 2)
dropdown_option('sfx', 'sfx_navi_overworld', 14, 0)
dropdown_option('sfx', 'sfx_navi_enemy', 14, 1)
dropdown_option('sfx', 'sfx_low_hp', 14, 2)
dropdown_option('sfx', 'sfx_menu_cursor', 15, 0)
dropdown_option('sfx', 'sfx_menu_select', 15, 1)
dropdown_option('sfx', 'sfx_nightfall', 15, 2)
dropdown_option('sfx', 'sfx_horse_neigh', 16, 0)
dropdown_option('sfx', 'sfx_hover_boots', 16, 1)
dropdown_option('sfx', 'sfx_ocarina', 16, 2)
# Special cases
# Sword trail duration is a range
option = cosmetic_options['sword_trail_duration']
optionFrame = Frame(romSettingsFrame)
optionFrame.grid(row=8, column=2, sticky=E)
optionLabel = Label(optionFrame, text=option.displayname)
optionLabel.pack(side=LEFT)
setattr(opts, 'sword_trail_duration', StringVar())
getattr(opts, 'sword_trail_duration').set(option.default)
optionMenu = OptionMenu(optionFrame, getattr(opts, 'sword_trail_duration'), *range(4, 21))
optionMenu.pack(side=LEFT)
# Glitched is a checkbox
opts.is_glitched = IntVar(value=0)
glitched_checkbox = Checkbutton(romSettingsFrame, text="Glitched Logic?", variable=opts.is_glitched)
glitched_checkbox.grid(row=17, column=0, sticky=W)
# Deathlink is a checkbox
opts.deathlink = IntVar(value=0)
deathlink_checkbox = Checkbutton(romSettingsFrame, text="DeathLink (Team Deaths)", variable=opts.deathlink)
deathlink_checkbox.grid(row=17, column=1, sticky=W)
romSettingsFrame.pack(side=TOP)
def adjustRom():
try:
guiargs = Namespace()
options = vars(opts)
for o in options:
result = options[o].get()
if result == 'true':
result = True
if result == 'false':
result = False
setattr(guiargs, o, result)
guiargs.sword_trail_duration = int(guiargs.sword_trail_duration)
path = adjust(guiargs)
except Exception as e:
logging.exception(e)
messagebox.showerror(title="Error while adjusting Rom", message=str(e))
else:
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
# Adjust button
bottomFrame = Frame(window)
adjustButton = Button(bottomFrame, text='Adjust Rom', command=adjustRom)
adjustButton.pack(side=BOTTOM, padx=(5, 5))
bottomFrame.pack(side=BOTTOM, pady=(5, 5))
window.mainloop()
def set_icon(window):
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args):
# Create a fake world and OOTWorld to use as a base
world = MultiWorld(1)
world.slot_seeds = {1: random}
ootworld = OOTWorld(world, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
result = getattr(args, name, None)
if result is None:
if issubclass(option, Choice):
result = option.name_lookup[option.default]
elif issubclass(option, Range) or issubclass(option, Toggle):
result = option.default
else:
raise Exception("Unsupported option type")
setattr(ootworld, name, result)
ootworld.logic_rules = 'glitched' if args.is_glitched else 'glitchless'
ootworld.death_link = args.deathlink
if os.path.splitext(args.rom)[-1] in ['.z64', '.n64']:
# Load up the ROM
rom = Rom(file=args.rom, force_use=True)
elif os.path.splitext(args.rom)[-1] == '.apz5':
# Load vanilla ROM
rom = Rom(file=args.vanilla_rom, force_use=True)
# Patch file
apply_patch_file(rom, args.rom)
else:
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
# Call patch_cosmetics
patch_cosmetics(ootworld, rom)
rom.write_byte(rom.sym('DEATH_LINK'), args.deathlink)
# Output new file
path_pieces = os.path.splitext(args.rom)
decomp_path = path_pieces[0] + '-adjusted-decomp.n64'
comp_path = path_pieces[0] + '-adjusted.n64'
rom.write_to_file(decomp_path)
compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path)
return comp_path
if __name__ == '__main__':
main()

View File

@@ -277,8 +277,8 @@ class OptionList(Option):
supports_weighting = False
value: list
def __init__(self, value: typing.List[str, typing.Any]):
self.value = value
def __init__(self, value: typing.List[typing.Any]):
self.value = value or []
super(OptionList, self).__init__()
@classmethod
@@ -292,7 +292,7 @@ class OptionList(Option):
return cls.from_text(str(data))
def get_option_name(self, value):
return ", ".join(value)
return ", ".join(map(str, value))
def __contains__(self, item):
return item in self.value
@@ -379,6 +379,7 @@ class StartHints(ItemSet):
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
displayname = "Start Location Hints"
@@ -399,7 +400,7 @@ per_game_common_options = {
"start_inventory": StartInventory,
"start_hints": StartHints,
"start_location_hints": StartLocationHints,
"exclude_locations": OptionSet
"exclude_locations": ExcludeLocations
}
if __name__ == "__main__":

View File

@@ -87,7 +87,7 @@ def get_base_rom_data(game: str):
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
file_name = Utils.get_options()["soe_options"]["rom"]
file_name = Utils.get_options()["soe_options"]["rom_file"]
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
else:
raise RuntimeError("Selected game for base rom not found.")

View File

@@ -15,7 +15,7 @@ from json import loads, dumps
from Utils import get_item_name_from_id, init_logging
if __name__ == "__main__":
init_logging("SNIClient")
init_logging("SNIClient", exception_logger="Client")
import colorama
@@ -73,7 +73,7 @@ class LttPCommandProcessor(ClientCommandProcessor):
pass
self.ctx.snes_reconnect_address = None
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number))
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
return True
def _cmd_snes_close(self) -> bool:
@@ -143,12 +143,7 @@ class Context(CommonContext):
self.awaiting_rom = False
self.auth = self.rom
auth = base64.b64encode(self.rom).decode()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(),
'game': self.game
}])
await self.send_connect(name=auth)
def on_deathlink(self, data: dict):
if not self.killing_player_task or self.killing_player_task.done():
@@ -180,15 +175,22 @@ async def deathlink_kill_player(ctx: Context):
snes_buffered_write(ctx, WRAM_START + 0x0373, bytes([8])) # deal 1 full heart of damage at next opportunity
elif ctx.game == GAME_SM:
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([0, 0])) # set current health to 0
if not ctx.death_link_allow_survive:
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
await snes_flush_writes(ctx)
await asyncio.sleep(1)
gamemode = None
if ctx.game == GAME_ALTTP:
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
if not gamemode or gamemode[0] in DEATH_MODES:
ctx.death_state = DeathState.dead
elif ctx.game == GAME_SM:
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if not gamemode or gamemode[0] in (DEATH_MODES if ctx.game == GAME_ALTTP else SM_DEATH_MODES):
ctx.death_state = DeathState.dead
health = await snes_read(ctx, WRAM_START + 0x09C2, 2)
if health is not None:
health = health[0] | (health[1] << 8)
if not gamemode or gamemode[0] in SM_DEATH_MODES or (ctx.death_link_allow_survive and health is not None and health > 0):
ctx.death_state = DeathState.dead
ctx.last_death_link = time.time()
@@ -685,11 +687,6 @@ async def snes_disconnect(ctx: Context):
async def snes_autoreconnect(ctx: Context):
# unfortunately currently broken. See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1033
# with prompt_toolkit.shortcuts.ProgressBar() as pb:
# for _ in pb(range(100)):
# await asyncio.sleep(RECONNECT_DELAY/100)
await asyncio.sleep(SNES_RECONNECT_DELAY)
if ctx.snes_reconnect_address and ctx.snes_socket is None:
await snes_connect(ctx, ctx.snes_reconnect_address)
@@ -894,10 +891,11 @@ async def game_watcher(ctx: Context):
if not ctx.rom:
ctx.finished_game = False
gameName = await snes_read(ctx, SM_ROMNAME_START, 2)
if gameName is None:
ctx.death_link_allow_survive = False
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
if game_name is None:
continue
elif gameName == b"SM":
elif game_name == b"SM":
ctx.game = GAME_SM
else:
ctx.game = GAME_ALTTP
@@ -910,14 +908,8 @@ async def game_watcher(ctx: Context):
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
death_link = bool(death_link[0] & 0b1)
old_tags = ctx.tags.copy()
if death_link:
ctx.tags.add("DeathLink")
else:
ctx.tags -= {"DeathLink"}
if old_tags != ctx.tags and ctx.server and not ctx.server.socket.closed:
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
@@ -1079,11 +1071,13 @@ async def main():
import Patch
logging.info("Patch file was supplied. Creating sfc rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
args.connect = meta["server"]
if "server" in meta:
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
if args.diff_file.endswith(".apsoe"):
import webbrowser
webbrowser.open("http://www.evermizer.com/apclient/")
webbrowser.open("http://www.evermizer.com/apclient/" +
(f"#server={meta['server']}" if "server" in meta else ""))
logging.info("Starting Evermizer Client in your Browser...")
import time
time.sleep(3)
@@ -1103,38 +1097,29 @@ async def main():
ctx = Context(args.snes, args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
input_task = None
if gui_enabled:
input_task = None
from kvui import SNIManager
ctx.ui = SNIManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address))
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait()
if snes_connect_task:
snes_connect_task.cancel()
ctx.server_address = None
ctx.snes_reconnect_address = None
await watcher_task
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
if snes_connect_task:
snes_connect_task.cancel()
await watcher_task
await ctx.shutdown()
if ui_task:
await ui_task

View File

@@ -1,6 +1,16 @@
from __future__ import annotations
import typing
import builtins
import os
import subprocess
import sys
import pickle
import functools
import io
import collections
import importlib
import logging
def tuplize_version(version: str) -> Version:
@@ -13,20 +23,9 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.2.0"
__version__ = "0.2.1"
version_tuple = tuplize_version(__version__)
import builtins
import os
import subprocess
import sys
import pickle
import functools
import io
import collections
import importlib
import logging
from yaml import load, dump, safe_load
try:
@@ -427,7 +426,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s]: %(message)s"):
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = local_path("logs")
os.makedirs(log_folder, exist_ok=True)
@@ -446,3 +445,32 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.addHandler(
logging.StreamHandler(sys.stdout)
)
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
orig_hook = sys.excepthook
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger(exception_logger).exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
sys.excepthook = handle_exception
def stream_input(stream, queue):
def queuer():
while 1:
text = stream.readline().strip()
if text:
queue.put_nowait(text)
from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
thread.start()
return thread

View File

@@ -126,7 +126,7 @@ def faq(lang):
@app.route('/seed/<suuid:seed>')
def viewSeed(seed: UUID):
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
@@ -141,7 +141,7 @@ def new_room(seed: UUID):
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("hostRoom", room=room.id))
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
@@ -159,7 +159,7 @@ def display_log(room: UUID):
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def hostRoom(room: UUID):
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
@@ -175,20 +175,17 @@ def hostRoom(room: UUID):
return render_template("hostRoom.html", room=room)
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
def hostRoomRedirect(room: UUID):
return redirect(url_for("hostRoom", room=room))
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it

View File

@@ -9,6 +9,7 @@ from pony.orm import commit
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@api_endpoints.route('/generate', methods=['POST'])
@@ -35,9 +36,6 @@ def generate_api():
if "race" in json_data:
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
hint_cost = int(meta_options_source.get("hint_cost", 10))
forfeit_mode = meta_options_source.get("forfeit_mode", "goal")
if not options:
return {"text": "No options found. Expected file attachment or json weights."
}, 400
@@ -45,7 +43,8 @@ def generate_api():
if len(options) > app.config["MAX_ROLL"]:
return {"text": "Max size of multiworld exceeded",
"detail": app.config["MAX_ROLL"]}, 409
meta = get_meta(meta_options_source)
meta["race"] = race
results, gen_options = roll_options(options)
if any(type(result) == str for result in results.values()):
return {"text": str(results),
@@ -54,7 +53,7 @@ def generate_api():
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps({"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}), state=STATE_QUEUED,
meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import functools
import logging
import os
import websockets
import asyncio
import socket
@@ -20,6 +19,7 @@ from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
class CustomClientMessageProcessor(ClientMessageProcessor):
ctx: WebHostContext
def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
if platform.lower().startswith("t"): # twitch
@@ -37,6 +37,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
# inject
import MultiServer
MultiServer.client_message_processor = CustomClientMessageProcessor
del (MultiServer)
@@ -56,7 +57,7 @@ class WebHostContext(Context):
def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
while self.running:
while not self.exit_event.is_set():
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
@@ -88,11 +89,11 @@ class WebHostContext(Context):
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self, exit_save:bool = False) -> bool:
def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id)
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.utcnow()
return True
@@ -101,6 +102,7 @@ class WebHostContext(Context):
d["video"] = [(tuple(playerslot), videodata) for playerslot, videodata in self.video.items()]
return d
def get_random_port():
return random.randint(49152, 65535)

View File

@@ -20,6 +20,16 @@ from .check import get_yaml_data, roll_options
from .upload import upload_zip_to_db
def get_meta(options_source: dict) -> dict:
meta = {
"hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
}
return meta
@app.route('/generate', methods=['GET', 'POST'])
@app.route('/generate/<race>', methods=['GET', 'POST'])
def generate(race=False):
@@ -35,9 +45,9 @@ def generate(race=False):
else:
results, gen_options = roll_options(options)
# get form data -> server settings
hint_cost = int(request.form.get("hint_cost", 10))
forfeit_mode = request.form.get("forfeit_mode", "goal")
meta = {"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}
meta = get_meta(request.form)
meta["race"] = race
if race:
meta["item_cheat"] = False
meta["remaining"] = False
@@ -66,7 +76,7 @@ def generate(race=False):
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("viewSeed", seed=seed_id))
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race)
@@ -133,7 +143,7 @@ def wait_seed(seed: UUID):
seed_id = seed
seed = Seed.get(id=seed_id)
if seed:
return redirect(url_for("viewSeed", seed=seed_id))
return redirect(url_for("view_seed", seed=seed_id))
generation = Generation.get(id=seed_id)
if not generation:

View File

@@ -40,7 +40,7 @@ class Seed(db.Entity):
creation_time = Required(datetime, default=lambda: datetime.utcnow())
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
class Command(db.Entity):
@@ -53,5 +53,5 @@ class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID)
options = Required(buffer, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}")
meta = Required(LongStr, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)

View File

@@ -49,7 +49,7 @@ def create():
game_options = {}
for option_name, option in world.options.items():
if option.options:
this_option = {
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
@@ -66,7 +66,10 @@ def create():
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
game_options[option_name] = this_option
this_option["options"].append({
"name": "Random",
"value": "random",
})
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = {

View File

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

View File

@@ -24,6 +24,6 @@ Specific items can be limited to your own world using plando.
Secret of Evermore will display "Sent an Item". Check the client output if you want to know which.
## What happens when the player receives an item?
When the player receives an item, a popup will appear to show which item was received. Items won't be recieved while a
When the player receives an item, a popup will appear to show which item was received. Items won't be received while a
script is active such as when visiting Nobilia Market or during most Boss Fights. Once all scripts have ended, items
will be recieved.
will be received.

View File

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

View File

@@ -12,6 +12,17 @@ game/games you plan to play are available here go ahead and install these as wel
supported by Archipelago but not listed in the installation check the relevant tutorial.
## Generating a game
### Creating a YAML
In a multiworld there must be one YAML per world. Any number of players can play on each world using either the game's
native coop system or using archipelago's coop support. Each world will hold one slot in the multiworld and will have a
slot name and, if the relevant game requires it, files to associate it with that multiworld. If multiple people plan to
play in one world cooperatively then they will only need one YAML for their coop world, but if each player is planning on
playing their own game then they will each need a YAML. These YAML files can be generated by going to the relevant game's
player settings page, entering the name they want to use for the game, setting the options to what they would like to
play with and then clicking on the export settings button. This will then download a YAML file that will contain all of
these options and this can then be given to whoever is going to generate the game.
### Gather all player YAMLS
All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with.
A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file.
@@ -51,6 +62,7 @@ The generator will put a zip folder into your `Archipelago\output` folder with t
This contains the patch files and relevant mods for the players as well as the serverdata for the host.
## Hosting a multiworld
### Uploading the seed to the website
The easiest and most recommended method is to generate the game on the website which will allow you to create a private
room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games.

View File

@@ -107,6 +107,9 @@ Archipelago if you chose to include it during the installation process.
10. Enter `localhost` into the server address box
11. Click "Connect"
For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP
server, you can also issue the `!help` command to learn about additional commands like `!hint`.
## Allowing Other People to Join Your Game
1. Ensure your Archipelago Client is running.
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.

View File

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

View File

@@ -1,11 +1,9 @@
# Minecraft Randomizer Setup Guide
#Automatic Hosting Install
- download and install [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and choose the `Minecraft Client` module
## Required Software
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) (update 1.17.1)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) (select `Minecraft Client` during installation.)
## Configuring your YAML file
@@ -16,73 +14,7 @@ each player to enjoy an experience customized for their taste, and different pla
can all have different options.
### Where do I get a YAML file?
A basic minecraft yaml will look like this.
```yaml
description: Basic Minecraft Yaml
# Your name in-game. Spaces will be replaced with underscores and
# there is a 16 character limit
name: YourName
game: Minecraft
# Shared Options supported by all games:
accessibility: locations
progression_balancing: on
# Minecraft Specific Options
Minecraft:
# Number of advancements required (87 max) to spawn the Ender Dragon and complete the game.
advancement_goal: 50
# Number of dragon egg shards to collect (30 max) before the Ender Dragon will spawn.
egg_shards_required: 10
# Number of egg shards available in the pool (30 max).
egg_shards_available: 15
# Modifies the level of items logically required for
# exploring dangerous areas and fighting bosses.
combat_difficulty:
easy: 0
normal: 1
hard: 0
# Junk-fills certain RNG-reliant or tedious advancements.
include_hard_advancements:
on: 0
off: 1
# Junk-fills extremely difficult advancements;
# this is only How Did We Get Here? and Adventuring Time.
include_insane_advancements:
on: 0
off: 1
# Some advancements require defeating the Ender Dragon;
# this will junk-fill them, so you won't have to finish them to send some items.
include_postgame_advancements:
on: 0
off: 1
# Enables shuffling of villages, outposts, fortresses, bastions, and end cities.
shuffle_structures:
on: 0
off: 1
# Adds structure compasses to the item pool,
# which point to the nearest indicated structure.
structure_compasses:
on: 0
off: 1
# Replaces a percentage of junk items with bee traps
# which spawn multiple angered bees around every player when received.
bee_traps:
0: 1
25: 0
50: 0
75: 0
100: 0
```
you can customize your settings by visiting the [minecraft player settings](/games/Minecraft/player-settings)
## Joining a MultiWorld Game
@@ -93,38 +25,34 @@ When you join a multiworld game, you will be asked to provide your YAML file to
is done, the host will provide you with either a link to download your data file, or with a zip file containing
everyone's data files. Your data file should have a `.apmc` extension.
double click on your `.apmc` file to have the minecraft client auto-launch the installed forge server.
double-click on your `.apmc` file to have the minecraft client auto-launch the installed forge server.
make sure to leave this window open as this is your server console.
### Connect to the MultiServer
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
status by typing `/op YourMinecraftUsername` in the forge server console then connecting in your Minecraft client.
Using minecraft 1.17.1 connect to the server `localhost`.
Once in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. `(Password)`
is only required if the Archipleago server you are using has a password set.
### Play the game
When the console tells you that you have joined the room, you're ready to begin playing. Congratulations
When the console tells you that you have joined the room, you're all set. Congratulations
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
forge server.
forge server. to star the game once everyone is ready type `/start`.
### Useful commands
- `!help` displays a list all server commands
- `!hint` will display how many hint points you have, along with any hints that have been given that are related to your game.
- `!hint (item)` will ask the server to tell you where (item) is
- `!hint_location (location)` will ask the server to tell you what item is on (location)
## Manual Installation Procedures
this is only required if you wish to set up a forge install yourself, its recommended to just use the Archipelago Installer.
###Required Software
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
## Manual Installation
it is highly recommended to ues the Archipelago installer to handle the installation of the forge server for you.
support will not be given for those wishing to manually install forge. but for those of you who know how, and wish to
do so the following links are the versions of the software we use.
### Manual install Software links
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.17.1.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
**DO NOT INSTALL THIS ON YOUR CLIENT**
### Dedicated Server Setup
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
- [Java 16](https://docs.aws.amazon.com/corretto/latest/corretto-16-ug/downloads-list.html)
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
- This will create the appropriate directories for you to place the files in the following step.
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!

View File

@@ -1,14 +1,12 @@
# Secret of Evermore Setup Guide
## Required Software
- [SNI](https://github.com/alttpo/sni/releases) (included in Archipelago if already installed)
- [SNI](https://github.com/alttpo/sni/releases) v0.0.59 or newer (included in Archipelago 0.2.1 setup)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI with ROM access
- [snes9x-rr win32.zip](https://github.com/gocha/snes9x-rr/releases) +
[socket.dll](http://www.nyo.fr/~skarsnik/socket.dll) +
[connector.lua](https://raw.githubusercontent.com/alttpo/sni/main/lua/Connector.lua)
- or [BizHawk](http://tasvideos.org/BizHawk.html)
- or [bsnes-plus-nwa](https://github.com/black-sliver/bsnes-plus)
- [snes9x-rr](https://github.com/gocha/snes9x-rr/releases) or
- [BizHawk](http://tasvideos.org/BizHawk.html) or
- [bsnes-plus-nwa](https://github.com/black-sliver/bsnes-plus)
- Or SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
- Your Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
@@ -60,8 +58,13 @@ If this is its first time launching, you may be prompted to allow it to communic
2. Click on the File menu and hover on **Lua Scripting**
3. Click on **New Lua Script Window...**
4. In the new window, click **Browse...**
5. Select the `Connector.lua` file you downloaded above
6. If the script window complains about missing `socket.dll` make sure the DLL is in snes9x or the lua file's directory.
5. Select the `Connector.lua` file from your SNI installation:
* `SNI/lua/x86/Connector.lua` for 32bit snes9x-rr or
* `SNI/lua/x64/Connector.lua` for "x64" snes9x-rr
6. Leave the Lua window open while you are playing.
* If the script window complains about missing `socket.dll` make sure it is in the lua directory.
* If the script window complains about "Bad EXE format", use the other Connector above (x86 <-> x64)
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
@@ -71,7 +74,7 @@ If this is its first time launching, you may be prompted to allow it to communic
2. Load your ROM file if it hasn't already been loaded.
3. Click on the Tools menu and click on **Lua Console**
4. Click the button to open a new Lua script.
5. Select the `Connector.lua` file you downloaded above
5. Select any `Connector.lua` file from your SNI installation
##### bsnes-plus-nwa
This should automatically connect to SNI.

View File

@@ -4,7 +4,7 @@
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A Guide to setting up the Archipelago software to generate multiworld games on your computer.",
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
"files": [
{
"language": "English",
@@ -16,9 +16,23 @@
}
]
},
{
"name": "Using Advanced Settings",
"description": "A guide to reading yaml files and editing them to fully customize your game.",
"files": [
{
"language": "English",
"filename": "archipelago/advanced_settings_en.md",
"link": "archipelago/advanced_settings/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Triggers Guide",
"description": "A Guide to setting up and using triggers in your game settings.",
"description": "A guide to setting up and using triggers in your game settings.",
"files": [
{
"language": "English",
@@ -309,5 +323,24 @@
]
}
]
},
{
"gameTitle": "Final Fantasy",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.",
"files": [
{
"language": "English",
"filename": "ff1/multiworld_en.md",
"link": "ff1/multiworld/en",
"authors": [
"jat2980"
]
}
]
}
]
}
]

View File

@@ -74,3 +74,4 @@ Below is a list of MSU packs which, so far as we know, are safe to stream. More
we learn of them. If you know of any we missed, please let us know!
- Vanilla Game Music
- [Smooth McGroove](https://drive.google.com/open?id=1JDa1jCKg5hG0Km6xNpmIgf4kDMOxVp3n)
- Zelda community member Amarith assembled the following list for the purpose of competitive restreams. While we have not ourselves verified this list, all submissions required VoD proof they were not muted. Generally speaking, MSU-1 packs are less safe if they contain lyrics at any point. This list was only tested on Twitch and results for other platforms may vary. [Restream-Safe List](https://tinyurl.com/MSUsApprovedForLeagueChannels)

View File

@@ -76,7 +76,7 @@ Firewall.
4. In the new window, click **Browse...**
5. Select the connector lua file included with your client
- Z3Client users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/sni/Connector.lua`
- SNIClient users should look in their Archipelago folder for `/sni/lua`
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
@@ -85,10 +85,11 @@ Firewall.
Once you have changed the loaded core, you must restart BizHawk.
2. Load your ROM file if it hasn't already been loaded.
3. Click on the Tools menu and click on **Lua Console**
4. Click the button to open a new Lua script.
5. Select the `sniConnector.lua` file you downloaded above
4. Click Script -> Open Script...
5. Select the `Connector.lua` file you downloaded above
- Z3Client users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/sni/Connector.lua`
- SNIClient users should look in their Archipelago folder for `/sni/lua`
6. Run the script by double-clicking it in the listing
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not

File diff suppressed because it is too large Load Diff

View File

@@ -1,449 +0,0 @@
# What is this file?
# This file contains options which allow you to configure your multiworld experience while allowing others
# to play how they want as well.
# How do I use it?
# The options in this file are weighted. This means the higher number you assign to a value, the more
# chances you have for that option to be chosen. For example, an option like this:
#
# map_shuffle:
# on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
# I've never seen a file like this before. What characters am I allowed to use?
# This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
# For use with the weighted-settings page on the website. Changing this value will cause all users to be prompted
# to update their settings. The version number should match the current released version number, and the revision
# should be updated manually by whoever edits this file.
ws_version: 4.1.1 rev0
description: Template Name # Used to describe your yaml. Useful if you have multiple files
name: YourName # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
### Logic Section ###
glitches_required: # Determine the logic required to complete the seed
none: 50 # No glitches required
minor_glitches: 0 # Puts fake flipper, waterwalk, super bunny shenanigans, and etc into logic
overworld_glitches: 0 # Assumes the player has knowledge of both overworld major glitches (boots clips, mirror clips) and minor glitches (fake flipper, super bunny shenanigans, water walk and etc.)
no_logic: 0 # Your own items are placed with no regard to any logic; such as your Fire Rod can be on your Trinexx.
# Other players items are placed into your world under OWG logic
dark_room_logic: # Logic for unlit dark rooms
lamp: 50 # require the Lamp for these rooms to be considered accessible.
torches: 0 # in addition to lamp, allow the fire rod and presence of easily accessible torches for access
none: 0 # all dark rooms are always considered doable, meaning this may force completion of rooms in complete darkness
restrict_dungeon_item_on_boss: # aka ambrosia boss items
on: 0 # prevents unshuffled compasses, maps and keys to be boss drops, they can still drop keysanity and other players' items
off: 50
### End of Logic Section ###
meta_ignore: # Nullify options specified in the meta.yaml file. Adding an option here guarantees it will not occur in your seed, even if the .yaml file specifies it
mode:
- inverted # Never play inverted seeds
retro:
- on # Never play retro seeds
weapons:
- swordless # Never play a swordless seed
map_shuffle: # Shuffle dungeon maps into the world and other dungeons, including other players' worlds
on: 0
off: 50
compass_shuffle: # Shuffle compasses into the world and other dungeons, including other players' worlds
on: 0
off: 50
smallkey_shuffle: # Shuffle small keys into the world and other dungeons, including other players' worlds
on: 0
universal: 0 # allows small keys to be used in any dungeon and adds shops to buy more
off: 50
bigkey_shuffle: # Shuffle big keys into the world and other dungeons, including other players' worlds
on: 0
off: 50
local_keys: # Keep small keys and big keys local to your world
on: 0
off: 50
dungeon_items: # Alternative to the 4 shuffles and local_keys above this, does nothing until the respective 4 shuffles and local_keys above are deleted
mc: 0 # Shuffle maps and compasses
none: 50 # Shuffle none of the 4
mcsb: 0 # Shuffle all of the 4, any combination of m, c, s and b will shuffle the respective item, or not if it's missing, so you can add more options here
lmcsb: 0 # Like mcsb above, but with keys kept local to your world. l is what makes your keys local, or not if it's missing
ub: 0 # universal small keys and shuffled big keys
# you can add more combos of these letters here
dungeon_counters:
on: 0 # Always display amount of items checked in a dungeon
pickup: 50 # Show when compass is picked up
default: 0 # Show when compass is picked up if the compass itself is shuffled
off: 0 # Never show item count in dungeons
accessibility:
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
progressive: # Enable or disable progressive items (swords, shields, bow)
on: 50 # All items are progressive
off: 0 # No items are progressive
random: 0 # Randomly decides for all items. Swords could be progressive, shields might not be
entrance_shuffle: # Documentation: https://alttpr.com/en/options#entrance_shuffle
none: 50 # Vanilla game map. All entrances and exits lead to their original locations. You probably want this option
dungeonssimple: 0 # Shuffle just dungeons amongst each other, swapping dungeons entirely, so Hyrule Castle is always 1 dungeon
dungeonsfull: 0 # Shuffle any dungeon entrance with any dungeon interior, so Hyrule Castle can be 4 different dungeons
simple: 0 # Entrances are grouped together before being randomized. Simple uses the most strict grouping rules
restricted: 0 # Less strict than simple
full: 0 # Less strict than restricted
crossed: 0 # Less strict than full
insanity: 0 # Very few grouping rules. Good luck
goals:
ganon: 50 # Climb GT, defeat Agahnim 2, and then kill Ganon
fast_ganon: 0 # Only killing Ganon is required. The hole is always open. However, items may still be placed in GT
dungeons: 0 # Defeat the boss of all dungeons, including Agahnim's tower and GT (Aga 2)
pedestal: 0 # Pull the Triforce from the Master Sword pedestal
ganon_pedestal: 0 # Pull the Master Sword pedestal, then kill Ganon
triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout the worlds, then turn them in to Murahadala in front of Hyrule Castle
local_triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout your world, then turn them in to Murahadala in front of Hyrule Castle
ganon_triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout the worlds, then kill Ganon
local_ganon_triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout your world, then kill Ganon
ice_rod_hunt: 0 # You start with everything needed to 216 the seed. Find the Ice rod, then kill Trinexx at Turtle rock.
pyramid_open:
goal: 50 # Opens the pyramid if the goal requires you to kill Ganon, unless the goal is Slow Ganon or All Dungeons
auto: 0 # Same as Goal, but also opens when any non-dungeon entrance shuffle is used
yes: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt
no: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower
triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required
available: 50 # available = triforce_pieces_available
triforce_pieces_extra: # Set to how many extra triforces pieces are available to collect in the world.
# Format "pieces: chance"
0: 0
5: 50
10: 50
15: 0
20: 0
triforce_pieces_percentage: # Set to how many triforce pieces according to a percentage of the required ones, are available to collect in the world.
# Format "pieces: chance"
100: 0 #No extra
150: 50 #Half the required will be added as extra
200: 0 #There are the double of the required ones available.
triforce_pieces_available: # Set to how many triforces pieces are available to collect in the world. Default is 30. Max is 90, Min is 1
# Format "pieces: chance"
25: 0
30: 50
40: 0
50: 0
triforce_pieces_required: # Set to how many out of X triforce pieces you need to win the game in a triforce hunt. Default is 20. Max is 90, Min is 1
# Format "pieces: chance"
15: 0
20: 50
30: 0
40: 0
50: 0
tower_open: # Crystals required to open GT
'0': 80
'1': 70
'2': 60
'3': 50
'4': 40
'5': 30
'6': 20
'7': 10
random: 0
ganon_open: # Crystals required to hurt Ganon
'0': 80
'1': 70
'2': 60
'3': 50
'4': 40
'5': 30
'6': 20
'7': 10
random: 0
mode:
standard: 50 # Begin the game by rescuing Zelda from her cell and escorting her to the Sanctuary
open: 50 # Begin the game from your choice of Link's House or the Sanctuary
inverted: 0 # Begin in the Dark World. The Moon Pearl is required to avoid bunny-state in Light World, and the Light World game map is altered
retro:
on: 0 # you must buy a quiver to use the bow, take-any caves and an old-man cave are added to the world. You may need to find your sword from the old man's cave
off: 50
hints:
'on': 50 # Hint tiles sometimes give item location hints
'off': 0 # Hint tiles provide gameplay tips
weapons: # Specifically, swords
randomized: 0 # Swords are placed randomly throughout the world
assured: 50 # Begin with a sword, the rest are placed randomly throughout the world
vanilla: 0 # Swords are placed in vanilla locations in your own game (Uncle, Pyramid Fairy, Smiths, Pedestal)
swordless: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
item_pool:
easy: 0 # Doubled upgrades, progressives, and etc
normal: 50 # Item availability remains unchanged from vanilla game
hard: 0 # Reduced upgrade availability (max: 14 hearts, blue mail, tempered sword, fire shield, no silvers unless swordless)
expert: 0 # Minimum upgrade availability (max: 8 hearts, green mail, master sword, fighter shield, no silvers unless swordless)
item_functionality:
easy: 0 # Allow Hammer to damage ganon, Allow Hammer tablet collection, Allow swordless medallion use everywhere.
normal: 50 # Vanilla item functionality
hard: 0 # Reduced helpfulness of items (potions less effective, can't catch faeries, cape uses double magic, byrna does not grant invulnerability, boomerangs do not stun, silvers disabled outside ganon)
expert: 0 # Vastly reduces the helpfulness of items (potions barely effective, can't catch faeries, cape uses double magic, byrna does not grant invulnerability, boomerangs and hookshot do not stun, silvers disabled outside ganon)
progression_balancing:
on: 50 # A system to reduce BK, as in times during which you can't do anything by moving your items into an earlier access sphere to make it likely you have stuff to do
off: 0 # Turn this off if you don't mind a longer multiworld, or can glitch around missing items.
tile_shuffle: # Randomize the tile layouts in flying tile rooms
on: 0
off: 50
misery_mire_medallion: # required medallion to open Misery Mire front entrance
random: 50
ether: 0
bombos: 0
quake: 0
turtle_rock_medallion: # required medallion to open Turtle Rock front entrance
random: 50
ether: 0
bombos: 0
quake: 0
### Enemizer Section ###
boss_shuffle:
none: 50 # Vanilla bosses
simple: 0 # Existing bosses except Ganon and Agahnim are shuffled throughout dungeons
full: 0 # 3 bosses can occur twice
random: 0 # Any boss can appear any amount of times
singularity: 0 # Picks a boss, tries to put it everywhere that works, if there's spaces remaining it picks a boss to fill those
enemy_shuffle: # Randomize enemy placement
on: 0
off: 50
killable_thieves: # Make thieves killable
on: 0 # Usually turned on together with enemy_shuffle to make annoying thief placement more manageable
off: 50
bush_shuffle: # Randomize the chance that bushes have enemies and the enemies under said bush
on: 0
off: 50
enemy_damage:
default: 50 # Vanilla enemy damage
shuffled: 0 # Enemies deal 0 to 4 hearts and armor helps
random: 0 # Enemies deal 0 to 8 hearts and armor just reshuffles the damage
enemy_health:
default: 50 # Vanilla enemy HP
easy: 0 # Enemies have reduced health
hard: 0 # Enemies have increased health
expert: 0 # Enemies have greatly increased health
pot_shuffle:
'on': 0 # Keys, items, and buttons hidden under pots in dungeons are shuffled with other pots in their supertile
'off': 50 # Default pot item locations
### End of Enemizer Section ###
# can add weights for any whole number between 0 and 100
beemizer_total_chance: # Remove items from the global item pool and replace them with single bees (fill bottles) and bee traps
0: 50 # No junk fill items are replaced (Beemizer is off)
25: 0 # 25% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
50: 0 # 50% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
75: 0 # 75% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
100: 0 # All junk fill items (rupees, bombs and arrows) are replaced with bees
beemizer_trap_chance:
60: 50 # 60% chance for each beemizer replacement to be a trap, 40% chance to be a single bee
70: 0 # 70% chance for each beemizer replacement to be a trap, 30% chance to be a single bee
80: 0 # 80% chance for each beemizer replacement to be a trap, 20% chance to be a single bee
90: 0 # 90% chance for each beemizer replacement to be a trap, 10% chance to be a single bee
100: 0 # All beemizer replacements are traps
### Shop Settings ###
shop_shuffle_slots: # Maximum amount of shop slots to be filled with regular item pool items (such as Moon Pearl)
0: 50
10: 0
20: 0
30: 0
shop_shuffle:
none: 50
g: 0 # Generate new default inventories for overworld/underworld shops, and unique shops
f: 0 # Generate new default inventories for every shop independently
p: 0 # Randomize the prices of the items in shop inventories
u: 0 # Shuffle capacity upgrades into the item pool (and allow them to traverse the multiworld)
w: 0 # Consider witch's hut like any other shop and shuffle/randomize it too
gp: 0 # Shuffle inventories and randomize prices
fp: 0 # Randomize items in every shop and their prices
ufp: 0 # Randomize items and prices in every shop, and include capacity upgrades in item pool
wfp: 0 # Randomize items and prices in every shop, and include potion shop inventory in shuffle
ufpw: 0 # Randomize items and prices in every shop, shuffle potion shop inventory, and include capacity upgrades
# You can add more combos
### End of Shop Section ###
shuffle_prizes: # aka drops
none: 0 # do not shuffle prize packs
g: 50 # shuffle "general" price packs, as in enemy, tree pull, dig etc.
b: 0 # shuffle "bonk" price packs
bg: 0 # shuffle both
timer:
none: 50 # No timer will be displayed.
timed: 0 # Starts with clock at zero. Green clocks subtract 4 minutes (total 20). Blue clocks subtract 2 minutes (total 10). Red clocks add two minutes (total 10). Winner is the player with the lowest time at the end.
timed_ohko: 0 # Starts the clock at ten minutes. Green clocks add five minutes (total 25). As long as the clock as at zero, Link will die in one hit.
ohko: 0 # Timer always at zero. Permanent OHKO.
timed_countdown: 0 # Starts the clock with forty minutes. Same clocks as timed mode, but if the clock hits zero you lose. You can still keep playing, though.
display: 0 # Displays a timer, but otherwise does not affect gameplay or the item pool.
countdown_start_time: # For timed_ohko and timed_countdown timer modes, the amount of time in minutes to start with
0: 0 # For timed_ohko, starts in OHKO mode when starting the game
10: 50
20: 0
30: 0
60: 0
red_clock_time: # For all timer modes, the amount of time in minutes to gain or lose when picking up a red clock
-2: 50
1: 0
blue_clock_time: # For all timer modes, the amount of time in minutes to gain or lose when picking up a blue clock
1: 0
2: 50
green_clock_time: # For all timer modes, the amount of time in minutes to gain or lose when picking up a green clock
4: 50
10: 0
15: 0
# Can be uncommented to use it
# local_items: # Force certain items to appear in your world only, not across the multiworld. Recognizes some group names, like "Swords"
# - "Moon Pearl"
# - "Small Keys"
# - "Big Keys"
# Can be uncommented to use it
# startinventory: # Begin the file with the listed items/upgrades
# Pegasus Boots: on
# Bomb Upgrade (+10): 4
# Arrow Upgrade (+10): 4
glitch_boots:
on: 50 # Start with Pegasus Boots in any glitched logic mode that makes use of them
off: 0
linked_options:
- name: crosskeys
options: # These overwrite earlier options if the percentage chance triggers
entrance_shuffle: crossed
bigkey_shuffle: true
compass_shuffle: true
map_shuffle: true
smallkey_shuffle: true
percentage: 0 # Set this to the percentage chance you want crosskeys
- name: localcrosskeys
options: # These overwrite earlier options if the percentage chance triggers
entrance_shuffle: crossed
bigkey_shuffle: true
compass_shuffle: true
map_shuffle: true
smallkey_shuffle: true
local_items: # Forces keys to be local to your own world
- "Small Keys"
- "Big Keys"
percentage: 0 # Set this to the percentage chance you want local crosskeys
- name: enemizer
options:
boss_shuffle: # Subchances can be injected too, which then get rolled
simple: 1
full: 1
random: 1
singularity: 1
enemy_damage:
shuffled: 1
random: 1
enemy_health:
easy: 1
hard: 1
expert: 1
percentage: 0 # Set this to the percentage chance you want enemizer
### door rando only options ###
door_shuffle: # Only available if the host uses the doors branch, it is ignored otherwise
vanilla: 50 # Everything should be like in vanilla
basic: 0 # Dungeons are shuffled within themselves
crossed: 0 # Dungeons are shuffled across each other
intensity: # Only available if the host uses the doors branch, it is ignored otherwise
1: 50 # Shuffles normal doors and spiral staircases
2: 0 # And shuffles open edges and straight staircases
3: 0 # And shuffles dungeon lobbies
random: 0 # Picks one of those at random
key_drop_shuffle: # Only available if the host uses the doors branch, it is ignored otherwise
on: 0 # Enables the small keys dropped by enemies or under pots, and the big key dropped by the Ball & Chain guard to be shuffled into the pool. This extends the number of checks to 249.
off: 50
experimental: # Only available if the host uses the doors branch, it is ignored otherwise
on: 0 # Enables experimental features.
off: 50
debug: # Only available if the host uses the doors branch, it is ignored otherwise
on: 0 # Enables debugging features. Currently, these are the Item collection counter. (overwrites total triforce pieces) and Castle Gate closed indicator.
off: 50
### end of door rando only options ###
rom:
#sprite_pool: # When specified, limits the pool of sprites used for randomon-event to the specified pool. Uncomment to use this.
# - link
# - pride link
# - penguin link
# - random # You can specify random multiple times for however many potentially unique random sprites you want in your pool.
sprite: # Enter the name of your preferred sprite and weight it appropriately
random: 0
link: 50 # To add other sprites: open the gui/Creator, go to adjust, select a sprite and write down the name the gui calls it
disablemusic: # If "on", all in-game music will be disabled
on: 0
off: 50
quickswap: # Enable switching items by pressing the L+R shoulder buttons
on: 50
off: 0
triforcehud: # Disable visibility of the triforce hud unless collecting a piece or speaking to Murahadala
normal: 50 # original behavior (always visible)
hide_goal: 0 # hide counter until a piece is collected or speaking to Murahadala
hide_required: 0 # Always visible, but required amount is invisible until determined by Murahadala
hide_both: 0 # Hide both under above circumstances
reduceflashing: # Reduces instances of flashing such as lightning attacks, weather, ether and more.
on: 50
off: 0
menuspeed: # Controls how fast the item menu opens and closes
normal: 50
instant: 0
double: 0
triple: 0
quadruple: 0
half: 0
heartcolor: # Controls the color of your health hearts
red: 50
blue: 0
green: 0
yellow: 0
random: 0
heartbeep: # Controls the frequency of the low-health beeping
double: 0
normal: 50
half: 0
quarter: 0
off: 0
ow_palettes: # Change the colors of the overworld
default: 50 # No changes
random: 0 # Shuffle the colors, with harmony in mind
blackout: 0 # everything black / blind mode
grayscale: 0
negative: 0
classic: 0
dizzy: 0
sick: 0
puke: 0
uw_palettes: # Change the colors of caves and dungeons
default: 50 # No changes
random: 0 # Shuffle the colors, with harmony in mind
blackout: 0 # everything black / blind mode
grayscale: 0
negative: 0
classic: 0
dizzy: 0
sick: 0
puke: 0
hud_palettes: # Change the colors of the hud
default: 50 # No changes
random: 0 # Shuffle the colors, with harmony in mind
blackout: 0 # everything black / blind mode
grayscale: 0
negative: 0
classic: 0
dizzy: 0
sick: 0
puke: 0
sword_palettes: # Change the colors of swords
default: 50 # No changes
random: 0 # Shuffle the colors, with harmony in mind
blackout: 0 # everything black / blind mode
grayscale: 0
negative: 0
classic: 0
dizzy: 0
sick: 0
puke: 0
shield_palettes: # Change the colors of shields
default: 50 # No changes
random: 0 # Shuffle the colors, with harmony in mind
blackout: 0 # everything black / blind mode
grayscale: 0
negative: 0
classic: 0
dizzy: 0
sick: 0
puke: 0

View File

@@ -34,7 +34,14 @@
<table>
<tbody>
<tr>
<td><label for="forfeit_mode">Forfeit Permission:</label></td>
<td>
<label for="forfeit_mode">Forfeit Permission:</label>
<span
class="interactive"
data-tooltip="A forfeit releases all remaining items from the locations
in your world.">(?)
</span>
</td>
<td>
<select name="forfeit_mode" id="forfeit_mode">
<option value="auto">Automatic on goal completion</option>
@@ -46,12 +53,49 @@
</td>
</tr>
<tr>
<td>
<label for="collect_mode">Collect Permission:</label>
<span
class="interactive"
data-tooltip="A collect releases all of your remaining items to you
from across the multiworld.">(?)
</span>
</td>
<td>
<select name="collect_mode" id="collect_mode">
<option value="goal">Allow !collect after goal completion</option>
<option value="auto">Automatic on goal completion</option>
<option value="auto-enabled">Automatic on goal completion and manual !collect</option>
<option value="enabled">Manual !collect</option>
<option value="disabled">Disabled</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="remaining_mode">Remaining Permission:</label>
<span
class="interactive"
data-tooltip="Remaining lists all items still in your world by name only.">(?)
</span>
</td>
<td>
<select name="remaining_mode" id="remaining_mode">
<option value="disabled">Disabled</option>
<option value="goal">Allow !remaining after goal completion</option>
<option value="enabled">Manual !remaining</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="hint_cost"> Hint Cost:</label>
<span
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
to get the location of that hint item.">(?)
</span>
</td>

View File

@@ -9,7 +9,7 @@
{% include 'header/grassHeader.html' %}
<div id="host-room">
{% if room.owner == session["_id"] %}
Room created from <a href="{{ url_for("viewSeed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
<br>
{% endif %}
{% if room.tracker %}
@@ -20,7 +20,7 @@
later,
you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %}
You can connect to this room by using '/connect archipelago.gg:{{ room.last_port }}'
You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}

View File

@@ -1,7 +1,7 @@
{% macro list_rooms(rooms) -%}
<ul>
{% for room in rooms %}
<li><a href="{{ url_for("hostRoom", room=room.id) }}">Room #{{ room.id|suuid }}</a></li>
<li><a href="{{ url_for("host_room", room=room.id) }}">Room #{{ room.id|suuid }}</a></li>
{% endfor %}
{{ caller() }}
</ul>

View File

@@ -42,6 +42,7 @@
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td><img src="{{ icons['Dragon Head'] }}" class="{{ 'acquired' if game_finished }}" title="Ender Dragon" /></td>
</tr>
</table>

View File

@@ -38,26 +38,29 @@
<td><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></td>
</tr>
<tr>
{% if 'Fire Orb' in acquired_items %}
<td><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></td>
<td><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></td>
{% if 'Djinn Inferno' in acquired_items %}
<td><img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" /></td>
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" /></td>
{% elif 'Fire Orb' in acquired_items %}
<td><img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" /></td>
{% elif 'Infernal Flames' in acquired_items %}
<td><img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" /></td>
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" /></td>
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" /></td>
{% else %}
<td><img src="{{ icons['Fire Orb'] }}" title="Fire Orb" /></td>
<td><img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" /></td>
{% endif %}
{% if 'Plasma Orb' in acquired_items %}
<td><img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" /></td>
{% if 'Royal Ring' in acquired_items %}
<td><img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" /></td>
{% elif 'Plasma Geyser' in acquired_items %}
<td><img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" /></td>
{% elif 'Royal Ring' in acquired_items %}
<td><img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" /></td>
{% elif 'Plasma Orb' in acquired_items %}
<td><img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" /></td>
{% else %}
<td><img src="{{ icons['Plasma Orb'] }}" title="Plasma Orb" /></td>
<td><img src="{{ icons['Royal Ring'] }}" title="Royal Ring" /></td>
{% endif %}
</tr>
</table>

View File

@@ -20,7 +20,7 @@
Multistream
</a>
</span>
<span class="info">This tracker will automatically update itself periodically.</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in inventory.items() %}

View File

@@ -29,8 +29,8 @@
<tbody>
{% for room in rooms %}
<tr>
<td><a href="{{ url_for("viewSeed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("hostRoom", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td>>={{ room.seed.slots|length }}</td>
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
@@ -55,7 +55,7 @@
<tbody>
{% for seed in seeds %}
<tr>
<td><a href="{{ url_for("viewSeed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td>{% if seed.multidata %}>={{ seed.slots|length }}{% else %}1{% endif %}
</td>
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>

View File

@@ -1,4 +1,5 @@
import collections
import typing
from typing import Counter, Optional, Dict, Any, Tuple
from flask import render_template
@@ -10,6 +11,7 @@ from worlds.alttp import Items
from WebHostLib import app, cache, Room
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from MultiServer import get_item_name_from_id, Context
alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -74,6 +76,7 @@ alttp_icons = {
"Ganons Tower": r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74"
}
def get_alttp_id(item_name):
return Items.item_table[item_name][2]
@@ -201,7 +204,10 @@ for item_name, data in Items.item_table.items():
big_key_ids[area] = data[2]
ids_big_key[data[2]] = area
from MultiServer import get_item_name_from_id, Context
# cleanup global namespace
del item_name
del data
del item
def attribute_item(inventory, team, recipient, item):
@@ -268,15 +274,14 @@ def get_static_room_data(room: Room):
for playernumber in range(1, len(names[0]) + 1)}
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
multidata["precollected_items"], \
multidata["games"]
multidata["precollected_items"], multidata["games"]
_multidata_cache[room.seed.id] = result
return result
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
# Team and player must be positive and greater than zero
if tracked_team < 0 or tracked_player < 1:
abort(404)
@@ -318,23 +323,23 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1
if games[tracked_player] == "A Link to the Past":
return __renderAlttpTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, \
seed_checks_in_area, checks_done)
elif games[tracked_player] == "Minecraft":
return __renderMinecraftTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name)
elif games[tracked_player] == "Ocarina of Time":
return __renderOoTTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name)
elif games[tracked_player] == "Timespinner":
return __renderTimespinnerTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name)
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
if specific_tracker and not want_generic:
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done)
else:
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name)
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done)
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
inventory: Counter, team: int, player: int, player_name: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
# Note the presence of the triforce item
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
@@ -384,7 +389,7 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
player_small_key_locations.add(ids_small_key[item_id])
return render_template("lttpTracker.html", inventory=inventory,
player_name=playerName, room=room, icons=alttp_icons, checks_done=checks_done,
player_name=player_name, room=room, icons=alttp_icons, checks_done=checks_done,
checks_in_area=seed_checks_in_area[player],
acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
@@ -394,7 +399,8 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int]]],
inventory: Counter, team: int, player: int, playerName: str) -> str:
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
@@ -422,19 +428,21 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Head": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b6/Dragon_Head.png",
}
minecraft_location_ids = {
"Story": [42073, 42080, 42081, 42023, 42082, 42027, 42039, 42085, 42002, 42009, 42010,
42070, 42041, 42049, 42090, 42004, 42031, 42025, 42029, 42051, 42077, 42089],
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42086, 42087, 42050, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42088],
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028,
42036, 42057, 42063, 42053, 42083, 42084, 42091]
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
}
display_data = {}
@@ -494,7 +502,8 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int]]],
inventory: Counter, team: int, player: int, playerName: str) -> str:
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
icons = {
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
@@ -621,12 +630,14 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
"Gerudo Training Grounds": (67597, 67635),
"Ganon's Castle": (67636, 67673),
}
def lookup_and_trim(id, area):
full_name = lookup_any_location_id_to_name[id]
if id == 67673:
return full_name[13:] # Ganons Tower Boss Key Chest
return full_name[13:] # Ganons Tower Boss Key Chest
if area != 'Overworld':
return full_name[len(area):] # trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
return full_name[len(area):]
return full_name
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
@@ -669,14 +680,16 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
display_data['game_finished'] = game_state == 30
return render_template("ootTracker.html",
inventory=inventory, player=player, team=team, room=room, player_name=playerName,
icons=icons, acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
small_key_counts=small_key_counts, boss_key_counts=boss_key_counts,
**display_data)
inventory=inventory, player=player, team=team, room=room, player_name=playerName,
icons=icons, acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
small_key_counts=small_key_counts, boss_key_counts=boss_key_counts,
**display_data)
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int]]],
inventory: Counter, team: int, player: int, playerName: str) -> str:
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
icons = {
"Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png",
@@ -706,6 +719,8 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
"Royal Ring": "https://timespinnerwiki.com/mediawiki/images/f/f3/Royal_Ring.png",
"Plasma Geyser": "https://timespinnerwiki.com/mediawiki/images/1/12/Plasma_Geyser.png",
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
}
timespinner_location_ids = {
@@ -720,7 +735,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
1337070, 1337071, 1337072, 1337073, 1337074, 1337075, 1337076, 1337077, 1337078, 1337079,
1337080, 1337081, 1337082, 1337083, 1337084, 1337085, 1337156, 1337157, 1337159,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337170],
1337170, 1337237, 1337238],
"Past": [
1337086, 1337087, 1337088, 1337089,
1337090, 1337091, 1337092, 1337093, 1337094, 1337095, 1337096, 1337097, 1337098, 1337099,
@@ -729,7 +744,8 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
1337120, 1337121, 1337122, 1337123, 1337124, 1337125, 1337126, 1337127, 1337128, 1337129,
1337130, 1337131, 1337132, 1337133, 1337134, 1337135, 1337136, 1337137, 1337138, 1337139,
1337140, 1337141, 1337142, 1337143, 1337144, 1337145, 1337146, 1337147, 1337148, 1337149,
1337150, 1337151, 1337152, 1337153, 1337154, 1337155],
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
1337171, 1337172, 1337173, 1337174, 1337175, 1337176],
"Ancient Pyramid": [1337246, 1337247, 1337248, 1337249]
}
@@ -760,7 +776,8 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int]]],
inventory: Counter, team: int, player: int, playerName: str) -> str:
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
player_received_items = {}
@@ -864,3 +881,11 @@ def getTracker(tracker: UUID):
key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids,
video=video, big_key_locations=group_big_key_locations,
hints=hints, long_player_names=long_player_names)
game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Minecraft": __renderMinecraftTracker,
"Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker
}

View File

@@ -65,8 +65,8 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
MultiServer.Context._decompress(multidata)
except:
flash("Could not load multidata. File may be corrupted or incompatible.")
else:
multidata = zfile.open(file).read()
multidata = None
if multidata:
flush() # commit slots
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta),
@@ -98,7 +98,7 @@ def uploads():
if type(res) == str:
return res
elif res:
return redirect(url_for("viewSeed", seed=res.id))
return redirect(url_for("view_seed", seed=res.id))
else:
try:
multidata = file.read()
@@ -109,7 +109,7 @@ def uploads():
else:
seed = Seed(multidata=multidata, owner=session["_id"])
flush() # place into DB and generate ids
return redirect(url_for("viewSeed", seed=seed.id))
return redirect(url_for("view_seed", seed=seed.id))
else:
flash("Not recognized file format. Awaiting a .archipelago file or .zip containing one.")
return render_template("hostGame.html")

View File

@@ -1,9 +1,9 @@
<TabbedPanel>
tab_width: 200
<Row@Label>:
<SelectableLabel>:
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
Rectangle:
size: self.size
pos: self.pos
@@ -13,10 +13,10 @@
font_size: dp(20)
markup: True
<UILog>:
viewclass: 'Row'
viewclass: 'SelectableLabel'
scroll_y: 0
effect_cls: "ScrollEffect"
RecycleBoxLayout:
SelectableRecycleBoxLayout:
default_size: None, dp(20)
default_size_hint: 1, None
size_hint_y: None
@@ -32,6 +32,8 @@
pos: (0, 0)
<ServerToolTip>:
size: self.texture_size
size_hint: None, None
font_size: dp(18)
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left"
canvas.before:
@@ -39,4 +41,14 @@
rgba: 0.2, 0.2, 0.2, 1
Rectangle:
size: self.size
pos: self.pos
pos: self.pos
Color:
rgba: 0.098, 0.337, 0.431, 1
Line:
width: 3
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
Color:
rgba: 0.235, 0.678, 0.843, 1
Line:
width: 1
rectangle: self.x-2, self.y-2, self.width+4, self.height+4

BIN
data/lua/FF1/core.dll Normal file

Binary file not shown.

View File

@@ -0,0 +1,542 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local ITEM_INDEX = 0x03
local WEAPON_INDEX = 0x07
local ARMOR_INDEX = 0x0B
local goldLookup = {
[0x16C] = 10,
[0x16D] = 20,
[0x16E] = 25,
[0x16F] = 30,
[0x170] = 55,
[0x171] = 70,
[0x172] = 85,
[0x173] = 110,
[0x174] = 135,
[0x175] = 155,
[0x176] = 160,
[0x177] = 180,
[0x178] = 240,
[0x179] = 255,
[0x17A] = 260,
[0x17B] = 295,
[0x17C] = 300,
[0x17D] = 315,
[0x17E] = 330,
[0x17F] = 350,
[0x180] = 385,
[0x181] = 400,
[0x182] = 450,
[0x183] = 500,
[0x184] = 530,
[0x185] = 575,
[0x186] = 620,
[0x187] = 680,
[0x188] = 750,
[0x189] = 795,
[0x18A] = 880,
[0x18B] = 1020,
[0x18C] = 1250,
[0x18D] = 1455,
[0x18E] = 1520,
[0x18F] = 1760,
[0x190] = 1975,
[0x191] = 2000,
[0x192] = 2750,
[0x193] = 3400,
[0x194] = 4150,
[0x195] = 5000,
[0x196] = 5450,
[0x197] = 6400,
[0x198] = 6720,
[0x199] = 7340,
[0x19A] = 7690,
[0x19B] = 7900,
[0x19C] = 8135,
[0x19D] = 9000,
[0x19E] = 9300,
[0x19F] = 9500,
[0x1A0] = 9900,
[0x1A1] = 10000,
[0x1A2] = 12350,
[0x1A3] = 13000,
[0x1A4] = 13450,
[0x1A5] = 14050,
[0x1A6] = 14720,
[0x1A7] = 15000,
[0x1A8] = 17490,
[0x1A9] = 18010,
[0x1AA] = 19990,
[0x1AB] = 20000,
[0x1AC] = 20010,
[0x1AD] = 26000,
[0x1AE] = 45000,
[0x1AF] = 65000
}
local extensionConsumableLookup = {
[432] = 0x3C,
[436] = 0x3C,
[440] = 0x3C,
[433] = 0x3D,
[437] = 0x3D,
[441] = 0x3D,
[434] = 0x3E,
[438] = 0x3E,
[442] = 0x3E,
[435] = 0x3F,
[439] = 0x3F,
[443] = 0x3F
}
local itemMessages = {}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local isNesHawk = false
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
if domains[1] == "System Bus" then
--NesHawk
isNesHawk = true
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
elseif domains[1] == "WRAM" then
--QuickNES
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
uRange = memory.readbyterange
local function StateOKForMainLoop()
memDomain.saveram()
local A = u8(0x102) -- Party Made
local B = u8(0x0FC)
local C = u8(0x0A3)
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
local bizhawk_version = client.getversion()
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5")
local is26To27 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7")
local function getMaxMessageLength()
if is23Or24Or25 then
return client.screenwidth()/11
elseif is26To27 then
return client.screenwidth()/12
end
end
local function drawText(x, y, message, color)
if is23Or24Or25 then
gui.addmessage(message)
elseif is26To27 then
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client")
end
end
local function clearScreen()
if is23Or24Or25 then
return
elseif is26To27 then
drawText(0, 0, "", "black")
end
end
local function drawMessages()
if table.empty(itemMessages) then
clearScreen()
return
end
local y = 10
found = false
maxMessageLength = getMaxMessageLength()
for k, v in pairs(itemMessages) do
if v["TTL"] > 0 then
message = v["message"]
while true do
drawText(5, y, message:sub(1, maxMessageLength), v["color"])
y = y + 16
message = message:sub(maxMessageLength + 1, message:len())
if message:len() == 0 then
break
end
end
newTTL = 0
if is26To27 then
newTTL = itemMessages[k]["TTL"] - 1
end
itemMessages[k]["TTL"] = newTTL
found = true
end
end
if found == false then
clearScreen()
end
end
function generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
data[0] = nil
return data
end
function setConsumableStacks()
memDomain.rom()
consumableStacks = {}
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
consumableStacks[0x35] = 1
consumableStacks[0x36] = u8(0x47400) + 1
consumableStacks[0x37] = u8(0x47401) + 1
consumableStacks[0x38] = u8(0x47402) + 1
consumableStacks[0x39] = u8(0x47403) + 1
consumableStacks[0x3A] = u8(0x47404) + 1
consumableStacks[0x3B] = u8(0x47405) + 1
consumableStacks[0x3C] = u8(0x47406) + 1
consumableStacks[0x3D] = u8(0x47407) + 1
consumableStacks[0x3E] = u8(0x47408) + 1
consumableStacks[0x3F] = u8(0x47409) + 1
end
function getEmptyWeaponSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x118, 0x4)
slot2 = uRange(0x158, 0x4)
slot3 = uRange(0x198, 0x4)
slot4 = uRange(0x1D8, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x118 + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x158 + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x198 + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1D8 + i
count = count + 1
end
end
return ret
end
function getEmptyArmorSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x11C, 0x4)
slot2 = uRange(0x15C, 0x4)
slot3 = uRange(0x19C, 0x4)
slot4 = uRange(0x1DC, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x11C + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x15C + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x19C + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1DC + i
count = count + 1
end
end
return ret
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
for i, v in pairs(msgBlock) do
if itemMessages[i] == nil then
local msg = {TTL=450, message=v, color=0xFFFF0000}
itemMessages[i] = msg
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = u8(0x102)
if itemsBlock ~= nil and isInGame ~= 0x00 then
if consumableStacks == nil then
setConsumableStacks()
end
memDomain.saveram()
-- print('ITEMBLOCK: ')
-- print(itemsBlock)
itemIndex = u8(ITEM_INDEX)
-- print('ITEMINDEX: '..itemIndex)
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
-- Minus the offset and add to the correct domain
local memoryLocation = v
if v >= 0x100 and v <= 0x114 then
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
-- Canal is a flipped bit
if memoryLocation == 0x0C then
wU8(memoryLocation, 0x00)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]
biggest = u8(0x01E)
medium = u8(0x01D)
smallest = u8(0x01C)
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
newValue = currentValue + amountToAdd
newBiggest = math.floor(newValue / 0x10000)
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
newSmallest = math.floor(math.fmod(newValue, 0x100))
wU8(0x01E, newBiggest)
wU8(0x01D, newMedium)
wU8(0x01C, newSmallest)
elseif v >= 0x115 and v <= 0x11B then
-- This is a regular consumable OR a shard
-- Minus Offset (0x100) + item offset (0x20)
memoryLocation = memoryLocation - 0x0E0
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
wU8(memoryLocation, currentValue + amountToAdd)
end
elseif v >= 0x1B0 and v <= 0x1BB then
-- This is an extension consumable
memoryLocation = extensionConsumableLookup[v]
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
value = currentValue + amountToAdd
if value > 99 then
value = 99
end
wU8(memoryLocation, value)
end
end
end
if #itemsBlock ~= itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end
memDomain.saveram()
weaponIndex = u8(WEAPON_INDEX)
emptyWeaponSlots = getEmptyWeaponSlots()
lastUsedWeaponIndex = weaponIndex
-- print('WEAPON_INDEX: '.. weaponIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
if v >= 0x11C and v <= 0x143 then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x11B
if #emptyWeaponSlots > 0 then
slot = table.remove(emptyWeaponSlots, 1)
wU8(slot, itemValue)
lastUsedWeaponIndex = weaponIndex + i
else
break
end
end
end
if lastUsedWeaponIndex ~= weaponIndex then
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
end
memDomain.saveram()
armorIndex = u8(ARMOR_INDEX)
emptyArmorSlots = getEmptyArmorSlots()
lastUsedArmorIndex = armorIndex
-- print('ARMOR_INDEX: '.. armorIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
if v >= 0x144 and v <= 0x16B then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x143
if #emptyArmorSlots > 0 then
slot = table.remove(emptyArmorSlots, 1)
wU8(slot, itemValue)
lastUsedArmorIndex = armorIndex + i
else
break
end
end
end
if lastUsedArmorIndex ~= armorIndex then
wU8(ARMOR_INDEX, lastUsedArmorIndex)
end
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function receive()
l, e = ff1Socket:receive()
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
processBlock(json.decode(l))
-- Determine Message to send back
memDomain.rom()
local playerName = uRange(0x7BCBF, 0x41)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["locations"] = generateLocationChecked()
end
msg = json.encode(retTable).."\n"
local ret, error = ff1Socket: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!")
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
curstate = STATE_OK
end
end
function main()
if (is23Or24Or25 or is26To27) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
-- Advance so the messages are drawn
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ff1Socket = client
ff1Socket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

380
data/lua/FF1/json.lua Normal file
View File

@@ -0,0 +1,380 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

132
data/lua/FF1/socket.lua Normal file
View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -140,7 +140,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
| ---- | ---- | ----- |
| hint_points | int | New argument. The client's current hint points. |
| players | list\[NetworkPlayer\] | Changed argument. Always sends all players, whether connected or not. |
| checked_locations | May be a partial update, containing new locations that were checked. |
| checked_locations | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. |
| missing_locations | Should never be sent as an update, if needed is the inverse of checked_locations. |
All arguments for this packet are optional, only changes are sent.
@@ -349,12 +349,21 @@ class JSONMessagePart(TypedDict):
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
Possible values for `type` include:
* player_id
* item_id
* location_id
* entrance_name
`color` is used to denote a console color to display the message part with. This is limited to console colors due to backwards compatibility needs with games such as ALttP. Although background colors as well as foreground colors are listed, only one may be applied to a [JSONMessagePart](#JSONMessagePart) at a time.
| Name | Notes |
| ---- | ----- |
| text | Regular text content. Is the default type and as such may be omitted. |
| player_id | player ID of someone on your team, should be resolved to Player Name |
| player_name | Player Name, could be a player within a multiplayer game or from another team, not ID resolvable |
| item_id | Item ID, should be resolved to Item Name |
| item_name | Item Name, not currently used over network, but supported by reference Clients. |
| location_id | Location ID, should be resolved to Location Name |
| location_name |Location Name, not currently used over network, but supported by reference Clients. |
| entrance_name | Entrance Name. No ID mapping exists. |
| color | Regular text that should be colored. Only `type` that will contain `color` data. |
`color` is used to denote a console color to display the message part with and is only send if the `type` is `color`. This is limited to console colors due to backwards compatibility needs with games such as ALttP. Although background colors as well as foreground colors are listed, only one may be applied to a [JSONMessagePart](#JSONMessagePart) at a time.
Color options:
* bold

View File

@@ -0,0 +1,52 @@
# This is a sample configuration for the Web host.
# If you wish to change any of these, rename this file to config.yaml
# Default values are shown here. Uncomment and change the values as desired.
# TODO
#SELFHOST: true
# Maximum concurrent world gens
#GENERATORS: 8
# TODO
#SELFLAUNCH: true
# TODO
#DEBUG: false
# Web hosting port
#PORT: 80
# Place where uploads go.
#UPLOAD_FOLDER: uploads
# Maximum upload size. Default is 64 megabyte (64 * 1024 * 1024)
#MAX_CONTENT_LENGTH: 67108864
# Secret key used to determine important things like cookie authentication of room/seed page ownership.
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
# SECRET_KEY: "Your secret key here"
# TODO
#JOB_THRESHOLD: 2
# waitress uses one thread for I/O, these are for processing of view that get sent
#WAITRESS_THREADS: 10
# Database provider details:
#PONY:
# provider: "sqlite"
# filename: "ap.db3" # This MUST be the ABSOLUTE PATH to the file.
# create_db: true
# Maximum number of players that are allowed to be rolled on the server. After this limit, one should roll locally and upload the results.
#MAX_ROLL: 20
# TODO
#CACHE_TYPE: "simple"
# TODO
#JSON_AS_ASCII: false
# Patch target. This is the address encoded into the patch that will be used for client auto-connect.
#PATCH_TARGET: archipelago.gg

View File

@@ -37,7 +37,7 @@ server_options:
# "auto" -> automatic collect on goal completion
# "auto-enabled" -> automatic collect on goal completion and manual collect is also enabled
# "goal" -> collect is allowed after goal completion
collect_mode: "disabled"
collect_mode: "goal"
# Remaining modes
# !remaining handling, that tells a client which items remain in their pool
# "enabled" -> Client can always ask for remaining items

View File

@@ -61,6 +61,8 @@ Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Se
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -82,11 +84,10 @@ Source: "{#sourcepath}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: igno
Source: "{#sourcepath}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#sourcepath}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#sourcepath}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#sourcepath}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#sourcepath}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
;minecraft temp files
Source: "{tmp}\forge-installer.jar"; DestDir: "{app}"; Flags: skipifsourcedoesntexist external deleteafterinstall; Components: client/minecraft
[Icons]
Name: "{group}\{#MyAppName} Folder"; Filename: "{app}";
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Components: server
@@ -94,16 +95,19 @@ Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/factorio
[Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp
Filename: "{app}\jre8\bin\java.exe"; Parameters: "-jar ""{app}\forge-installer.jar"" --installServer ""{app}\Minecraft Forge server"""; Flags: runhidden; Check: IsForgeNeeded(); StatusMsg: "Installing Forge Server..."; Components: client/minecraft
Filename: "{app}\ArchipelagoMinecraftClient.exe"; Parameters: "--install"; StatusMsg: "Installing Forge Server..."; Components: client/minecraft
[UninstallDelete]
Type: dirifempty; Name: "{app}"
@@ -123,6 +127,11 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
@@ -138,7 +147,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{
const
SHCONTCH_NOPROGRESSBOX = 4;
SHCONTCH_RESPONDYESTOALL = 16;
FORGE_VERSION = '1.16.5-36.2.0';
// See: https://stackoverflow.com/a/51614652/2287576
function IsVCRedist64BitNeeded(): boolean;
@@ -160,48 +168,6 @@ begin
end;
end;
function IsForgeNeeded(): boolean;
begin
Result := True;
if (FileExists(ExpandConstant('{app}')+'\Minecraft Forge Server\forge-'+FORGE_VERSION+'.jar')) then
Result := False;
end;
function IsJavaNeeded(): boolean;
begin
Result := True;
if (FileExists(ExpandConstant('{app}')+'\jre8\bin\java.exe')) then
Result := False;
end;
function OnDownloadMinecraftProgress(const Url, FileName: String; const Progress, ProgressMax: Int64): Boolean;
begin
if Progress = ProgressMax then
Log(Format('Successfully downloaded Minecraft additional files to {tmp}: %s', [FileName]));
Result := True;
end;
procedure UnZip(ZipPath, TargetPath: string);
var
Shell: Variant;
ZipFile: Variant;
TargetFolder: Variant;
begin
Shell := CreateOleObject('Shell.Application');
ZipFile := Shell.NameSpace(ZipPath);
if VarIsClear(ZipFile) then
RaiseException(
Format('ZIP file "%s" does not exist or cannot be opened', [ZipPath]));
TargetFolder := Shell.NameSpace(TargetPath);
if VarIsClear(TargetFolder) then
RaiseException(Format('Target path "%s" does not exist', [TargetPath]));
TargetFolder.CopyHere(
ZipFile.Items, SHCONTCH_NOPROGRESSBOX or SHCONTCH_RESPONDYESTOALL);
end;
var R : longint;
var lttprom: string;
@@ -216,8 +182,6 @@ var SoERomFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
@@ -265,11 +229,6 @@ begin
'.sfc');
end;
procedure AddMinecraftDownloads();
begin
MinecraftDownloadPage := CreateDownloadPage(SetupMessage(msgWizardPreparing), SetupMessage(msgPreparingDesc), @OnDownloadMinecraftProgress);
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
@@ -302,33 +261,7 @@ end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
if (CurPageID = wpReady) and (WizardIsComponentSelected('client/minecraft')) then begin
MinecraftDownloadPage.Clear;
if(IsForgeNeeded()) then
MinecraftDownloadPage.Add('https://maven.minecraftforge.net/net/minecraftforge/forge/'+FORGE_VERSION+'/forge-'+FORGE_VERSION+'-installer.jar','forge-installer.jar','');
if(IsJavaNeedeD()) then
MinecraftDownloadPage.Add('https://corretto.aws/downloads/latest/amazon-corretto-8-x64-windows-jre.zip','java.zip','');
MinecraftDownloadPage.Show;
try
try
MinecraftDownloadPage.Download;
Result := True;
except
if MinecraftDownloadPage.AbortedByUser then
Log('Aborted by user.')
else
SuppressibleMsgBox(AddPeriod(GetExceptionMessage), mbCriticalError, MB_OK, IDOK);
Result := False;
end;
finally
if( isJavaNeeded() ) then
if(ForceDirectories(ExpandConstant('{app}'))) then
UnZip(ExpandConstant('{tmp}')+'\java.zip',ExpandConstant('{app}'));
MinecraftDownloadPage.Hide;
end;
Result := True;
end
else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
@@ -392,7 +325,7 @@ function GetOoTROMPath(Param: string): string;
begin
if Length(ootrom) > 0 then
Result := ootrom
else if (Assigned(OoTROMFilePage)) then
else if Assigned(OoTROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f');
if R <> 0 then
@@ -419,8 +352,6 @@ begin
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
AddMinecraftDownloads();
end;
@@ -435,4 +366,4 @@ begin
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot'));
end;
end;

View File

@@ -61,6 +61,8 @@ Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Se
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -82,11 +84,10 @@ Source: "{#sourcepath}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: igno
Source: "{#sourcepath}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#sourcepath}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#sourcepath}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#sourcepath}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#sourcepath}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
;minecraft temp files
Source: "{tmp}\forge-installer.jar"; DestDir: "{app}"; Flags: skipifsourcedoesntexist external deleteafterinstall; Components: client/minecraft
[Icons]
Name: "{group}\{#MyAppName} Folder"; Filename: "{app}";
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Components: server
@@ -94,16 +95,19 @@ Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/factorio
[Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp
Filename: "{app}\jre8\bin\java.exe"; Parameters: "-jar ""{app}\forge-installer.jar"" --installServer ""{app}\Minecraft Forge server"""; Flags: runhidden; Check: IsForgeNeeded(); StatusMsg: "Installing Forge Server..."; Components: client/minecraft
Filename: "{app}\ArchipelagoMinecraftClient.exe"; Parameters: "--install"; StatusMsg: "Installing Forge Server..."; Components: client/minecraft
[UninstallDelete]
Type: dirifempty; Name: "{app}"
@@ -123,6 +127,11 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
@@ -138,7 +147,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{
const
SHCONTCH_NOPROGRESSBOX = 4;
SHCONTCH_RESPONDYESTOALL = 16;
FORGE_VERSION = '1.16.5-36.2.0';
// See: https://stackoverflow.com/a/51614652/2287576
function IsVCRedist64BitNeeded(): boolean;
@@ -160,48 +168,6 @@ begin
end;
end;
function IsForgeNeeded(): boolean;
begin
Result := True;
if (FileExists(ExpandConstant('{app}')+'\Minecraft Forge Server\forge-'+FORGE_VERSION+'.jar')) then
Result := False;
end;
function IsJavaNeeded(): boolean;
begin
Result := True;
if (FileExists(ExpandConstant('{app}')+'\jre8\bin\java.exe')) then
Result := False;
end;
function OnDownloadMinecraftProgress(const Url, FileName: String; const Progress, ProgressMax: Int64): Boolean;
begin
if Progress = ProgressMax then
Log(Format('Successfully downloaded Minecraft additional files to {tmp}: %s', [FileName]));
Result := True;
end;
procedure UnZip(ZipPath, TargetPath: string);
var
Shell: Variant;
ZipFile: Variant;
TargetFolder: Variant;
begin
Shell := CreateOleObject('Shell.Application');
ZipFile := Shell.NameSpace(ZipPath);
if VarIsClear(ZipFile) then
RaiseException(
Format('ZIP file "%s" does not exist or cannot be opened', [ZipPath]));
TargetFolder := Shell.NameSpace(TargetPath);
if VarIsClear(TargetFolder) then
RaiseException(Format('Target path "%s" does not exist', [TargetPath]));
TargetFolder.CopyHere(
ZipFile.Items, SHCONTCH_NOPROGRESSBOX or SHCONTCH_RESPONDYESTOALL);
end;
var R : longint;
var lttprom: string;
@@ -216,8 +182,6 @@ var SoERomFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
@@ -265,11 +229,6 @@ begin
'.sfc');
end;
procedure AddMinecraftDownloads();
begin
MinecraftDownloadPage := CreateDownloadPage(SetupMessage(msgWizardPreparing), SetupMessage(msgPreparingDesc), @OnDownloadMinecraftProgress);
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
@@ -302,33 +261,7 @@ end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
if (CurPageID = wpReady) and (WizardIsComponentSelected('client/minecraft')) then begin
MinecraftDownloadPage.Clear;
if(IsForgeNeeded()) then
MinecraftDownloadPage.Add('https://maven.minecraftforge.net/net/minecraftforge/forge/'+FORGE_VERSION+'/forge-'+FORGE_VERSION+'-installer.jar','forge-installer.jar','');
if(IsJavaNeedeD()) then
MinecraftDownloadPage.Add('https://corretto.aws/downloads/latest/amazon-corretto-8-x64-windows-jre.zip','java.zip','');
MinecraftDownloadPage.Show;
try
try
MinecraftDownloadPage.Download;
Result := True;
except
if MinecraftDownloadPage.AbortedByUser then
Log('Aborted by user.')
else
SuppressibleMsgBox(AddPeriod(GetExceptionMessage), mbCriticalError, MB_OK, IDOK);
Result := False;
end;
finally
if( isJavaNeeded() ) then
if(ForceDirectories(ExpandConstant('{app}'))) then
UnZip(ExpandConstant('{tmp}')+'\java.zip',ExpandConstant('{app}'));
MinecraftDownloadPage.Hide;
end;
Result := True;
end
else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
@@ -349,7 +282,7 @@ begin
R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := LttPROMFilePage.Values[0]
end
else
@@ -397,7 +330,7 @@ begin
R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f');
if R <> 0 then
MsgBox('OoT ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := OoTROMFilePage.Values[0]
end
else
@@ -405,7 +338,7 @@ begin
end;
procedure InitializeWizard();
begin
begin
AddOoTRomPage();
lttprom := CheckRom('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', '03a63945398191337e896e5771f77173');
@@ -419,8 +352,6 @@ begin
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
AddMinecraftDownloads();
end;

122
kvui.py
View File

@@ -2,7 +2,6 @@ import os
import logging
import typing
import asyncio
import sys
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
@@ -11,6 +10,8 @@ os.environ["KIVY_LOG_ENABLE"] = "0"
from kivy.app import App
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel
from kivy.base import ExceptionHandler, ExceptionManager, Config, Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
@@ -25,6 +26,10 @@ from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar
from kivy.utils import escape_markup
from kivy.lang import Builder
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart
@@ -140,11 +145,57 @@ class ContainerLayout(FloatLayout):
pass
class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
RecycleBoxLayout):
""" Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, Label):
""" Add selection support to the Label """
index = None
selected = BooleanProperty(False)
def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """
self.index = index
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
return True
if self.collide_point(*touch.pos):
if self.selected:
self.parent.clear_selection()
else:
# Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]")))
cmdinput = App.get_running_app().textinput
if not cmdinput.text and " did you mean " in text:
for question in ("Didn't find something that closely matches, did you mean ",
"Too many close matches, did you mean "):
if text.startswith(question):
name = Utils.get_text_between(text, question,
"? (")
cmdinput.text = f"!{App.get_running_app().last_autofillable_command} {name}"
break
Clipboard.copy(text)
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
self.selected = is_selected
class GameManager(App):
logging_pairs = [
("Client", "Archipelago"),
]
base_title = "Archipelago Client"
base_title: str = "Archipelago Client"
last_autofillable_command: str
def __init__(self, ctx: context_type):
self.title = self.base_title
@@ -153,6 +204,22 @@ class GameManager(App):
self.icon = r"data/icon.png"
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
self.log_panels = {}
# keep track of last used command to autofill on click
self.last_autofillable_command = "hint"
autofillable_commands = ("hint_location", "hint", "getitem")
original_say = ctx.on_user_say
def intercept_say(text):
text = original_say(text)
if text:
for command in autofillable_commands:
if text.startswith("!"+command):
self.last_autofillable_command = command
break
return text
ctx.on_user_say = intercept_say
super(GameManager, self).__init__()
def build(self):
@@ -164,7 +231,8 @@ class GameManager(App):
# top part
server_label = ServerLabel()
connect_layout.add_widget(server_label)
self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False)
self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False,
write_tab=False)
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
@@ -201,33 +269,21 @@ class GameManager(App):
info_button = Button(height=30, text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
textinput.bind(on_text_validate=self.on_message)
bottom_layout.add_widget(textinput)
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message)
def text_focus(event):
"""Needs to be set via delay, as unfocusing happens after on_message"""
self.textinput.focus = True
self.textinput.text_focus = text_focus
bottom_layout.add_widget(self.textinput)
self.grid.add_widget(bottom_layout)
self.commandprocessor("/help")
Clock.schedule_interval(self.update_texts, 1 / 30)
self.container.add_widget(self.grid)
self.catch_unhandled_exceptions()
return self.container
def catch_unhandled_exceptions(self):
"""Relay unhandled exceptions to UI logger."""
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
orig_hook = sys.excepthook
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger("Client").exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
sys.excepthook = handle_exception
def update_texts(self, dt):
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
@@ -242,7 +298,11 @@ class GameManager(App):
self.progressbar.value = 0
def command_button_action(self, button):
logging.getLogger("Client").info("/help for client commands and !help for server commands.")
if self.ctx.server:
logging.getLogger("Client").info("/help for client commands and !help for server commands.")
else:
logging.getLogger("Client").info("/help for client commands and once you are connected, "
"!help for server commands.")
def connect_button_action(self, button):
if self.ctx.server:
@@ -269,6 +329,9 @@ class GameManager(App):
self.ctx.input_queue.put_nowait(input_text)
elif input_text:
self.commandprocessor(input_text)
Clock.schedule_once(textinput.text_focus)
except Exception as e:
logging.getLogger("Client").exception(e)
@@ -302,9 +365,16 @@ class TextManager(GameManager):
base_title = "Archipelago Text Client"
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.DEBUG)
super(LogtoUI, self).__init__(logging.INFO)
self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None:

View File

@@ -4,12 +4,6 @@
# For example, if a meta.yaml fast_ganon result is rolled, every player will have that fast_ganon goal
# There is the special case of null, which ignores that part of the meta.yaml,
# allowing for a chance for that meta to not take effect
# Players can also have a meta_ignore option to ignore specific options
# Example of ignore that would be in a player's file:
# meta_ignore:
# mode:
# inverted
# This means, if mode is meta-rolled and the result happens to be inverted, then defer to the player's yaml instead.
meta_description: Meta-Mystery file with the intention of having similar-length completion times for a hopefully better experience
null:
progression_balancing: # Progression balancing tries to make sure that the player has *something* towards any players goal in each "sphere"
@@ -33,26 +27,6 @@ A Link to the Past:
open: 60
inverted: 10
null: 10 # Maintain individual world states
tower_open:
'0': 8
'1': 7
'2': 6
'3': 5
'4': 4
'5': 3
'6': 2
'7': 1
random: 10 # A different GT open time should not usually result in a vastly different completion time, unless ganon goal and tower_open > ganon_open
ganon_open:
'0': 3
'1': 4
'2': 5
'3': 6
'4': 7
'5': 8
'6': 9
'7': 10
random: 5 # This will mean differing completion times. But leaving it for that surprise effect
triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
colorama>=0.4.4
websockets>=10.0
websockets>=10.1
PyYAML>=6.0
fuzzywuzzy>=0.18.0
prompt_toolkit>=3.0.22
appdirs>=1.4.4
jinja2>=3.0.3
schema>=0.7.4

View File

@@ -80,6 +80,10 @@ scripts = {
"FactorioClient.py": ("ArchipelagoFactorioClient", True, icon),
# Minecraft
"MinecraftClient.py": ("ArchipelagoMinecraftClient", False, mcicon),
# Ocarina of Time
"OoTAdjuster.py": ("ArchipelagoOoTAdjuster", True, icon),
# FF1
"FF1Client.py": ("ArchipelagoFF1Client", True, icon),
}
exes = []
@@ -142,7 +146,7 @@ extra_data = ["LICENSE", "data", "EnemizerCLI", "host.yaml", "SNI", "meta.yaml"]
for data in extra_data:
installfile(Path(data))
os.makedirs(buildfolder / "Players", exist_ok=True)
os.makedirs(buildfolder / "Players" / "Templates", exist_ok=True)
from WebHostLib.options import create
create()
from worlds.AutoWorld import AutoWorldRegister
@@ -150,7 +154,7 @@ for worldname, worldtype in AutoWorldRegister.world_types.items():
if not worldtype.hidden:
file_name = worldname+".yaml"
shutil.copyfile(os.path.join("WebHostLib", "static", "generated", "configs", file_name),
buildfolder / "Players" / file_name)
buildfolder / "Players" / "Templates" / file_name)
try:
from maseya import z3pr

View File

@@ -613,19 +613,24 @@ class TestAdvancements(TestMinecraft):
["You Need a Mint", False, [], ['Progressive Resource Crafting']],
["You Need a Mint", False, [], ['Flint and Steel']],
["You Need a Mint", False, [], ['Progressive Tools']],
["You Need a Mint", False, ['Progressive Weapons'], ['Progressive Weapons', 'Progressive Weapons']],
["You Need a Mint", False, [], ['Progressive Armor']],
["You Need a Mint", False, [], ['Progressive Weapons']],
["You Need a Mint", False, [], ['Progressive Armor', 'Shield']],
["You Need a Mint", False, [], ['Brewing']],
["You Need a Mint", False, [], ['Bottles']],
["You Need a Mint", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']],
["You Need a Mint", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']],
["You Need a Mint", False, [], ['Archery']],
["You Need a Mint", False, [], ['Bottles']],
["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket',
'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor',
'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']],
["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket',
'Progressive Weapons', 'Progressive Armor', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']],
["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools',
'Progressive Weapons', 'Progressive Weapons', 'Archery', 'Progressive Armor',
'Brewing', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']],
'Progressive Weapons', 'Progressive Armor', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']],
["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket',
'Progressive Weapons', 'Shield', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']],
["You Need a Mint", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools',
'Progressive Weapons', 'Shield', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Bottles']],
])
def test_42047(self):
@@ -954,7 +959,11 @@ class TestAdvancements(TestMinecraft):
def test_42072(self):
self.run_location_tests([
["A Throwaway Joke", True, []],
["A Throwaway Joke", False, []],
["A Throwaway Joke", False, [], ['Progressive Weapons']],
["A Throwaway Joke", False, [], ['Campfire', 'Progressive Resource Crafting']],
["A Throwaway Joke", True, ['Progressive Weapons', 'Campfire']],
["A Throwaway Joke", True, ['Progressive Weapons', 'Progressive Resource Crafting']],
])
def test_42073(self):
@@ -1143,3 +1152,127 @@ class TestAdvancements(TestMinecraft):
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield']],
])
def test_42092(self):
self.run_location_tests([
["Wax On", False, []],
["Wax On", False, [], ["Progressive Tools"]],
["Wax On", False, [], ["Campfire"]],
["Wax On", False, ["Progressive Resource Crafting"], ["Progressive Resource Crafting"]],
["Wax On", True, ["Progressive Tools", "Progressive Resource Crafting", "Progressive Resource Crafting", "Campfire"]],
])
def test_42093(self):
self.run_location_tests([
["Wax Off", False, []],
["Wax Off", False, [], ["Progressive Tools"]],
["Wax Off", False, [], ["Campfire"]],
["Wax Off", False, ["Progressive Resource Crafting"], ["Progressive Resource Crafting"]],
["Wax Off", True, ["Progressive Tools", "Progressive Resource Crafting", "Progressive Resource Crafting", "Campfire"]],
])
def test_42094(self):
self.run_location_tests([
["The Cutest Predator", False, []],
["The Cutest Predator", False, [], ["Progressive Tools"]],
["The Cutest Predator", False, [], ["Progressive Resource Crafting"]],
["The Cutest Predator", False, [], ["Bucket"]],
["The Cutest Predator", True, ["Progressive Tools", "Progressive Resource Crafting", "Bucket"]],
])
def test_42095(self):
self.run_location_tests([
["The Healing Power of Friendship", False, []],
["The Healing Power of Friendship", False, [], ["Progressive Tools"]],
["The Healing Power of Friendship", False, [], ["Progressive Resource Crafting"]],
["The Healing Power of Friendship", False, [], ["Bucket"]],
["The Healing Power of Friendship", True, ["Progressive Tools", "Progressive Resource Crafting", "Bucket"]],
])
def test_42096(self):
self.run_location_tests([
["Is It a Bird?", False, []],
["Is It a Bird?", False, [], ["Progressive Weapons"]],
["Is It a Bird?", False, [], ["Progressive Tools"]],
["Is It a Bird?", False, [], ["Progressive Resource Crafting"]],
["Is It a Bird?", False, [], ["Spyglass"]],
["Is It a Bird?", True, ["Progressive Weapons", "Progressive Tools", "Progressive Resource Crafting", "Spyglass"]],
])
def test_42097(self):
self.run_location_tests([
["Is It a Balloon?", False, []],
["Is It a Balloon?", False, [], ['Progressive Resource Crafting']],
["Is It a Balloon?", False, [], ['Flint and Steel']],
["Is It a Balloon?", False, [], ['Progressive Tools']],
["Is It a Balloon?", False, [], ['Progressive Weapons']],
["Is It a Balloon?", False, [], ['Spyglass']],
["Is It a Balloon?", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']],
["Is It a Balloon?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Spyglass']],
["Is It a Balloon?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons', 'Spyglass']],
])
def test_42098(self):
self.run_location_tests([
["Is It a Plane?", False, []],
["Is It a Plane?", False, [], ['Progressive Resource Crafting']],
["Is It a Plane?", False, [], ['Flint and Steel']],
["Is It a Plane?", False, [], ['Progressive Tools']],
["Is It a Plane?", False, [], ['Progressive Weapons']],
["Is It a Plane?", False, [], ['Progressive Armor', 'Shield']],
["Is It a Plane?", False, [], ['Brewing']],
["Is It a Plane?", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']],
["Is It a Plane?", False, ['3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls'], ['3 Ender Pearls']],
["Is It a Plane?", False, [], ['Spyglass']],
["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket',
'Progressive Weapons', 'Progressive Armor', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']],
["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools',
'Progressive Weapons', 'Progressive Armor', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']],
["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket',
'Progressive Weapons', 'Shield', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']],
["Is It a Plane?", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools',
'Progressive Weapons', 'Shield', 'Brewing',
'3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', '3 Ender Pearls', 'Spyglass']],
])
def test_42099(self):
self.run_location_tests([
["Surge Protector", False, []],
["Surge Protector", False, [], ['Channeling Book']],
["Surge Protector", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']],
["Surge Protector", False, [], ['Enchanting']],
["Surge Protector", False, [], ['Progressive Tools']],
["Surge Protector", False, [], ['Progressive Weapons']],
["Surge Protector", True, ['Progressive Weapons', 'Progressive Tools', 'Progressive Tools', 'Progressive Tools',
'Enchanting', 'Progressive Resource Crafting', 'Progressive Resource Crafting', 'Channeling Book']],
])
def test_42100(self):
self.run_location_tests([
["Light as a Rabbit", False, []],
["Light as a Rabbit", False, [], ["Progressive Weapons"]],
["Light as a Rabbit", False, [], ["Progressive Tools"]],
["Light as a Rabbit", False, [], ["Progressive Resource Crafting"]],
["Light as a Rabbit", False, [], ["Bucket"]],
["Light as a Rabbit", True, ["Progressive Weapons", "Progressive Tools", "Progressive Resource Crafting", "Bucket"]],
])
def test_42101(self):
self.run_location_tests([
["Glow and Behold!", False, []],
["Glow and Behold!", False, [], ["Progressive Weapons"]],
["Glow and Behold!", False, [], ["Progressive Resource Crafting", "Campfire"]],
["Glow and Behold!", True, ["Progressive Weapons", "Progressive Resource Crafting"]],
["Glow and Behold!", True, ["Progressive Weapons", "Campfire"]],
])
def test_42102(self):
self.run_location_tests([
["Whatever Floats Your Goat!", False, []],
["Whatever Floats Your Goat!", False, [], ["Progressive Weapons"]],
["Whatever Floats Your Goat!", False, [], ["Progressive Resource Crafting", "Campfire"]],
["Whatever Floats Your Goat!", True, ["Progressive Weapons", "Progressive Resource Crafting"]],
["Whatever Floats Your Goat!", True, ["Progressive Weapons", "Campfire"]],
])

View File

@@ -4,7 +4,7 @@ from BaseClasses import MultiWorld
from worlds import AutoWorld
from worlds.minecraft import MinecraftWorld
from worlds.minecraft.Items import MinecraftItem, item_table
from worlds.minecraft.Options import AdvancementGoal, CombatDifficulty, BeeTraps
from worlds.minecraft.Options import *
from Options import Toggle, Range
# Converts the name of an item into an item object
@@ -30,16 +30,17 @@ class TestMinecraft(TestBase):
self.world = MultiWorld(1)
self.world.game[1] = "Minecraft"
self.world.worlds[1] = MinecraftWorld(self.world, 1)
exclusion_pools = ['hard', 'insane', 'postgame']
exclusion_pools = ['hard', 'unreasonable', 'postgame']
for pool in exclusion_pools:
setattr(self.world, f"include_{pool}_advancements", [False, False])
setattr(self.world, f"include_{pool}_advancements", {1: False})
setattr(self.world, "advancement_goal", {1: AdvancementGoal(30)})
setattr(self.world, "shuffle_structures", {1: Toggle(False)})
setattr(self.world, "combat_difficulty", {1: CombatDifficulty(1)}) # normal
setattr(self.world, "egg_shards_required", {1: EggShardsRequired(0)})
setattr(self.world, "egg_shards_available", {1: EggShardsAvailable(0)})
setattr(self.world, "required_bosses", {1: BossGoal(1)}) # ender dragon
setattr(self.world, "shuffle_structures", {1: ShuffleStructures(False)})
setattr(self.world, "bee_traps", {1: BeeTraps(0)})
setattr(self.world, "combat_difficulty", {1: CombatDifficulty(1)}) # normal
setattr(self.world, "structure_compasses", {1: Toggle(False)})
setattr(self.world, "egg_shards_required", {1: Range(0)})
setattr(self.world, "egg_shards_available", {1: Range(0)})
AutoWorld.call_single(self.world, "create_regions", 1)
AutoWorld.call_single(self.world, "generate_basic", 1)
AutoWorld.call_single(self.world, "set_rules", 1)

View File

@@ -3,7 +3,8 @@ import os
__all__ = {"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name",
"network_data_package"}
"network_data_package",
"AutoWorldRegister"}
# import all submodules to trigger AutoWorldRegister
for file in os.scandir(os.path.dirname(__file__)):

View File

@@ -1796,6 +1796,7 @@ def link_inverted_entrances(world, player):
if world.get_entrance('Inverted Ganons Tower', player).connected_region.name != 'Ganons Tower (Entrance)':
world.ganonstower_vanilla[player] = False
def connect_simple(world, exitname, regionname, player):
world.get_entrance(exitname, player).connect(world.get_region(regionname, player))
@@ -1820,6 +1821,7 @@ def connect_entrance(world, entrancename: str, exitname: str, player: int):
entrance.connect(region, addresses, target)
world.spoiler.set_entrance(entrance.name, exit.name if exit is not None else region.name, 'entrance', player)
def connect_exit(world, exitname, entrancename, player):
entrance = world.get_entrance(entrancename, player)
exit = world.get_entrance(exitname, player)

View File

@@ -145,10 +145,17 @@ class RestrictBossItem(Toggle):
displayname = "Prevent Dungeon Item on Boss"
class Hints(DefaultOnToggle):
"""Put item and entrance placement hints on telepathic tiles and some NPCs.
Additionally King Zora and Bottle Merchant say what they're selling."""
class Hints(Choice):
"""Vendors: King Zora and Bottle Merchant say what they're selling.
On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints."""
displayname = "Hints"
option_off = 0
option_vendors = 1
option_on = 2
option_full = 3
default = 2
alias_false = 0
alias_true = 2
class EnemyShuffle(Toggle):

View File

@@ -21,8 +21,7 @@ import concurrent.futures
import bsdiff4
from typing import Optional
from BaseClasses import CollectionState, Region
from worlds.alttp.SubClasses import ALttPLocation
from BaseClasses import CollectionState, Region, Location
from worlds.alttp.Shops import ShopType, ShopPriceType
from worlds.alttp.Dungeons import dungeon_music_addresses
from worlds.alttp.Regions import location_table, old_location_address_to_new_location_address
@@ -1535,7 +1534,7 @@ def patch_rom(world, rom, player, enemized):
}
def get_reveal_bytes(itemName):
locations = world.find_items(itemName, player)
locations = world.find_item_locations(itemName, player)
if len(locations) < 1:
return 0x0000
location = locations[0]
@@ -2114,7 +2113,7 @@ def write_strings(rom, world, player):
if dest.player != player:
if ped_hint:
hint += f" for {world.player_name[dest.player]}!"
elif type(dest) in [Region, ALttPLocation]:
elif isinstance(dest, (Region, Location)):
hint += f" in {world.player_name[dest.player]}'s world"
else:
hint += f" for {world.player_name[dest.player]}"
@@ -2130,171 +2129,180 @@ def write_strings(rom, world, player):
vendor_location = world.get_location("Bottle Merchant", player)
tt['bottle_vendor_choice'] = f"I gots {hint_text(vendor_location.item)}\nYous gots 100 rupees?" \
f"\n ≥ I want\n no way!\n{{CHOICE}}"
tt['sign_north_of_links_house'] = '> Randomizer The telepathic tiles can have hints!'
hint_locations = HintLocations.copy()
local_random.shuffle(hint_locations)
all_entrances = [entrance for entrance in world.get_entrances() if entrance.player == player]
local_random.shuffle(all_entrances)
# First we take care of the one inconvenient dungeon in the appropriately simple shuffles.
entrances_to_hint = {}
entrances_to_hint.update(InconvenientDungeonEntrances)
if world.shuffle_ganon:
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Ganons Tower': 'The sealed castle door'})
if world.hints[player].value >= 2:
if world.hints[player] == "full":
tt['sign_north_of_links_house'] = '> Randomizer The telepathic tiles have hints!'
else:
entrances_to_hint.update({'Ganons Tower': 'Ganon\'s Tower'})
if world.shuffle[player] in ['simple', 'restricted', 'restricted_legacy']:
for entrance in all_entrances:
if entrance.name in entrances_to_hint:
this_hint = entrances_to_hint[entrance.name] + ' leads to ' + hint_text(
entrance.connected_region) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint = {}
break
# Now we write inconvenient locations for most shuffles and finish taking care of the less chaotic ones.
entrances_to_hint.update(InconvenientOtherEntrances)
if world.shuffle[player] in ['vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed']:
hint_count = 0
elif world.shuffle[player] in ['simple', 'restricted', 'restricted_legacy']:
hint_count = 2
else:
hint_count = 4
for entrance in all_entrances:
if entrance.name in entrances_to_hint:
if hint_count:
this_hint = entrances_to_hint[entrance.name] + ' leads to ' + hint_text(
entrance.connected_region) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint.pop(entrance.name)
hint_count -= 1
else:
break
tt['sign_north_of_links_house'] = '> Randomizer The telepathic tiles can have hints!'
hint_locations = HintLocations.copy()
local_random.shuffle(hint_locations)
all_entrances = [entrance for entrance in world.get_entrances() if entrance.player == player]
local_random.shuffle(all_entrances)
# Next we handle hints for randomly selected other entrances, curating the selection intelligently based on shuffle.
if world.shuffle[player] not in ['simple', 'restricted', 'restricted_legacy']:
entrances_to_hint.update(ConnectorEntrances)
entrances_to_hint.update(DungeonEntrances)
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Agahnims Tower': 'The dark mountain tower'})
else:
entrances_to_hint.update({'Agahnims Tower': 'The sealed castle door'})
elif world.shuffle[player] == 'restricted':
entrances_to_hint.update(ConnectorEntrances)
entrances_to_hint.update(OtherEntrances)
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Dark Sanctuary': 'The dark sanctuary cave'})
entrances_to_hint.update({'Inverted Big Bomb Shop': 'The old hero\'s dark home'})
entrances_to_hint.update({'Inverted Links House': 'The old hero\'s light home'})
else:
entrances_to_hint.update({'Dark Sanctuary Hint': 'The dark sanctuary cave'})
entrances_to_hint.update({'Big Bomb Shop': 'The old bomb shop'})
if world.shuffle[player] in ['insanity', 'madness_legacy', 'insanity_legacy']:
entrances_to_hint.update(InsanityEntrances)
# First we take care of the one inconvenient dungeon in the appropriately simple shuffles.
entrances_to_hint = {}
entrances_to_hint.update(InconvenientDungeonEntrances)
if world.shuffle_ganon:
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Pyramid Entrance': 'The extra castle passage'})
entrances_to_hint.update({'Inverted Ganons Tower': 'The sealed castle door'})
else:
entrances_to_hint.update({'Pyramid Ledge': 'The pyramid ledge'})
hint_count = 4 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 0
for entrance in all_entrances:
if entrance.name in entrances_to_hint:
if hint_count:
this_hint = entrances_to_hint[entrance.name] + ' leads to ' + hint_text(
entrance.connected_region) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint.pop(entrance.name)
hint_count -= 1
else:
break
# Next we write a few hints for specific inconvenient locations. We don't make many because in entrance this is highly unpredictable.
locations_to_hint = InconvenientLocations.copy()
if world.shuffle[player] in ['vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed']:
locations_to_hint.extend(InconvenientVanillaLocations)
local_random.shuffle(locations_to_hint)
hint_count = 3 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 5
for location in locations_to_hint[:hint_count]:
if location == 'Swamp Left':
if local_random.randint(0, 1):
first_item = hint_text(world.get_location('Swamp Palace - West Chest', player).item)
second_item = hint_text(world.get_location('Swamp Palace - Big Key Chest', player).item)
else:
second_item = hint_text(world.get_location('Swamp Palace - West Chest', player).item)
first_item = hint_text(world.get_location('Swamp Palace - Big Key Chest', player).item)
this_hint = ('The westmost chests in Swamp Palace contain ' + first_item + ' and ' + second_item + '.')
tt[hint_locations.pop(0)] = this_hint
elif location == 'Mire Left':
if local_random.randint(0, 1):
first_item = hint_text(world.get_location('Misery Mire - Compass Chest', player).item)
second_item = hint_text(world.get_location('Misery Mire - Big Key Chest', player).item)
else:
second_item = hint_text(world.get_location('Misery Mire - Compass Chest', player).item)
first_item = hint_text(world.get_location('Misery Mire - Big Key Chest', player).item)
this_hint = ('The westmost chests in Misery Mire contain ' + first_item + ' and ' + second_item + '.')
tt[hint_locations.pop(0)] = this_hint
elif location == 'Tower of Hera - Big Key Chest':
this_hint = 'Waiting in the Tower of Hera basement leads to ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Ganons Tower - Big Chest':
this_hint = 'The big chest in Ganon\'s Tower contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Thieves\' Town - Big Chest':
this_hint = 'The big chest in Thieves\' Town contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Ice Palace - Big Chest':
this_hint = 'The big chest in Ice Palace contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Eastern Palace - Big Key Chest':
this_hint = 'The antifairy guarded chest in Eastern Palace contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Sahasrahla':
this_hint = 'Sahasrahla seeks a green pendant for ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Graveyard Cave':
this_hint = 'The cave north of the graveyard contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint.update({'Ganons Tower': 'Ganon\'s Tower'})
if world.shuffle[player] in ['simple', 'restricted', 'restricted_legacy']:
for entrance in all_entrances:
if entrance.name in entrances_to_hint:
this_hint = entrances_to_hint[entrance.name] + ' leads to ' + hint_text(
entrance.connected_region) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint = {}
break
# Now we write inconvenient locations for most shuffles and finish taking care of the less chaotic ones.
entrances_to_hint.update(InconvenientOtherEntrances)
if world.shuffle[player] in ['vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed']:
hint_count = 0
elif world.shuffle[player] in ['simple', 'restricted', 'restricted_legacy']:
hint_count = 2
else:
this_hint = location + ' contains ' + hint_text(world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
hint_count = 4
for entrance in all_entrances:
if entrance.name in entrances_to_hint:
if hint_count:
this_hint = entrances_to_hint[entrance.name] + ' leads to ' + hint_text(
entrance.connected_region) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint.pop(entrance.name)
hint_count -= 1
else:
break
# Lastly we write hints to show where certain interesting items are. It is done the way it is to re-use the silver code and also to give one hint per each type of item regardless of how many exist. This supports many settings well.
items_to_hint = RelevantItems.copy()
if world.smallkey_shuffle[player]:
items_to_hint.extend(SmallKeys)
if world.bigkey_shuffle[player]:
items_to_hint.extend(BigKeys)
local_random.shuffle(items_to_hint)
hint_count = 5 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 8
while hint_count > 0 and items_to_hint:
this_item = items_to_hint.pop(0)
this_location = world.find_items(this_item, player)
if this_location:
local_random.shuffle(this_location)
this_hint = this_location[0].item.hint_text + ' can be found ' + hint_text(this_location[0]) + '.'
tt[hint_locations.pop(0)] = this_hint
hint_count -= 1
# Next we handle hints for randomly selected other entrances,
# curating the selection intelligently based on shuffle.
if world.shuffle[player] not in ['simple', 'restricted', 'restricted_legacy']:
entrances_to_hint.update(ConnectorEntrances)
entrances_to_hint.update(DungeonEntrances)
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Agahnims Tower': 'The dark mountain tower'})
else:
entrances_to_hint.update({'Agahnims Tower': 'The sealed castle door'})
elif world.shuffle[player] == 'restricted':
entrances_to_hint.update(ConnectorEntrances)
entrances_to_hint.update(OtherEntrances)
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Dark Sanctuary': 'The dark sanctuary cave'})
entrances_to_hint.update({'Inverted Big Bomb Shop': 'The old hero\'s dark home'})
entrances_to_hint.update({'Inverted Links House': 'The old hero\'s light home'})
else:
entrances_to_hint.update({'Dark Sanctuary Hint': 'The dark sanctuary cave'})
entrances_to_hint.update({'Big Bomb Shop': 'The old bomb shop'})
if world.shuffle[player] in ['insanity', 'madness_legacy', 'insanity_legacy']:
entrances_to_hint.update(InsanityEntrances)
if world.shuffle_ganon:
if world.mode[player] == 'inverted':
entrances_to_hint.update({'Inverted Pyramid Entrance': 'The extra castle passage'})
else:
entrances_to_hint.update({'Pyramid Ledge': 'The pyramid ledge'})
hint_count = 4 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 0
for entrance in all_entrances:
if entrance.name in entrances_to_hint:
if hint_count:
this_hint = entrances_to_hint[entrance.name] + ' leads to ' + hint_text(
entrance.connected_region) + '.'
tt[hint_locations.pop(0)] = this_hint
entrances_to_hint.pop(entrance.name)
hint_count -= 1
else:
break
# All remaining hint slots are filled with junk hints. It is done this way to ensure the same junk hint isn't selected twice.
junk_hints = junk_texts.copy()
local_random.shuffle(junk_hints)
for location, text in zip(hint_locations, junk_hints):
tt[location] = text
# Next we write a few hints for specific inconvenient locations. We don't make many because in entrance this is highly unpredictable.
locations_to_hint = InconvenientLocations.copy()
if world.shuffle[player] in ['vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed']:
locations_to_hint.extend(InconvenientVanillaLocations)
local_random.shuffle(locations_to_hint)
hint_count = 3 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 5
for location in locations_to_hint[:hint_count]:
if location == 'Swamp Left':
if local_random.randint(0, 1):
first_item = hint_text(world.get_location('Swamp Palace - West Chest', player).item)
second_item = hint_text(world.get_location('Swamp Palace - Big Key Chest', player).item)
else:
second_item = hint_text(world.get_location('Swamp Palace - West Chest', player).item)
first_item = hint_text(world.get_location('Swamp Palace - Big Key Chest', player).item)
this_hint = ('The westmost chests in Swamp Palace contain ' + first_item + ' and ' + second_item + '.')
tt[hint_locations.pop(0)] = this_hint
elif location == 'Mire Left':
if local_random.randint(0, 1):
first_item = hint_text(world.get_location('Misery Mire - Compass Chest', player).item)
second_item = hint_text(world.get_location('Misery Mire - Big Key Chest', player).item)
else:
second_item = hint_text(world.get_location('Misery Mire - Compass Chest', player).item)
first_item = hint_text(world.get_location('Misery Mire - Big Key Chest', player).item)
this_hint = ('The westmost chests in Misery Mire contain ' + first_item + ' and ' + second_item + '.')
tt[hint_locations.pop(0)] = this_hint
elif location == 'Tower of Hera - Big Key Chest':
this_hint = 'Waiting in the Tower of Hera basement leads to ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Ganons Tower - Big Chest':
this_hint = 'The big chest in Ganon\'s Tower contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Thieves\' Town - Big Chest':
this_hint = 'The big chest in Thieves\' Town contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Ice Palace - Big Chest':
this_hint = 'The big chest in Ice Palace contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Eastern Palace - Big Key Chest':
this_hint = 'The antifairy guarded chest in Eastern Palace contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Sahasrahla':
this_hint = 'Sahasrahla seeks a green pendant for ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
elif location == 'Graveyard Cave':
this_hint = 'The cave north of the graveyard contains ' + hint_text(
world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
else:
this_hint = location + ' contains ' + hint_text(world.get_location(location, player).item) + '.'
tt[hint_locations.pop(0)] = this_hint
# Lastly we write hints to show where certain interesting items are. It is done the way it is to re-use the silver code and also to give one hint per each type of item regardless of how many exist. This supports many settings well.
items_to_hint = RelevantItems.copy()
if world.smallkey_shuffle[player]:
items_to_hint.extend(SmallKeys)
if world.bigkey_shuffle[player]:
items_to_hint.extend(BigKeys)
local_random.shuffle(items_to_hint)
if world.hints[player] == "full":
hint_count = len(hint_locations) # fill all remaining hint locations with Item hints.
else:
hint_count = 5 if world.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull',
'dungeonscrossed'] else 8
hint_count = min(hint_count, len(items_to_hint), len(hint_locations))
if hint_count:
locations = world.find_items_in_locations(set(items_to_hint), player)
local_random.shuffle(locations)
for x in range(hint_count):
this_location = locations.pop()
this_hint = this_location.item.hint_text + ' can be found ' + hint_text(this_location) + '.'
tt[hint_locations.pop(0)] = this_hint
if hint_locations:
# All remaining hint slots are filled with junk hints.
# It is done this way to ensure the same junk hint isn't selected twice.
junk_hints = junk_texts.copy()
local_random.shuffle(junk_hints)
for location, text in zip(hint_locations, junk_hints):
tt[location] = text
# We still need the older hints of course. Those are done here.
silverarrows = world.find_items('Silver Bow', player)
silverarrows = world.find_item_locations('Silver Bow', player)
local_random.shuffle(silverarrows)
silverarrow_hint = (
' %s?' % hint_text(silverarrows[0]).replace('Ganon\'s', 'my')) if silverarrows else '?\nI think not!'
@@ -2302,7 +2310,7 @@ def write_strings(rom, world, player):
tt['ganon_phase_3_no_silvers_alt'] = 'Did you find the silver arrows%s' % silverarrow_hint
if world.worlds[player].has_progressive_bows and (world.difficulty_requirements[player].progressive_bow_limit >= 2 or (
world.swordless[player] or world.logic[player] == 'noglitches')):
prog_bow_locs = world.find_items('Progressive Bow', player)
prog_bow_locs = world.find_item_locations('Progressive Bow', player)
world.slot_seeds[player].shuffle(prog_bow_locs)
found_bow = False
found_bow_alt = False

View File

@@ -284,7 +284,7 @@ junk_texts = [
"{C:GREEN}\nTheres always\nmoney in the\nBanana Stand>",
"{C:GREEN}\n \nJust walk away\n >",
"{C:GREEN}\neverybody is\nlooking for\nsomething >",
"{C:GREEN}\nSpring Ball\nare behind\nRidley >",
# "{C:GREEN}\nSpring Ball\nare behind\nRidley >", removed as people may assume it's a real hint
"{C:GREEN}\nThe gnome asks\nyou to guess\nhis name. >",
"{C:GREEN}\nI heard beans\non toast is a\ngreat meal. >",
"{C:GREEN}\n> Sweetcorn\non pizza is a\ngreat choice.",

View File

@@ -12,7 +12,7 @@ import shutil
from . import Options
from BaseClasses import MultiWorld
from .Technologies import tech_table, rocket_recipes, recipes, free_sample_blacklist, progressive_technology_table, \
base_tech_table, tech_to_progressive_lookup, progressive_tech_table
base_tech_table, tech_to_progressive_lookup, progressive_tech_table, liquids
template_env: Optional[jinja2.Environment] = None
@@ -47,10 +47,16 @@ recipe_time_scales = {
Options.RecipeTime.option_vanilla: None
}
recipe_time_ranges = {
Options.RecipeTime.option_new_fast: (0.25, 2),
Options.RecipeTime.option_new_normal: (0.25, 10),
Options.RecipeTime.option_slow: (5, 10)
}
def generate_mod(world, output_directory: str):
player = world.player
multiworld = world.world
global data_final_template, locale_template, control_template, data_template
global data_final_template, locale_template, control_template, data_template, settings_template
with template_load_lock:
if not data_final_template:
mod_template_folder = os.path.join(os.path.dirname(__file__), "data", "mod_template")
@@ -60,6 +66,7 @@ def generate_mod(world, output_directory: str):
data_final_template = template_env.get_template("data-final-fixes.lua")
locale_template = template_env.get_template(r"locale/en/locale.cfg")
control_template = template_env.get_template("control.lua")
settings_template = template_env.get_template("settings.lua")
# get data for templates
player_names = {x: multiworld.player_name[x] for x in multiworld.player_ids}
locations = []
@@ -91,26 +98,38 @@ def generate_mod(world, output_directory: str):
"mod_name": mod_name, "allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(),
"tech_cost_scale": tech_cost_scale, "custom_technologies": multiworld.worlds[player].custom_technologies,
"tech_tree_layout_prerequisites": multiworld.tech_tree_layout_prerequisites[player],
"slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name,
"slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name, "slot_player": player,
"starting_items": multiworld.starting_items[player], "recipes": recipes,
"random": random, "flop_random": flop_random,
"static_nodes": multiworld.worlds[player].static_nodes,
"recipe_time_scale": recipe_time_scales[multiworld.recipe_time[player].value],
"recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None),
"recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None),
"free_sample_blacklist": {item : 1 for item in free_sample_blacklist},
"progressive_technology_table": {tech.name : tech.progressive for tech in
progressive_technology_table.values()},
"custom_recipes": world.custom_recipes,
"max_science_pack": multiworld.max_science_pack[player].value}
"max_science_pack": multiworld.max_science_pack[player].value,
"liquids": liquids,
"goal": multiworld.goal[player].value}
for factorio_option in Options.factorio_options:
if factorio_option in ["free_sample_blacklist", "free_sample_whitelist"]:
continue
template_data[factorio_option] = getattr(multiworld, factorio_option)[player].value
if getattr(multiworld, "silo")[player].value == Options.Silo.option_randomize_recipe:
template_data["free_sample_blacklist"]["rocket-silo"] = 1
if getattr(multiworld, "satellite")[player].value == Options.Satellite.option_randomize_recipe:
template_data["free_sample_blacklist"]["satellite"] = 1
template_data["free_sample_blacklist"].update({item: 1 for item in multiworld.free_sample_blacklist[player].value})
template_data["free_sample_blacklist"].update({item: 0 for item in multiworld.free_sample_whitelist[player].value})
control_code = control_template.render(**template_data)
data_template_code = data_template.render(**template_data)
data_final_fixes_code = data_final_template.render(**template_data)
settings_code = settings_template.render(**template_data)
mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)
en_locale_dir = os.path.join(mod_dir, "locale", "en")
@@ -122,6 +141,8 @@ def generate_mod(world, output_directory: str):
f.write(data_final_fixes_code)
with open(os.path.join(mod_dir, "control.lua"), "wt") as f:
f.write(control_code)
with open(os.path.join(mod_dir, "settings.lua"), "wt") as f:
f.write(settings_code)
locale_content = locale_template.render(**template_data)
with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f:
f.write(locale_content)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import typing
from Options import Choice, OptionDict, ItemDict, Option, DefaultOnToggle, Range, DeathLink
from Options import Choice, OptionDict, OptionSet, ItemDict, Option, DefaultOnToggle, Range, DeathLink
from schema import Schema, Optional, And, Or
# schema helpers
@@ -33,6 +33,14 @@ class MaxSciencePack(Choice):
return self.get_ordered_science_packs()[self.value].replace("_", "-")
class Goal(Choice):
"""Goal required to complete the game."""
displayname = "Goal"
option_rocket = 0
option_satellite = 1
default = 0
class TechCost(Choice):
"""How expensive are the technologies."""
displayname = "Technology Cost Scale"
@@ -55,6 +63,14 @@ class Silo(Choice):
default = 0
class Satellite(Choice):
"""Ingredients to craft satellite."""
displayname = "Satellite"
option_vanilla = 0
option_randomize_recipe = 1
default = 0
class FreeSamples(Choice):
"""Get free items with your technologies."""
displayname = "Free Samples"
@@ -91,13 +107,25 @@ class TechTreeInformation(Choice):
class RecipeTime(Choice):
"""randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc."""
"""Randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc.
Fast: 0.25X - 1X
Normal: 0.5X - 2X
Slow: 1X - 4X
Chaos: 0.25X - 4X
New category: ignores vanilla recipe time and rolls new one
New Fast: 0.25 - 2 seconds
New Normal: 0.25 - 10 seconds
New Slow: 5 - 10 seconds
"""
displayname = "Recipe Time"
option_vanilla = 0
option_fast = 1
option_normal = 2
option_slow = 4
option_chaos = 5
option_new_fast = 6
option_new_normal = 7
option_new_slow = 8
class Progressive(Choice):
@@ -126,6 +154,15 @@ class FactorioStartItems(ItemDict):
default = {"burner-mining-drill": 19, "stone-furnace": 19}
class FactorioFreeSampleBlacklist(OptionSet):
displayname = "Free Sample Blacklist"
class FactorioFreeSampleWhitelist(OptionSet):
"""overrides any free sample blacklist present. This may ruin the balance of the mod, be forewarned."""
displayname = "Free Sample Whitelist"
class TrapCount(Range):
range_end = 4
@@ -286,12 +323,16 @@ class ImportedBlueprint(DefaultOnToggle):
factorio_options: typing.Dict[str, type(Option)] = {
"max_science_pack": MaxSciencePack,
"goal": Goal,
"tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost,
"silo": Silo,
"satellite": Satellite,
"free_samples": FreeSamples,
"tech_tree_information": TechTreeInformation,
"starting_items": FactorioStartItems,
"free_sample_blacklist": FactorioFreeSampleBlacklist,
"free_sample_whitelist": FactorioFreeSampleWhitelist,
"recipe_time": RecipeTime,
"recipe_ingredients": RecipeIngredients,
"imported_blueprints": ImportedBlueprint,

View File

@@ -59,8 +59,8 @@ class Technology(FactorioElement): # maybe make subclass of Location?
def build_rule(self, player: int):
logging.debug(f"Building rules for {self.name}")
return lambda state, technologies=technologies: all(state.has(f"Automated {ingredient}", player)
for ingredient in self.ingredients)
return lambda state: all(state.has(f"Automated {ingredient}", player)
for ingredient in self.ingredients)
def get_prior_technologies(self) -> Set[Technology]:
"""Get Technologies that have to precede this one to resolve tree connections."""
@@ -131,6 +131,7 @@ class Recipe(FactorioElement):
base = {technology_table[tech_name] for tech_name in recipe_sources.get(self.name, ())}
for ingredient in self.ingredients:
base |= required_technologies[ingredient]
base |= required_technologies[self.crafting_machine]
return base
@property
@@ -300,19 +301,17 @@ for category_name, machine_name in machine_per_category.items():
required_technologies: Dict[str, FrozenSet[Technology]] = Utils.KeyedDefaultDict(lambda ingredient_name: frozenset(
recursively_get_unlocking_technologies(ingredient_name, unlock_func=unlock)))
advancement_technologies: Set[str] = set()
for ingredient_name in all_ingredient_names:
technologies = required_technologies[ingredient_name]
advancement_technologies |= {technology.name for technology in technologies}
def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe) -> Set[str]:
def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe, satellite_recipe: Recipe) -> Set[str]:
techs = set()
if silo_recipe:
for ingredient in silo_recipe.ingredients:
techs |= recursively_get_unlocking_technologies(ingredient)
for ingredient in part_recipe.ingredients:
techs |= recursively_get_unlocking_technologies(ingredient)
if satellite_recipe:
techs |= satellite_recipe.unlocking_technologies
for ingredient in satellite_recipe.ingredients:
techs |= recursively_get_unlocking_technologies(ingredient)
return {tech.name for tech in techs}
@@ -335,8 +334,6 @@ rocket_recipes = {
{"copper-cable": 10, "iron-plate": 10, "wood": 10}
}
advancement_technologies |= {tech.name for tech in required_technologies["rocket-silo"]}
# progressive technologies
# auto-progressive
progressive_rows: Dict[str, Union[List[str], Tuple[str, ...]]] = {}
@@ -430,8 +427,6 @@ for root in sorted_rows:
unlocks=any(technology_table[tech].unlocks for tech in progressive))
progressive_tech_table[root] = progressive_technology.factorio_id
progressive_technology_table[root] = progressive_technology
if any(tech in advancement_technologies for tech in progressive):
advancement_technologies.add(root)
tech_to_progressive_lookup: Dict[str, str] = {}
for technology in progressive_technology_table.values():
@@ -464,9 +459,8 @@ rel_cost = {
"used-up-uranium-fuel-cell": 1000
}
# forbid liquids for now, TODO: allow a single liquid per assembler
blacklist: Set[str] = all_ingredient_names | {"rocket-part", "crude-oil", "water", "sulfuric-acid", "petroleum-gas",
"light-oil", "heavy-oil", "lubricant", "steam"}
blacklist: Set[str] = all_ingredient_names | {"rocket-part"}
liquids: Set[str] = {"crude-oil", "water", "sulfuric-acid", "petroleum-gas", "light-oil", "heavy-oil", "lubricant", "steam"}
@Utils.cache_argsless
@@ -479,7 +473,7 @@ def get_science_pack_pools() -> Dict[str, Set[str]]:
cost += rel_cost.get(ingredient_name, 1) * amount
return cost
science_pack_pools = {}
science_pack_pools: Dict[str, Set[str]] = {}
already_taken = blacklist.copy()
current_difficulty = 5
for science_pack in Options.MaxSciencePack.get_ordered_science_packs():
@@ -490,6 +484,10 @@ def get_science_pack_pools() -> Dict[str, Set[str]]:
current |= set(recipe.products)
if science_pack == "automation-science-pack":
current |= {"iron-ore", "copper-ore", "coal", "stone"}
# Can't hand craft automation science if liquids end up in its recipe, making the seed impossible.
current -= liquids
elif science_pack == "logistic-science-pack":
current |= {"steam"}
current -= already_taken
already_taken |= current
current_difficulty *= 2

View File

@@ -1,15 +1,17 @@
import collections
import typing
from ..AutoWorld import World
from BaseClasses import Region, Entrance, Location, Item
from .Technologies import base_tech_table, recipe_sources, base_technology_table, advancement_technologies, \
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, rocket_recipes, \
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
liquids
from .Shapes import get_shapes
from .Mod import generate_mod
from .Options import factorio_options, Silo, TechTreeInformation
from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal
import logging
@@ -32,13 +34,17 @@ class Factorio(World):
game: str = "Factorio"
static_nodes = {"automation", "logistics", "rocket-silo"}
custom_recipes = {}
additional_advancement_technologies = set()
advancement_technologies: typing.Set[str]
item_name_to_id = all_items
location_name_to_id = base_tech_table
data_version = 5
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
self.advancement_technologies = set()
def generate_basic(self):
player = self.player
want_progressives = collections.defaultdict(lambda: self.world.progressive[player].
@@ -136,12 +142,16 @@ class Factorio(World):
Rules.add_rule(location, lambda state,
locations=locations: all(state.can_reach(loc) for loc in locations))
silo_recipe = None if self.world.silo[self.player].value == Silo.option_spawn \
else self.custom_recipes["rocket-silo"] \
if "rocket-silo" in self.custom_recipes \
silo_recipe = None
if self.world.silo[self.player] == Silo.option_spawn:
silo_recipe = self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \
else next(iter(all_product_sources.get("rocket-silo")))
part_recipe = self.custom_recipes["rocket-part"]
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe)
satellite_recipe = None
if self.world.goal[self.player] == Goal.option_satellite:
satellite_recipe = self.custom_recipes["satellite"] if "satellite" in self.custom_recipes \
else next(iter(all_product_sources.get("satellite")))
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe)
world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player)
for technology in
victory_tech_names)
@@ -163,7 +173,7 @@ class Factorio(World):
return super(Factorio, self).collect_item(state, item, remove)
def get_required_client_version(self) -> tuple:
return max((0, 1, 6), super(Factorio, self).get_required_client_version())
return max((0, 2, 1), super(Factorio, self).get_required_client_version())
options = factorio_options
@@ -176,7 +186,29 @@ class Factorio(World):
for recipe in world.worlds[player].custom_recipes.values():
spoiler_handle.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
def make_balanced_recipe(self, original: Recipe, pool: list, factor: float = 1) -> Recipe:
@staticmethod
def get_category(category: str, liquids: int) -> str:
categories = {1: "crafting-with-fluid",
2: "chemistry"}
return categories.get(liquids, category)
def make_quick_recipe(self, original: Recipe, pool: list, allow_liquids: int = 2) -> Recipe:
new_ingredients = {}
liquids_used = 0
for _ in original.ingredients:
new_ingredient = pool.pop()
if new_ingredient in liquids:
while liquids_used == allow_liquids and new_ingredient in liquids:
# liquids already at max for current recipe. Return the liquid to the pool, shuffle, and get a new ingredient.
pool.append(new_ingredient)
self.world.random.shuffle(pool)
new_ingredient = pool.pop()
liquids_used += 1
new_ingredients[new_ingredient] = 1
return Recipe(original.name, self.get_category(original.category, liquids_used), new_ingredients, original.products, original.energy)
def make_balanced_recipe(self, original: Recipe, pool: list, factor: float = 1, allow_liquids: int = 2) -> \
Recipe:
"""Generate a recipe from pool with time and cost similar to original * factor"""
new_ingredients = {}
self.world.random.shuffle(pool)
@@ -187,20 +219,13 @@ class Factorio(World):
remaining_energy = target_energy
remaining_num_ingredients = target_num_ingredients
fallback_pool = []
liquids_used = 0
# fill all but one slot with random ingredients, last with a good match
while remaining_num_ingredients > 0 and len(pool) > 0:
if remaining_num_ingredients == 1:
max_raw = 1.1 * remaining_raw
min_raw = 0.9 * remaining_raw
max_energy = 1.1 * remaining_energy
min_energy = 1.1 * remaining_energy
else:
max_raw = remaining_raw * 0.75
min_raw = (remaining_raw - max_raw) / remaining_num_ingredients
max_energy = remaining_energy * 0.75
min_energy = (remaining_energy - max_energy) / remaining_num_ingredients
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if liquids_used == allow_liquids and ingredient in liquids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
if ingredient in all_product_sources:
ingredient_recipe = min(all_product_sources[ingredient], key=lambda recipe: recipe.rel_cost)
ingredient_raw = sum((count for ingredient, count in ingredient_recipe.base_cost.items()))
@@ -209,6 +234,16 @@ class Factorio(World):
# assume simple ore TODO: remove if tree when mining data is harvested from Factorio
ingredient_raw = 1
ingredient_energy = 2
if remaining_num_ingredients == 1:
max_raw = 1.1 * remaining_raw
min_raw = 0.9 * remaining_raw
max_energy = 1.1 * remaining_energy
min_energy = 0.9 * remaining_energy
else:
max_raw = remaining_raw * 0.75
min_raw = (remaining_raw - max_raw) / remaining_num_ingredients
max_energy = remaining_energy * 0.75
min_energy = (remaining_energy - max_energy) / remaining_num_ingredients
min_num_raw = min_raw / ingredient_raw
max_num_raw = max_raw / ingredient_raw
min_num_energy = min_energy / ingredient_energy
@@ -223,30 +258,41 @@ class Factorio(World):
remaining_raw -= num * ingredient_raw
remaining_energy -= num * ingredient_energy
remaining_num_ingredients -= 1
if ingredient in liquids:
liquids_used += 1
# fill failed slots with whatever we got
pool = fallback_pool
while remaining_num_ingredients > 0 and len(pool) > 0:
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if ingredient not in recipes:
if liquids_used == allow_liquids and ingredient in liquids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
ingredient_recipe = recipes.get(ingredient, None)
if not ingredient_recipe and ingredient.endswith("-barrel"):
ingredient_recipe = recipes.get(f"fill-{ingredient}", None)
if not ingredient_recipe:
logging.warning(f"missing recipe for {ingredient}")
continue
ingredient_recipe = recipes[ingredient]
ingredient_raw = sum((count for ingredient, count in ingredient_recipe.base_cost.items()))
ingredient_energy = ingredient_recipe.total_energy
num_raw = remaining_raw / ingredient_raw / remaining_num_ingredients
num_energy = remaining_energy / ingredient_energy / remaining_num_ingredients
num = int(min(num_raw, num_energy))
if num < 1: continue
if num < 1:
continue
new_ingredients[ingredient] = num
remaining_raw -= num * ingredient_raw
remaining_energy -= num * ingredient_energy
remaining_num_ingredients -= 1
if ingredient in liquids:
liquids_used += 1
if remaining_num_ingredients > 1:
logging.warning("could not randomize recipe")
return Recipe(original.name, original.category, new_ingredients, original.products, original.energy)
return Recipe(original.name, self.get_category(original.category, liquids_used), new_ingredients, original.products, original.energy)
def set_custom_technologies(self):
custom_technologies = {}
@@ -260,12 +306,12 @@ class Factorio(World):
science_pack_pools = get_science_pack_pools()
valid_pool = sorted(science_pack_pools[self.world.max_science_pack[self.player].get_max_pack()])
self.world.random.shuffle(valid_pool)
while any([valid_pool[x] in liquids for x in range(3)]):
self.world.random.shuffle(valid_pool)
self.custom_recipes = {"rocket-part": Recipe("rocket-part", original_rocket_part.category,
{valid_pool[x]: 10 for x in range(3)},
original_rocket_part.products,
original_rocket_part.energy)}
self.additional_advancement_technologies = {tech.name for tech in
self.custom_recipes["rocket-part"].recursive_unlocking_technologies}
if self.world.recipe_ingredients[self.player]:
valid_pool = []
@@ -273,36 +319,45 @@ class Factorio(World):
valid_pool += sorted(science_pack_pools[pack])
self.world.random.shuffle(valid_pool)
if pack in recipes: # skips over space science pack
original = recipes[pack]
new_ingredients = {}
for _ in original.ingredients:
new_ingredients[valid_pool.pop()] = 1
new_recipe = Recipe(pack, original.category, new_ingredients, original.products, original.energy)
self.additional_advancement_technologies |= {tech.name for tech in
new_recipe.recursive_unlocking_technologies}
new_recipe = self.make_quick_recipe(recipes[pack], valid_pool)
self.custom_recipes[pack] = new_recipe
if self.world.silo[self.player].value == Silo.option_randomize_recipe:
if self.world.silo[self.player].value == Silo.option_randomize_recipe \
or self.world.satellite[self.player].value == Satellite.option_randomize_recipe:
valid_pool = []
for pack in sorted(self.world.max_science_pack[self.player].get_allowed_packs()):
valid_pool += sorted(science_pack_pools[pack])
new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool,
factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.additional_advancement_technologies |= {tech.name for tech in
new_recipe.recursive_unlocking_technologies}
self.custom_recipes["rocket-silo"] = new_recipe
if self.world.silo[self.player].value == Silo.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool.copy(),
factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.custom_recipes["rocket-silo"] = new_recipe
if self.world.satellite[self.player].value == Satellite.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(recipes["satellite"], valid_pool,
factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.custom_recipes["satellite"] = new_recipe
needed_recipes = self.world.max_science_pack[self.player].get_allowed_packs() | {"rocket-part"}
if self.world.silo[self.player] != Silo.option_spawn:
needed_recipes |= {"rocket-silo"}
if self.world.goal[self.player].value == Goal.option_satellite:
needed_recipes |= {"satellite"}
for recipe in needed_recipes:
recipe = self.custom_recipes.get(recipe, recipes[recipe])
self.advancement_technologies |= {tech.name for tech in recipe.recursive_unlocking_technologies}
# handle marking progressive techs as advancement
prog_add = set()
for tech in self.additional_advancement_technologies:
for tech in self.advancement_technologies:
if tech in tech_to_progressive_lookup:
prog_add.add(tech_to_progressive_lookup[tech])
self.additional_advancement_technologies |= prog_add
self.advancement_technologies |= prog_add
def create_item(self, name: str) -> Item:
if name in tech_table:
return FactorioItem(name, name in advancement_technologies or
name in self.additional_advancement_technologies,
return FactorioItem(name, name in self.advancement_technologies,
tech_table[name], self.player)
item = FactorioItem(name, False, all_items[name], self.player)

View File

@@ -8,7 +8,15 @@ SLOT_NAME = "{{ slot_name }}"
SEED_NAME = "{{ seed_name }}"
FREE_SAMPLE_BLACKLIST = {{ dict_to_lua(free_sample_blacklist) }}
TRAP_EVO_FACTOR = {{ evolution_trap_increase }} / 100
DEATH_LINK = {{ death_link | int }}
MAX_SCIENCE_PACK = {{ max_science_pack }}
GOAL = {{ goal }}
ARCHIPELAGO_DEATH_LINK_SETTING = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}"
if settings.global[ARCHIPELAGO_DEATH_LINK_SETTING].value then
DEATH_LINK = 1
else
DEATH_LINK = 0
end
CURRENTLY_DEATH_LOCK = 0
@@ -76,6 +84,27 @@ function on_force_destroyed(event)
global.forcedata[event.force.name] = nil
end
function on_runtime_mod_setting_changed(event)
local force
if event.player_index == nil then
force = game.forces.player
else
force = game.players[event.player_index].force
end
if event.setting == ARCHIPELAGO_DEATH_LINK_SETTING then
if settings.global[ARCHIPELAGO_DEATH_LINK_SETTING].value then
DEATH_LINK = 1
else
DEATH_LINK = 0
end
if force ~= nil then
dumpInfo(force)
end
end
end
script.on_event(defines.events.on_runtime_mod_setting_changed, on_runtime_mod_setting_changed)
-- Initialize player data, either from them joining the game or them already being part of the game when the mod was
-- added.`
function on_player_created(event)
@@ -107,8 +136,19 @@ end
script.on_event(defines.events.on_player_removed, on_player_removed)
function on_rocket_launched(event)
global.forcedata[event.rocket.force.name]['victory'] = 1
dumpInfo(event.rocket.force)
if event.rocket and event.rocket.valid and global.forcedata[event.rocket.force.name]['victory'] == 0 then
if event.rocket.get_item_count("satellite") > 0 or GOAL == 0 then
global.forcedata[event.rocket.force.name]['victory'] = 1
dumpInfo(event.rocket.force)
game.set_game_state
{
game_finished = true,
player_won = true,
can_continue = true,
victorious_force = event.rocket.force
}
end
end
end
script.on_event(defines.events.on_rocket_launched, on_rocket_launched)
@@ -198,6 +238,10 @@ script.on_init(function()
e.player_index = index
on_player_created(e)
end
if remote.interfaces["silo_script"] then
remote.call("silo_script", "set_no_victory", true)
end
end)
-- hook into researches done
@@ -366,18 +410,19 @@ function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores)
end
if DEATH_LINK == 1 then
script.on_event(defines.events.on_entity_died, function(event)
if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event
return
end
script.on_event(defines.events.on_entity_died, function(event)
if DEATH_LINK == 0 then
return
end
if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event
return
end
local force = event.entity.force
global.forcedata[force.name].death_link_tick = game.tick
dumpInfo(force)
kill_players(force)
end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}})
end
local force = event.entity.force
global.forcedata[force.name].death_link_tick = game.tick
dumpInfo(force)
kill_players(force)
end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}})
-- add / commands
@@ -392,7 +437,8 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress
local data_collection = {
["research_done"] = research_done,
["victory"] = chain_lookup(global, "forcedata", force.name, "victory"),
["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick")
["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick"),
["death_link"] = DEATH_LINK
}
for tech_name, tech in pairs(force.technologies) do
@@ -442,9 +488,6 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
elseif force.technologies[item_name] ~= nil then
tech = force.technologies[item_name]
if tech ~= nil then
if global.index_sync[index] ~= nil and global.index_sync[index] ~= tech then
game.print("Warning: Desync Detected. Duplicate/Missing items may occur.")
end
global.index_sync[index] = tech
if tech.researched ~= true then
game.print({"", "Received [technology=" .. tech.name .. "] from ", source})

View File

@@ -3,7 +3,8 @@
require('lib')
{%- for recipe_name, recipe in custom_recipes.items() %}
data.raw["recipe"]["{{recipe_name}}"].ingredients = {{ dict_to_recipe(recipe.ingredients) }}
data.raw["recipe"]["{{recipe_name}}"].category = "{{recipe.category}}"
data.raw["recipe"]["{{recipe_name}}"].ingredients = {{ dict_to_recipe(recipe.ingredients, liquids) }}
{%- endfor %}
local technologies = data.raw["technology"]
@@ -24,6 +25,26 @@ template_tech.upgrade = false
template_tech.effects = {}
template_tech.prerequisites = {}
{%- if max_science_pack < 6 %}
technologies["space-science-pack"].effects = {}
{%- if max_science_pack == 0 %}
table.insert (technologies["automation"].effects, {type = "unlock-recipe", recipe = "satellite"})
{%- elif max_science_pack == 1 %}
table.insert (technologies["logistic-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"})
{%- elif max_science_pack == 2 %}
table.insert (technologies["military-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"})
{%- elif max_science_pack == 3 %}
table.insert (technologies["chemical-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"})
{%- elif max_science_pack == 4 %}
table.insert (technologies["production-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"})
{%- elif max_science_pack == 5 %}
table.insert (technologies["utility-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"})
{% endif %}
{% endif %}
{%- if silo == 2 %}
data.raw["recipe"]["rocket-silo"].enabled = true
{% endif %}
function prep_copy(new_copy, old_tech)
old_tech.hidden = true
local ingredient_filter = allowed_ingredients[old_tech.name]
@@ -42,9 +63,12 @@ function prep_copy(new_copy, old_tech)
weights[key] = value
end
SNI.setWeights(weights)
-- Just in case an ingredient is being added to an existing tech. Found the root cause of the 9.223e+18 problem.
-- Turns out science-not-invited was ultimately dividing by zero, due to it being unaware of there being added ingredients.
old_tech.unit.ingredients = add_ingredients(old_tech.unit.ingredients, ingredient_filter)
SNI.sendInvite(old_tech)
-- SCIENCE-not-invited could potentially make tech cost 9.223e+18.
old_tech.unit.count = math.min(10000, old_tech.unit.count)
old_tech.unit.count = math.min(100000, old_tech.unit.count)
end
new_copy.unit = table.deepcopy(old_tech.unit)
new_copy.unit.ingredients = filter_ingredients(new_copy.unit.ingredients, ingredient_filter)
@@ -100,6 +124,20 @@ function adjust_energy(recipe_name, factor)
end
end
function set_energy(recipe_name, energy)
local recipe = data.raw.recipe[recipe_name]
if (recipe.normal ~= nil) then
recipe.normal.energy_required = energy
end
if (recipe.expensive ~= nil) then
recipe.expensive.energy_required = energy
end
if (recipe.expensive == nil and recipe.normal == nil) then
recipe.energy_required = energy
end
end
data.raw["assembling-machine"]["assembling-machine-1"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories)
data.raw["assembling-machine"]["assembling-machine-2"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories)
data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes)
@@ -144,6 +182,12 @@ data:extend{new_tree_copy}
adjust_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_scale) }})
{%- endif %}
{%- endfor -%}
{% elif recipe_time_range %}
{%- for recipe_name, recipe in recipes.items() %}
{%- if recipe.category != "mining" %}
set_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_range) }})
{%- endif %}
{%- endfor -%}
{% endif %}
{%- if silo==2 %}

View File

@@ -22,4 +22,10 @@ ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends somet
{%- else %}
ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends something to someone. For purposes of hints, this location is called "{{ original_tech_name }}".
{%- endif -%}
{% endfor %}
{% endfor %}
[mod-setting-name]
archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Death Link
[mod-setting-description]
archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Kill other players in the same Archipelago Multiworld that also have Death Link turned on, when you die.

View File

@@ -20,10 +20,10 @@
{%- else -%} {{ value | safe }}
{%- endif -%}
{%- endmacro -%}
{% macro dict_to_recipe(dict) -%}
{% macro dict_to_recipe(dict, liquids) -%}
{
{%- for key, value in dict.items() -%}
{"{{ key }}", {{ value | safe }}}{% if not loop.last %},{% endif %}
{type = {% if key in liquids %}"fluid"{% else %}"item"{% endif %}, name = "{{ key }}", amount = {{ value | safe }}}{% if not loop.last %},{% endif %}
{% endfor -%}
}
{%- endmacro %}

View File

@@ -0,0 +1,12 @@
data:extend({
{
type = "bool-setting",
name = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}",
setting_type = "runtime-global",
{% if death_link %}
default_value = true
{% else %}
default_value = false
{% endif %}
}
})

71
worlds/ff1/Items.py Normal file
View File

@@ -0,0 +1,71 @@
import json
from pathlib import Path
from typing import Dict, Set, NamedTuple, List
from BaseClasses import Item
class ItemData(NamedTuple):
name: str
code: int
item_type: str
progression: bool
FF1_BRIDGE = 'Bridge'
FF1_STARTER_ITEMS = [
"Ship"
]
FF1_PROGRESSION_LIST = [
"Rod", "Cube", "Lute", "Key", "Chime", "Oxyale",
"Ship", "Canoe", "Floater", "Canal",
"Crown", "Crystal", "Herb", "Tnt", "Adamant", "Slab", "Ruby", "Bottle",
"Shard",
"EarthOrb", "FireOrb", "WaterOrb", "AirOrb"
]
class FF1Items:
_item_table: List[ItemData] = []
_item_table_lookup: Dict[str, ItemData] = {}
def _populate_item_table_from_data(self):
base_path = Path(__file__).parent
file_path = (base_path / "data/items.json").resolve()
with open(file_path) as file:
items = json.load(file)
# Hardcode progression and categories for now
self._item_table = [ItemData(name, code, "FF1Item", name in FF1_PROGRESSION_LIST)
for name, code in items.items()]
self._item_table_lookup = {item.name: item for item in self._item_table}
def _get_item_table(self) -> List[ItemData]:
if not self._item_table or not self._item_table_lookup:
self._populate_item_table_from_data()
return self._item_table
def _get_item_table_lookup(self) -> Dict[str, ItemData]:
if not self._item_table or not self._item_table_lookup:
self._populate_item_table_from_data()
return self._item_table_lookup
def get_item_names_per_category(self) -> Dict[str, Set[str]]:
categories: Dict[str, Set[str]] = {}
for item in self._get_item_table():
categories.setdefault(item.item_type, set()).add(item.name)
return categories
def generate_item(self, name: str, player: int) -> Item:
item = self._get_item_table_lookup().get(name)
return Item(name, item.progression, item.code, player)
def get_item_name_to_code_dict(self) -> Dict[str, int]:
return {name: item.code for name, item in self._get_item_table_lookup().items()}
def get_item(self, name: str) -> ItemData:
return self._get_item_table_lookup()[name]

75
worlds/ff1/Locations.py Normal file
View File

@@ -0,0 +1,75 @@
import json
from pathlib import Path
from typing import Dict, NamedTuple, List, Optional
from BaseClasses import Region, RegionType, Location
EventId: Optional[int] = None
CHAOS_TERMINATED_EVENT = 'Terminated Chaos'
class LocationData(NamedTuple):
name: str
address: int
class FF1Locations:
_location_table: List[LocationData] = []
_location_table_lookup: Dict[str, LocationData] = {}
def _populate_item_table_from_data(self):
base_path = Path(__file__).parent
file_path = (base_path / "data/locations.json").resolve()
with open(file_path) as file:
locations = json.load(file)
# Hardcode progression and categories for now
self._location_table = [LocationData(name, code) for name, code in locations.items()]
self._location_table_lookup = {item.name: item for item in self._location_table}
def _get_location_table(self) -> List[LocationData]:
if not self._location_table or not self._location_table_lookup:
self._populate_item_table_from_data()
return self._location_table
def _get_location_table_lookup(self) -> Dict[str, LocationData]:
if not self._location_table or not self._location_table_lookup:
self._populate_item_table_from_data()
return self._location_table_lookup
def get_location_name_to_address_dict(self) -> Dict[str, int]:
data = {name: location.address for name, location in self._get_location_table_lookup().items()}
data[CHAOS_TERMINATED_EVENT] = EventId
return data
@staticmethod
def create_menu_region(player: int, locations: Dict[str, int],
rules: Dict[str, List[List[str]]]) -> Region:
menu_region = Region("Menu", RegionType.Generic, "Menu", player)
for name, address in locations.items():
location = Location(player, name, address, menu_region)
## TODO REMOVE WHEN LOGIC FOR TOFR IS CORRECT
if "ToFR" in name:
rules_list = [["Rod", "Cube", "Lute", "Key", "Chime", "Oxyale",
"Ship", "Canoe", "Floater", "Canal",
"Crown", "Crystal", "Herb", "Tnt", "Adamant", "Slab", "Ruby", "Bottle"]]
location.access_rule = generate_rule(rules_list, player)
elif name in rules:
rules_list = rules[name]
location.access_rule = generate_rule(rules_list, player)
menu_region.locations.append(location)
return menu_region
def generate_rule(rules_list, player):
def x(state):
for rule in rules_list:
current_state = True
for item in rule:
if not state.has(item, player):
current_state = False
break
if current_state:
return True
return False
return x

22
worlds/ff1/Options.py Normal file
View File

@@ -0,0 +1,22 @@
from typing import Dict
from Options import OptionDict
class Locations(OptionDict):
displayname = "locations"
class Items(OptionDict):
displayname = "items"
class Rules(OptionDict):
displayname = "rules"
ff1_options: Dict[str, OptionDict] = {
"locations": Locations,
"items": Items,
"rules": Rules
}

97
worlds/ff1/__init__.py Normal file
View File

@@ -0,0 +1,97 @@
from typing import Dict
from BaseClasses import Item, Location, MultiWorld
from .Items import ItemData, FF1Items, FF1_STARTER_ITEMS, FF1_PROGRESSION_LIST, FF1_BRIDGE
from .Locations import EventId, FF1Locations, generate_rule, CHAOS_TERMINATED_EVENT
from .Options import ff1_options
from ..AutoWorld import World
class FF1World(World):
"""
Final Fantasy 1, originally released on the NES on 1987, is the game that started the beloved, long running series.
The randomizer takes the original 8-bit Final Fantasy game for NES (USA edition) and allows you to
shuffle important aspects like the location of key items, the difficulty of monsters and fiends,
and even the location of towns and dungeons.
Part puzzle and part speed-run, it breathes new life into one of the most influential games ever made.
"""
options = ff1_options
game = "Final Fantasy"
topology_present = False
remote_items = True
data_version = 1
remote_start_inventory = True
ff1_items = FF1Items()
ff1_locations = FF1Locations()
item_name_groups = ff1_items.get_item_names_per_category()
item_name_to_id = ff1_items.get_item_name_to_code_dict()
location_name_to_id = ff1_locations.get_location_name_to_address_dict()
def __init__(self, world: MultiWorld, player: int):
super().__init__(world, player)
self.locked_items = []
self.locked_locations = []
def generate_early(self):
return
def create_regions(self):
locations = get_options(self.world, 'locations', self.player)
rules = get_options(self.world, 'rules', self.player)
menu_region = self.ff1_locations.create_menu_region(self.player, locations, rules)
terminated_event = Location(self.player, CHAOS_TERMINATED_EVENT, EventId, menu_region)
terminated_item = Item(CHAOS_TERMINATED_EVENT, True, EventId, self.player)
terminated_event.place_locked_item(terminated_item)
items = get_options(self.world, 'items', self.player)
goal_rule = generate_rule([[name for name in items.keys() if name in FF1_PROGRESSION_LIST and name != "Shard"]],
self.player)
if "Shard" in items.keys():
def goal_rule_and_shards(state):
return goal_rule(state) and state.has("Shard", self.player, 32)
terminated_event.access_rule = goal_rule_and_shards
menu_region.locations.append(terminated_event)
self.world.regions += [menu_region]
def create_item(self, name: str) -> Item:
return self.ff1_items.generate_item(name, self.player)
def set_rules(self):
self.world.completion_condition[self.player] = lambda state: state.has(CHAOS_TERMINATED_EVENT, self.player)
def generate_basic(self):
items = get_options(self.world, 'items', self.player)
if FF1_BRIDGE in items.keys():
self._place_locked_item_in_sphere0(FF1_BRIDGE)
if items:
possible_early_items = [name for name in FF1_STARTER_ITEMS if name in items.keys()]
if possible_early_items:
progression_item = self.world.random.choice(possible_early_items)
self._place_locked_item_in_sphere0(progression_item)
items = [self.create_item(name) for name, data in items.items() for x in range(data['count']) if name not in
self.locked_items]
self.world.itempool += items
def _place_locked_item_in_sphere0(self, progression_item: str):
if progression_item:
rules = get_options(self.world, 'rules', self.player)
sphere_0_locations = [name for name, rules in rules.items()
if rules and len(rules[0]) == 0 and name not in self.locked_locations]
if sphere_0_locations:
initial_location = self.world.random.choice(sphere_0_locations)
locked_location = self.world.get_location(initial_location, self.player)
locked_location.place_locked_item(self.create_item(progression_item))
self.locked_items.append(progression_item)
self.locked_locations.append(locked_location.name)
def fill_slot_data(self) -> Dict[str, object]:
slot_data: Dict[str, object] = {}
return slot_data
def get_options(world: MultiWorld, name: str, player: int):
return getattr(world, name, None)[player].value

194
worlds/ff1/data/items.json Normal file
View File

@@ -0,0 +1,194 @@
{
"None": 256,
"Lute": 257,
"Crown": 258,
"Crystal": 259,
"Herb": 260,
"Key": 261,
"Tnt": 262,
"Adamant": 263,
"Slab": 264,
"Ruby": 265,
"Rod": 266,
"Floater": 267,
"Chime": 268,
"Tail": 269,
"Cube": 270,
"Bottle": 271,
"Oxyale": 272,
"EarthOrb": 273,
"FireOrb": 274,
"WaterOrb": 275,
"AirOrb": 276,
"Shard": 277,
"Tent": 278,
"Cabin": 279,
"House": 280,
"Heal": 281,
"Pure": 282,
"Soft": 283,
"WoodenNunchucks": 284,
"SmallKnife": 285,
"WoodenRod": 286,
"Rapier": 287,
"IronHammer": 288,
"ShortSword": 289,
"HandAxe": 290,
"Scimitar": 291,
"IronNunchucks": 292,
"LargeKnife": 293,
"IronStaff": 294,
"Sabre": 295,
"LongSword": 296,
"GreatAxe": 297,
"Falchon": 298,
"SilverKnife": 299,
"SilverSword": 300,
"SilverHammer": 301,
"SilverAxe": 302,
"FlameSword": 303,
"IceSword": 304,
"DragonSword": 305,
"GiantSword": 306,
"SunSword": 307,
"CoralSword": 308,
"WereSword": 309,
"RuneSword": 310,
"PowerRod": 311,
"LightAxe": 312,
"HealRod": 313,
"MageRod": 314,
"Defense": 315,
"WizardRod": 316,
"Vorpal": 317,
"CatClaw": 318,
"ThorHammer": 319,
"BaneSword": 320,
"Katana": 321,
"Xcalber": 322,
"Masamune": 323,
"Cloth": 324,
"WoodenArmor": 325,
"ChainArmor": 326,
"IronArmor": 327,
"SteelArmor": 328,
"SilverArmor": 329,
"FlameArmor": 330,
"IceArmor": 331,
"OpalArmor": 332,
"DragonArmor": 333,
"Copper": 334,
"Silver": 335,
"Gold": 336,
"Opal": 337,
"WhiteShirt": 338,
"BlackShirt": 339,
"WoodenShield": 340,
"IronShield": 341,
"SilverShield": 342,
"FlameShield": 343,
"IceShield": 344,
"OpalShield": 345,
"AegisShield": 346,
"Buckler": 347,
"ProCape": 348,
"Cap": 349,
"WoodenHelm": 350,
"IronHelm": 351,
"SilverHelm": 352,
"OpalHelm": 353,
"HealHelm": 354,
"Ribbon": 355,
"Gloves": 356,
"CopperGauntlets": 357,
"IronGauntlets": 358,
"SilverGauntlets": 359,
"ZeusGauntlets": 360,
"PowerGauntlets": 361,
"OpalGauntlets": 362,
"ProRing": 363,
"Gold10": 364,
"Gold20": 365,
"Gold25": 366,
"Gold30": 367,
"Gold55": 368,
"Gold70": 369,
"Gold85": 370,
"Gold110": 371,
"Gold135": 372,
"Gold155": 373,
"Gold160": 374,
"Gold180": 375,
"Gold240": 376,
"Gold255": 377,
"Gold260": 378,
"Gold295": 379,
"Gold300": 380,
"Gold315": 381,
"Gold330": 382,
"Gold350": 383,
"Gold385": 384,
"Gold400": 385,
"Gold450": 386,
"Gold500": 387,
"Gold530": 388,
"Gold575": 389,
"Gold620": 390,
"Gold680": 391,
"Gold750": 392,
"Gold795": 393,
"Gold880": 394,
"Gold1020": 395,
"Gold1250": 396,
"Gold1455": 397,
"Gold1520": 398,
"Gold1760": 399,
"Gold1975": 400,
"Gold2000": 401,
"Gold2750": 402,
"Gold3400": 403,
"Gold4150": 404,
"Gold5000": 405,
"Gold5450": 406,
"Gold6400": 407,
"Gold6720": 408,
"Gold7340": 409,
"Gold7690": 410,
"Gold7900": 411,
"Gold8135": 412,
"Gold9000": 413,
"Gold9300": 414,
"Gold9500": 415,
"Gold9900": 416,
"Gold10000": 417,
"Gold12350": 418,
"Gold13000": 419,
"Gold13450": 420,
"Gold14050": 421,
"Gold14720": 422,
"Gold15000": 423,
"Gold17490": 424,
"Gold18010": 425,
"Gold19990": 426,
"Gold20000": 427,
"Gold20010": 428,
"Gold26000": 429,
"Gold45000": 430,
"Gold65000": 431,
"Smoke": 435,
"FullCure": 432,
"Blast": 434,
"Phoenix": 433,
"Flare": 437,
"Black": 438,
"Refresh": 436,
"Guard": 439,
"Wizard": 442,
"HighPotion": 441,
"Cloak": 443,
"Quick": 440,
"Ship": 480,
"Bridge": 488,
"Canal": 492,
"Canoe": 498
}

View File

@@ -0,0 +1,257 @@
{
"Coneria1": 257,
"Coneria2": 258,
"ConeriaMajor": 259,
"Coneria4": 260,
"Coneria5": 261,
"Coneria6": 262,
"MatoyasCave1": 299,
"MatoyasCave3": 301,
"MatoyasCave2": 300,
"NorthwestCastle1": 273,
"NorthwestCastle3": 275,
"NorthwestCastle2": 274,
"ToFTopLeft1": 263,
"ToFBottomLeft": 265,
"ToFTopLeft2": 264,
"ToFRevisited6": 509,
"ToFRevisited4": 507,
"ToFRMasmune": 504,
"ToFRevisited5": 508,
"ToFRevisited3": 506,
"ToFRevisited2": 505,
"ToFRevisited7": 510,
"ToFTopRight1": 267,
"ToFTopRight2": 268,
"ToFBottomRight": 266,
"IceCave15": 377,
"IceCave16": 378,
"IceCave9": 371,
"IceCave11": 373,
"IceCave10": 372,
"IceCave12": 374,
"IceCave13": 375,
"IceCave14": 376,
"IceCave1": 363,
"IceCave2": 364,
"IceCave3": 365,
"IceCave4": 366,
"IceCave5": 367,
"IceCaveMajor": 370,
"IceCave7": 369,
"IceCave6": 368,
"Elfland1": 269,
"Elfland2": 270,
"Elfland3": 271,
"Elfland4": 272,
"Ordeals5": 383,
"Ordeals6": 384,
"Ordeals7": 385,
"Ordeals1": 379,
"Ordeals2": 380,
"Ordeals3": 381,
"Ordeals4": 382,
"OrdealsMajor": 387,
"Ordeals8": 386,
"SeaShrine7": 411,
"SeaShrine8": 412,
"SeaShrine9": 413,
"SeaShrine10": 414,
"SeaShrine1": 405,
"SeaShrine2": 406,
"SeaShrine3": 407,
"SeaShrine4": 408,
"SeaShrine5": 409,
"SeaShrine6": 410,
"SeaShrine13": 417,
"SeaShrine14": 418,
"SeaShrine11": 415,
"SeaShrine15": 419,
"SeaShrine16": 420,
"SeaShrineLocked": 421,
"SeaShrine18": 422,
"SeaShrine19": 423,
"SeaShrine20": 424,
"SeaShrine23": 427,
"SeaShrine21": 425,
"SeaShrine22": 426,
"SeaShrine24": 428,
"SeaShrine26": 430,
"SeaShrine28": 432,
"SeaShrine25": 429,
"SeaShrine30": 434,
"SeaShrine31": 435,
"SeaShrine27": 431,
"SeaShrine29": 433,
"SeaShrineMajor": 436,
"SeaShrine12": 416,
"DwarfCave3": 291,
"DwarfCave4": 292,
"DwarfCave6": 294,
"DwarfCave7": 295,
"DwarfCave5": 293,
"DwarfCave8": 296,
"DwarfCave9": 297,
"DwarfCave10": 298,
"DwarfCave1": 289,
"DwarfCave2": 290,
"Waterfall1": 437,
"Waterfall2": 438,
"Waterfall3": 439,
"Waterfall4": 440,
"Waterfall5": 441,
"Waterfall6": 442,
"MirageTower5": 456,
"MirageTower16": 467,
"MirageTower17": 468,
"MirageTower15": 466,
"MirageTower18": 469,
"MirageTower14": 465,
"SkyPalace1": 470,
"SkyPalace2": 471,
"SkyPalace3": 472,
"SkyPalace4": 473,
"SkyPalace18": 487,
"SkyPalace19": 488,
"SkyPalace16": 485,
"SkyPalaceMajor": 489,
"SkyPalace17": 486,
"SkyPalace22": 491,
"SkyPalace21": 490,
"SkyPalace23": 492,
"SkyPalace24": 493,
"SkyPalace31": 500,
"SkyPalace32": 501,
"SkyPalace33": 502,
"SkyPalace34": 503,
"SkyPalace29": 498,
"SkyPalace26": 495,
"SkyPalace25": 494,
"SkyPalace28": 497,
"SkyPalace27": 496,
"SkyPalace30": 499,
"SkyPalace14": 483,
"SkyPalace11": 480,
"SkyPalace12": 481,
"SkyPalace13": 482,
"SkyPalace15": 484,
"SkyPalace10": 479,
"SkyPalace5": 474,
"SkyPalace6": 475,
"SkyPalace7": 476,
"SkyPalace8": 477,
"SkyPalace9": 478,
"MirageTower9": 460,
"MirageTower13": 464,
"MirageTower10": 461,
"MirageTower12": 463,
"MirageTower11": 462,
"MirageTower1": 452,
"MirageTower2": 453,
"MirageTower4": 455,
"MirageTower3": 454,
"MirageTower8": 459,
"MirageTower7": 458,
"MirageTower6": 457,
"Volcano30": 359,
"Volcano32": 361,
"Volcano31": 360,
"Volcano28": 357,
"Volcano29": 358,
"Volcano21": 350,
"Volcano20": 349,
"Volcano24": 353,
"Volcano19": 348,
"Volcano25": 354,
"VolcanoMajor": 362,
"Volcano26": 355,
"Volcano27": 356,
"Volcano22": 351,
"Volcano23": 352,
"Volcano1": 330,
"Volcano9": 338,
"Volcano2": 331,
"Volcano10": 339,
"Volcano3": 332,
"Volcano8": 337,
"Volcano4": 333,
"Volcano13": 342,
"Volcano11": 340,
"Volcano7": 336,
"Volcano6": 335,
"Volcano5": 334,
"Volcano14": 343,
"Volcano12": 341,
"Volcano15": 344,
"Volcano18": 347,
"Volcano17": 346,
"Volcano16": 345,
"MarshCave6": 281,
"MarshCave5": 280,
"MarshCave7": 282,
"MarshCave8": 283,
"MarshCave10": 285,
"MarshCave2": 277,
"MarshCave11": 286,
"MarshCave3": 278,
"MarshCaveMajor": 284,
"MarshCave12": 287,
"MarshCave4": 279,
"MarshCave1": 276,
"MarshCave13": 288,
"TitansTunnel1": 326,
"TitansTunnel2": 327,
"TitansTunnel3": 328,
"TitansTunnel4": 329,
"EarthCave1": 302,
"EarthCave2": 303,
"EarthCave5": 306,
"EarthCave3": 304,
"EarthCave4": 305,
"EarthCave9": 310,
"EarthCave10": 311,
"EarthCave11": 312,
"EarthCave6": 307,
"EarthCave7": 308,
"EarthCave12": 313,
"EarthCaveMajor": 317,
"EarthCave19": 320,
"EarthCave17": 318,
"EarthCave18": 319,
"EarthCave20": 321,
"EarthCave24": 325,
"EarthCave21": 322,
"EarthCave22": 323,
"EarthCave23": 324,
"EarthCave13": 314,
"EarthCave15": 316,
"EarthCave14": 315,
"EarthCave8": 309,
"Cardia11": 398,
"Cardia9": 396,
"Cardia10": 397,
"Cardia6": 393,
"Cardia8": 395,
"Cardia7": 394,
"Cardia13": 400,
"Cardia12": 399,
"Cardia4": 391,
"Cardia5": 392,
"Cardia3": 390,
"Cardia1": 388,
"Cardia2": 389,
"CaravanShop": 767,
"King": 513,
"Princess2": 530,
"Matoya": 522,
"Astos": 519,
"Bikke": 516,
"CanoeSage": 533,
"ElfPrince": 518,
"Nerrick": 520,
"Smith": 521,
"CubeBot": 529,
"Sarda": 525,
"Fairy": 531,
"Lefein": 527
}

View File

@@ -56,10 +56,12 @@ item_table = {
"Structure Compass (End City)": ItemData(45041, True),
"Shulker Box": ItemData(45042, False),
"Dragon Egg Shard": ItemData(45043, True),
"Spyglass": ItemData(45044, True),
"Bee Trap (Minecraft)": ItemData(45100, False),
"Blaze Rods": ItemData(None, True),
"Victory": ItemData(None, True)
"Defeat Ender Dragon": ItemData(None, True),
"Defeat Wither": ItemData(None, True),
}
# 33 required items
@@ -87,6 +89,7 @@ required_items = {
"Infinity Book": 1,
"3 Ender Pearls": 4,
"Saddle": 1,
"Spyglass": 1,
}
junk_weights = {

View File

@@ -108,9 +108,21 @@ advancement_table = {
"Overkill": AdvData(42089, 'Nether Fortress'),
"Librarian": AdvData(42090, 'Overworld'),
"Overpowered": AdvData(42091, 'Bastion Remnant'),
"Wax On": AdvData(42092, 'Overworld'),
"Wax Off": AdvData(42093, 'Overworld'),
"The Cutest Predator": AdvData(42094, 'Overworld'),
"The Healing Power of Friendship": AdvData(42095, 'Overworld'),
"Is It a Bird?": AdvData(42096, 'Overworld'),
"Is It a Balloon?": AdvData(42097, 'The Nether'),
"Is It a Plane?": AdvData(42098, 'The End'),
"Surge Protector": AdvData(42099, 'Overworld'),
"Light as a Rabbit": AdvData(42100, 'Overworld'),
"Glow and Behold!": AdvData(42101, 'Overworld'),
"Whatever Floats Your Goat!": AdvData(42102, 'Overworld'),
"Blaze Spawner": AdvData(None, 'Nether Fortress'),
"Ender Dragon": AdvData(None, 'The End')
"Ender Dragon": AdvData(None, 'The End'),
"Wither": AdvData(None, 'Nether Fortress'),
}
exclusion_table = {
@@ -126,23 +138,39 @@ exclusion_table = {
"Uneasy Alliance",
"Cover Me in Debris",
"A Complete Catalogue",
"Surge Protector",
"Light as a Rabbit", # will be normal in 1.18
},
"insane": {
"unreasonable": {
"How Did We Get Here?",
"Adventuring Time",
},
"postgame": {
"Free the End",
"The Next Generation",
"The End... Again...",
"You Need a Mint",
"Monsters Hunted",
}
def get_postgame_advancements(required_bosses):
postgame_advancements = {
"ender_dragon": {
"Free the End",
"The Next Generation",
"The End... Again...",
"You Need a Mint",
"Monsters Hunted",
"Is It a Plane?",
},
"wither": {
"Withering Heights",
"Bring Home the Beacon",
"Beaconator",
"A Furious Cocktail",
"How Did We Get Here?",
"Monsters Hunted",
}
}
}
events_table = {
"Ender Dragon": "Victory"
}
lookup_id_to_name: typing.Dict[int, str] = {loc_data.id: loc_name for loc_name, loc_data in advancement_table.items() if
loc_data.id}
advancements = set()
if required_bosses in {"ender_dragon", "both"}:
advancements.update(postgame_advancements["ender_dragon"])
if required_bosses in {"wither", "both"}:
advancements.update(postgame_advancements["wither"])
return advancements

View File

@@ -1,37 +1,51 @@
import typing
from Options import Choice, Option, Toggle, Range
from Options import Choice, Option, Toggle, Range, OptionList, DeathLink
class AdvancementGoal(Range):
"""Number of advancements required to spawn the Ender Dragon."""
"""Number of advancements required to spawn bosses."""
displayname = "Advancement Goal"
range_start = 0
range_end = 87
default = 50
range_end = 92
default = 40
class EggShardsRequired(Range):
"""Number of dragon egg shards to collect before the Ender Dragon will spawn."""
"""Number of dragon egg shards to collect to spawn bosses."""
displayname = "Egg Shards Required"
range_start = 0
range_end = 30
range_end = 40
default = 0
class EggShardsAvailable(Range):
"""Number of dragon egg shards available to collect."""
displayname = "Egg Shards Available"
range_start = 0
range_end = 30
range_end = 40
default = 0
class BossGoal(Choice):
"""Bosses which must be defeated to finish the game."""
displayname = "Required Bosses"
option_none = 0
option_ender_dragon = 1
option_wither = 2
option_both = 3
default = 1
class ShuffleStructures(Toggle):
"""Enables shuffling of villages, outposts, fortresses, bastions, and end cities."""
displayname = "Shuffle Structures"
default = 1
class StructureCompasses(Toggle):
"""Adds structure compasses to the item pool, which point to the nearest indicated structure."""
displayname = "Structure Compasses"
default = 1
class BeeTraps(Range):
@@ -39,6 +53,7 @@ class BeeTraps(Range):
displayname = "Bee Trap Percentage"
range_start = 0
range_end = 100
default = 0
class CombatDifficulty(Choice):
@@ -53,33 +68,45 @@ class CombatDifficulty(Choice):
class HardAdvancements(Toggle):
"""Enables certain RNG-reliant or tedious advancements."""
displayname = "Include Hard Advancements"
default = 0
class InsaneAdvancements(Toggle):
class UnreasonableAdvancements(Toggle):
"""Enables the extremely difficult advancements "How Did We Get Here?" and "Adventuring Time.\""""
displayname = "Include Insane Advancements"
displayname = "Include Unreasonable Advancements"
default = 0
class PostgameAdvancements(Toggle):
"""Enables advancements that require spawning and defeating the Ender Dragon."""
"""Enables advancements that require spawning and defeating the required bosses."""
displayname = "Include Postgame Advancements"
default = 0
class SendDefeatedMobs(Toggle):
"""Send killed mobs to other Minecraft worlds which have this option enabled."""
displayname = "Send Defeated Mobs"
default = 0
class StartingItems(OptionList):
"""Start with these items. Each entry should be of this format: {item: "item_name", amount: #, nbt: "nbt_string"}"""
displayname = "Starting Items"
minecraft_options: typing.Dict[str, type(Option)] = {
"advancement_goal": AdvancementGoal,
"egg_shards_required": EggShardsRequired,
"egg_shards_available": EggShardsAvailable,
"shuffle_structures": ShuffleStructures,
"structure_compasses": StructureCompasses,
"bee_traps": BeeTraps,
"combat_difficulty": CombatDifficulty,
"include_hard_advancements": HardAdvancements,
"include_insane_advancements": InsaneAdvancements,
"include_postgame_advancements": PostgameAdvancements,
"send_defeated_mobs": SendDefeatedMobs,
"advancement_goal": AdvancementGoal,
"egg_shards_required": EggShardsRequired,
"egg_shards_available": EggShardsAvailable,
"required_bosses": BossGoal,
"shuffle_structures": ShuffleStructures,
"structure_compasses": StructureCompasses,
"bee_traps": BeeTraps,
"combat_difficulty": CombatDifficulty,
"include_hard_advancements": HardAdvancements,
"include_unreasonable_advancements": UnreasonableAdvancements,
"include_postgame_advancements": PostgameAdvancements,
"send_defeated_mobs": SendDefeatedMobs,
"starting_items": StartingItems,
"death_link": DeathLink,
}

View File

@@ -1,5 +1,5 @@
from ..generic.Rules import set_rule
from .Locations import exclusion_table, events_table
from ..generic.Rules import set_rule, add_rule
from .Locations import exclusion_table, get_postgame_advancements
from BaseClasses import MultiWorld
from ..AutoWorld import LogicMixin
@@ -9,6 +9,9 @@ class MinecraftLogic(LogicMixin):
def _mc_has_iron_ingots(self, player: int):
return self.has('Progressive Tools', player) and self.has('Progressive Resource Crafting', player)
def _mc_has_copper_ingots(self, player: int):
return self.has('Progressive Tools', player) and self.has('Progressive Resource Crafting', player)
def _mc_has_gold_ingots(self, player: int):
return self.has('Progressive Resource Crafting', player) and (self.has('Progressive Tools', player, 2) or self.can_reach('The Nether', 'Region', player))
@@ -21,6 +24,9 @@ class MinecraftLogic(LogicMixin):
def _mc_has_bottle(self, player: int):
return self.has('Bottles', player) and self.has('Progressive Resource Crafting', player)
def _mc_has_spyglass(self, player: int):
return self._mc_has_copper_ingots(player) and self.has('Spyglass', player) and self._mc_can_adventure(player)
def _mc_can_enchant(self, player: int):
return self.has('Enchanting', player) and self._mc_has_diamond_pickaxe(player) # mine obsidian and lapis
@@ -81,48 +87,32 @@ class MinecraftLogic(LogicMixin):
return self._mc_fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
return self._mc_fortress_loot(player) and normal_kill
def _mc_can_respawn_ender_dragon(self, player: int):
return self.can_reach('The Nether', 'Region', player) and self.can_reach('The End', 'Region', player) and \
self.has('Progressive Resource Crafting', player) # smelt sand into glass
def _mc_can_kill_ender_dragon(self, player: int):
# Since it is possible to kill the dragon without getting any of the advancements related to it, we need to require that it can be respawned.
respawn_dragon = self.can_reach('The Nether', 'Region', player) and self.has('Progressive Resource Crafting', player)
if self._mc_combat_difficulty(player) == 'easy':
return respawn_dragon and self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and \
return self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and \
self.has('Archery', player) and self._mc_can_brew_potions(player) and self._mc_can_enchant(player)
if self._mc_combat_difficulty(player) == 'hard':
return respawn_dragon and ((self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player)) or \
(self.has('Progressive Weapons', player, 1) and self.has('Bed', player)))
return respawn_dragon and self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and self.has('Archery', player)
return (self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player)) or \
(self.has('Progressive Weapons', player, 1) and self.has('Bed', player))
return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and self.has('Archery', player)
def _mc_has_structure_compass(self, entrance_name: str, player: int):
if not self.world.structure_compasses[player]:
return True
return self.has(f"Structure Compass ({self.world.get_entrance(entrance_name, player).connected_region.name})", player)
def set_rules(world: MultiWorld, player: int):
def reachable_locations(state):
postgame_advancements = exclusion_table['postgame'].copy()
for event in events_table.keys():
postgame_advancements.add(event)
return [location for location in world.get_locations() if
location.player == player and
location.name not in postgame_advancements and
location.can_reach(state)]
# Sets rules on entrances and advancements that are always applied
def set_advancement_rules(world: MultiWorld, player: int):
# Retrieves the appropriate structure compass for the given entrance
def get_struct_compass(entrance_name):
struct = world.get_entrance(entrance_name, player).connected_region.name
return f"Structure Compass ({struct})"
# 92 total advancements. Goal is to complete X advancements and then Free the End.
# There are 5 advancements which cannot be included for dragon spawning (4 postgame, Free the End)
# Hence the true maximum is (92 - 5) = 87
goal = world.advancement_goal[player]
egg_shards = min(world.egg_shards_required[player], world.egg_shards_available[player])
can_complete = lambda state: len(reachable_locations(state)) >= goal and state.has("Dragon Egg Shard", player, egg_shards) and state.can_reach('The End', 'Region', player) and state._mc_can_kill_ender_dragon(player)
if world.logic[player] != 'nologic':
world.completion_condition[player] = lambda state: state.has('Victory', player)
set_rule(world.get_entrance("Nether Portal", player), lambda state: state.has('Flint and Steel', player) and
(state.has('Bucket', player) or state.has('Progressive Tools', player, 3)) and
state._mc_has_iron_ingots(player))
@@ -133,7 +123,8 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_entrance("Nether Structure 2", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Nether Structure 2", player))
set_rule(world.get_entrance("The End Structure", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("The End Structure", player))
set_rule(world.get_location("Ender Dragon", player), lambda state: can_complete(state))
set_rule(world.get_location("Ender Dragon", player), lambda state: state._mc_can_kill_ender_dragon(player))
set_rule(world.get_location("Wither", player), lambda state: state._mc_can_kill_wither(player))
set_rule(world.get_location("Blaze Spawner", player), lambda state: state._mc_fortress_loot(player))
set_rule(world.get_location("Who is Cutting Onions?", player), lambda state: state._mc_can_piglin_trade(player))
@@ -142,7 +133,7 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Very Very Frightening", player), lambda state: state.has("Channeling Book", player) and state._mc_can_use_anvil(player) and state._mc_can_enchant(player) and \
((world.get_region('Village', player).entrances[0].parent_region.name != 'The End' and state.can_reach('Village', 'Region', player)) or state.can_reach('Zombie Doctor', 'Location', player))) # need villager into the overworld for lightning strike
set_rule(world.get_location("Hot Stuff", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player))
set_rule(world.get_location("Free the End", player), lambda state: can_complete(state))
set_rule(world.get_location("Free the End", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player))
set_rule(world.get_location("A Furious Cocktail", player), lambda state: state._mc_can_brew_potions(player) and
state.has("Fishing Rod", player) and # Water Breathing
state.can_reach('The Nether', 'Region', player) and # Regeneration, Fire Resistance, gold nuggets
@@ -154,7 +145,7 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Not Today, Thank You", player), lambda state: state.has("Shield", player) and state._mc_has_iron_ingots(player))
set_rule(world.get_location("Isn't It Iron Pick", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player))
set_rule(world.get_location("Local Brewery", player), lambda state: state._mc_can_brew_potions(player))
set_rule(world.get_location("The Next Generation", player), lambda state: can_complete(state))
set_rule(world.get_location("The Next Generation", player), lambda state: state._mc_can_kill_ender_dragon(player))
set_rule(world.get_location("Fishy Business", player), lambda state: state.has("Fishing Rod", player))
set_rule(world.get_location("Hot Tourist Destinations", player), lambda state: True)
set_rule(world.get_location("This Boat Has Legs", player), lambda state: (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and
@@ -188,7 +179,7 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Total Beelocation", player), lambda state: state.has("Silk Touch Book", player) and state._mc_can_use_anvil(player) and state._mc_can_enchant(player))
set_rule(world.get_location("Arbalistic", player), lambda state: state._mc_craft_crossbow(player) and state.has("Piercing IV Book", player) and
state._mc_can_use_anvil(player) and state._mc_can_enchant(player))
set_rule(world.get_location("The End... Again...", player), lambda state: can_complete(state))
set_rule(world.get_location("The End... Again...", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player))
set_rule(world.get_location("Acquire Hardware", player), lambda state: state._mc_has_iron_ingots(player))
set_rule(world.get_location("Not Quite \"Nine\" Lives", player), lambda state: state._mc_can_piglin_trade(player) and state.has("Progressive Resource Crafting", player, 2))
set_rule(world.get_location("Cover Me With Diamonds", player), lambda state: state.has("Progressive Armor", player, 2) and state.can_reach("Diamonds!", "Location", player))
@@ -196,9 +187,10 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Hired Help", player), lambda state: state.has("Progressive Resource Crafting", player, 2) and state._mc_has_iron_ingots(player))
set_rule(world.get_location("Return to Sender", player), lambda state: True)
set_rule(world.get_location("Sweet Dreams", player), lambda state: state.has("Bed", player) or state.can_reach('Village', 'Region', player))
set_rule(world.get_location("You Need a Mint", player), lambda state: can_complete(state) and state._mc_has_bottle(player))
set_rule(world.get_location("You Need a Mint", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_has_bottle(player))
set_rule(world.get_location("Adventure", player), lambda state: True)
set_rule(world.get_location("Monsters Hunted", player), lambda state: can_complete(state) and state._mc_can_kill_wither(player) and state.has("Fishing Rod", player)) # pufferfish for Water Breathing
set_rule(world.get_location("Monsters Hunted", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player) and
state._mc_can_kill_wither(player) and state.has("Fishing Rod", player)) # pufferfish for Water Breathing
set_rule(world.get_location("Enchanter", player), lambda state: state._mc_can_enchant(player))
set_rule(world.get_location("Voluntary Exile", player), lambda state: state._mc_basic_combat(player))
set_rule(world.get_location("Eye Spy", player), lambda state: state._mc_enter_stronghold(player))
@@ -224,7 +216,7 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Uneasy Alliance", player), lambda state: state._mc_has_diamond_pickaxe(player) and state.has('Fishing Rod', player))
set_rule(world.get_location("Diamonds!", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player))
set_rule(world.get_location("A Terrible Fortress", player), lambda state: True) # since you don't have to fight anything
set_rule(world.get_location("A Throwaway Joke", player), lambda state: True) # kill drowned
set_rule(world.get_location("A Throwaway Joke", player), lambda state: state._mc_can_adventure(player)) # kill drowned
set_rule(world.get_location("Minecraft", player), lambda state: True)
set_rule(world.get_location("Sticky Situation", player), lambda state: state.has("Campfire", player) and state._mc_has_bottle(player))
set_rule(world.get_location("Ol' Betsy", player), lambda state: state._mc_craft_crossbow(player))
@@ -249,3 +241,42 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Librarian", player), lambda state: state.has("Enchanting", player))
set_rule(world.get_location("Overpowered", player), lambda state: state._mc_has_iron_ingots(player) and
state.has('Progressive Tools', player, 2) and state._mc_basic_combat(player)) # mine gold blocks w/ iron pick
set_rule(world.get_location("Wax On", player), lambda state: state._mc_has_copper_ingots(player) and state.has('Campfire', player) and
state.has('Progressive Resource Crafting', player, 2))
set_rule(world.get_location("Wax Off", player), lambda state: state._mc_has_copper_ingots(player) and state.has('Campfire', player) and
state.has('Progressive Resource Crafting', player, 2))
set_rule(world.get_location("The Cutest Predator", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player))
set_rule(world.get_location("The Healing Power of Friendship", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player))
set_rule(world.get_location("Is It a Bird?", player), lambda state: state._mc_has_spyglass(player) and state._mc_can_adventure(player))
set_rule(world.get_location("Is It a Balloon?", player), lambda state: state._mc_has_spyglass(player))
set_rule(world.get_location("Is It a Plane?", player), lambda state: state._mc_has_spyglass(player) and state._mc_can_respawn_ender_dragon(player))
set_rule(world.get_location("Surge Protector", player), lambda state: state.has("Channeling Book", player) and state._mc_can_use_anvil(player) and state._mc_can_enchant(player) and \
((world.get_region('Village', player).entrances[0].parent_region.name != 'The End' and state.can_reach('Village', 'Region', player)) or state.can_reach('Zombie Doctor', 'Location', player)))
set_rule(world.get_location("Light as a Rabbit", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has('Bucket', player))
set_rule(world.get_location("Glow and Behold!", player), lambda state: state._mc_can_adventure(player))
set_rule(world.get_location("Whatever Floats Your Goat!", player), lambda state: state._mc_can_adventure(player))
# Sets rules on completion condition and postgame advancements
def set_completion_rules(world: MultiWorld, player: int):
def reachable_locations(state):
postgame_advancements = get_postgame_advancements(world.required_bosses[player].current_key)
return [location for location in world.get_locations() if
location.player == player and
location.name not in postgame_advancements and
location.address != None and
location.can_reach(state)]
def defeated_required_bosses(state):
return (world.required_bosses[player].current_key not in {"ender_dragon", "both"} or state.has("Defeat Ender Dragon", player)) and \
(world.required_bosses[player].current_key not in {"wither", "both"} or state.has("Defeat Wither", player))
# 103 total advancements. Goal is to complete X advancements and then defeat the dragon.
# There are 11 possible postgame advancements; 5 for dragon, 5 for wither, 1 shared between them
# Hence the max for completion is 92
egg_shards = min(world.egg_shards_required[player], world.egg_shards_available[player])
completion_requirements = lambda state: len(reachable_locations(state)) >= world.advancement_goal[player] and \
state.has("Dragon Egg Shard", player, egg_shards)
world.completion_condition[player] = lambda state: completion_requirements(state) and defeated_required_bosses(state)
# Set rules on postgame advancements
for adv_name in get_postgame_advancements(world.required_bosses[player].current_key):
add_rule(world.get_location(adv_name, player), completion_requirements)

View File

@@ -4,16 +4,17 @@ from base64 import b64encode, b64decode
from math import ceil
from .Items import MinecraftItem, item_table, required_items, junk_weights
from .Locations import MinecraftAdvancement, advancement_table, exclusion_table, events_table
from .Locations import MinecraftAdvancement, advancement_table, exclusion_table, get_postgame_advancements
from .Regions import mc_regions, link_minecraft_structures, default_connections
from .Rules import set_rules
from .Rules import set_advancement_rules, set_completion_rules
from worlds.generic.Rules import exclusion_rules
from BaseClasses import Region, Entrance, Item
from .Options import minecraft_options
from ..AutoWorld import World
client_version = 6
client_version = 7
minecraft_version = "1.17.1"
class MinecraftWorld(World):
"""
@@ -29,7 +30,7 @@ class MinecraftWorld(World):
item_name_to_id = {name: data.code for name, data in item_table.items()}
location_name_to_id = {name: data.id for name, data in advancement_table.items()}
data_version = 3
data_version = 4
def _get_mc_data(self):
exits = [connection[0] for connection in default_connections]
@@ -39,12 +40,16 @@ class MinecraftWorld(World):
'player_name': self.world.get_player_name(self.player),
'player_id': self.player,
'client_version': client_version,
'minecraft_version': minecraft_version,
'structures': {exit: self.world.get_entrance(exit, self.player).connected_region.name for exit in exits},
'advancement_goal': self.world.advancement_goal[self.player],
'egg_shards_required': min(self.world.egg_shards_required[self.player], self.world.egg_shards_available[self.player]),
'egg_shards_available': self.world.egg_shards_available[self.player],
'required_bosses': self.world.required_bosses[self.player].current_key,
'MC35': bool(self.world.send_defeated_mobs[self.player]),
'race': self.world.is_race
'death_link': bool(self.world.death_link[self.player]),
'starting_items': str(self.world.starting_items[self.player].value),
'race': self.world.is_race,
}
def generate_basic(self):
@@ -72,20 +77,24 @@ class MinecraftWorld(World):
# Choose locations to automatically exclude based on settings
exclusion_pool = set()
exclusion_types = ['hard', 'insane', 'postgame']
exclusion_types = ['hard', 'unreasonable']
for key in exclusion_types:
if not getattr(self.world, f"include_{key}_advancements")[self.player]:
exclusion_pool.update(exclusion_table[key])
# For postgame advancements, check with the boss goal
exclusion_pool.update(get_postgame_advancements(self.world.required_bosses[self.player].current_key))
exclusion_rules(self.world, self.player, exclusion_pool)
# Prefill event locations with their events
self.world.get_location("Blaze Spawner", self.player).place_locked_item(self.create_item("Blaze Rods"))
self.world.get_location("Ender Dragon", self.player).place_locked_item(self.create_item("Victory"))
self.world.get_location("Ender Dragon", self.player).place_locked_item(self.create_item("Defeat Ender Dragon"))
self.world.get_location("Wither", self.player).place_locked_item(self.create_item("Defeat Wither"))
self.world.itempool += itempool
def set_rules(self):
set_rules(self.world, self.player)
set_advancement_rules(self.world, self.player)
set_completion_rules(self.world, self.player)
def create_regions(self):
def MCRegion(region_name: str, exits=[]):
@@ -110,7 +119,8 @@ class MinecraftWorld(World):
slot_data = self._get_mc_data()
for option_name in minecraft_options:
option = getattr(self.world, option_name)[self.player]
slot_data[option_name] = int(option.value)
if slot_data.get(option_name, None) is None and type(option.value) in {str, int}:
slot_data[option_name] = int(option.value)
return slot_data
def create_item(self, name: str) -> Item:

View File

@@ -1,6 +1,6 @@
class Dungeon(object):
def __init__(self, world, name, hint, boss_key, small_keys, dungeon_items):
def __init__(self, world, name, hint, font_color, boss_key, small_keys, dungeon_items):
def to_array(obj):
if obj == None:
return []
@@ -12,6 +12,7 @@ class Dungeon(object):
self.world = world
self.name = name
self.hint_text = hint
self.font_color = font_color
self.regions = []
self.boss_key = to_array(boss_key)
self.small_keys = to_array(small_keys)
@@ -28,7 +29,7 @@ class Dungeon(object):
new_small_keys = [item.copy(new_world) for item in self.small_keys]
new_dungeon_items = [item.copy(new_world) for item in self.dungeon_items]
new_dungeon = Dungeon(new_world, self.name, self.hint, new_boss_key, new_small_keys, new_dungeon_items)
new_dungeon = Dungeon(new_world, self.name, self.hint_text, self.font_color, new_boss_key, new_small_keys, new_dungeon_items)
return new_dungeon

View File

@@ -7,6 +7,8 @@ from .Utils import data_path
dungeon_table = [
{
'name': 'Deku Tree',
'hint': 'the Deku Tree',
'font_color': 'Green',
'boss_key': 0,
'small_key': 0,
'small_key_mq': 0,
@@ -15,6 +17,7 @@ dungeon_table = [
{
'name': 'Dodongos Cavern',
'hint': 'Dodongo\'s Cavern',
'font_color': 'Red',
'boss_key': 0,
'small_key': 0,
'small_key_mq': 0,
@@ -23,6 +26,7 @@ dungeon_table = [
{
'name': 'Jabu Jabus Belly',
'hint': 'Jabu Jabu\'s Belly',
'font_color': 'Blue',
'boss_key': 0,
'small_key': 0,
'small_key_mq': 0,
@@ -30,6 +34,8 @@ dungeon_table = [
},
{
'name': 'Forest Temple',
'hint': 'the Forest Temple',
'font_color': 'Green',
'boss_key': 1,
'small_key': 5,
'small_key_mq': 6,
@@ -37,6 +43,8 @@ dungeon_table = [
},
{
'name': 'Bottom of the Well',
'hint': 'the Bottom of the Well',
'font_color': 'Pink',
'boss_key': 0,
'small_key': 3,
'small_key_mq': 2,
@@ -44,6 +52,8 @@ dungeon_table = [
},
{
'name': 'Fire Temple',
'hint': 'the Fire Temple',
'font_color': 'Red',
'boss_key': 1,
'small_key': 8,
'small_key_mq': 5,
@@ -51,6 +61,8 @@ dungeon_table = [
},
{
'name': 'Ice Cavern',
'hint': 'the Ice Cavern',
'font_color': 'Blue',
'boss_key': 0,
'small_key': 0,
'small_key_mq': 0,
@@ -58,6 +70,8 @@ dungeon_table = [
},
{
'name': 'Water Temple',
'hint': 'the Water Temple',
'font_color': 'Blue',
'boss_key': 1,
'small_key': 6,
'small_key_mq': 2,
@@ -65,6 +79,8 @@ dungeon_table = [
},
{
'name': 'Shadow Temple',
'hint': 'the Shadow Temple',
'font_color': 'Pink',
'boss_key': 1,
'small_key': 5,
'small_key_mq': 6,
@@ -72,6 +88,8 @@ dungeon_table = [
},
{
'name': 'Gerudo Training Grounds',
'hint': 'the Gerudo Training Grounds',
'font_color': 'Yellow',
'boss_key': 0,
'small_key': 9,
'small_key_mq': 3,
@@ -79,6 +97,8 @@ dungeon_table = [
},
{
'name': 'Spirit Temple',
'hint': 'the Spirit Temple',
'font_color': 'Yellow',
'boss_key': 1,
'small_key': 5,
'small_key_mq': 7,
@@ -100,6 +120,7 @@ def create_dungeons(ootworld):
for dungeon_info in dungeon_table:
name = dungeon_info['name']
hint = dungeon_info['hint'] if 'hint' in dungeon_info else name
font_color = dungeon_info['font_color'] if 'font_color' in dungeon_info else 'White'
if ootworld.logic_rules == 'glitchless':
if not ootworld.dungeon_mq[name]:
@@ -125,5 +146,5 @@ def create_dungeons(ootworld):
for item in dungeon_items:
item.priority = True
ootworld.dungeons.append(Dungeon(ootworld, name, hint, boss_keys, small_keys, dungeon_items))
ootworld.dungeons.append(Dungeon(ootworld, name, hint, font_color, boss_keys, small_keys, dungeon_items))

View File

@@ -1,7 +1,7 @@
from itertools import chain
import logging
from worlds.generic.Rules import set_rule
from worlds.generic.Rules import set_rule, add_rule
from .Hints import get_hint_area, HintAreaNotFound
from .Regions import TimeOfDay
@@ -29,12 +29,13 @@ def assume_entrance_pool(entrance_pool, ootworld):
assumed_pool = []
for entrance in entrance_pool:
assumed_forward = entrance.assume_reachable()
if entrance.reverse != None:
if entrance.reverse != None and not ootworld.decouple_entrances:
assumed_return = entrance.reverse.assume_reachable()
if (entrance.type in ('Dungeon', 'Grotto', 'Grave') and entrance.reverse.name != 'Spirit Temple Lobby -> Desert Colossus From Spirit Lobby') or \
(entrance.type == 'Interior' and ootworld.shuffle_special_interior_entrances):
# In most cases, Dungeon, Grotto/Grave and Simple Interior exits shouldn't be assumed able to give access to their parent region
set_rule(assumed_return, lambda state, **kwargs: False)
if not (ootworld.mix_entrance_pools != 'off' and (ootworld.shuffle_overworld_entrances or ootworld.shuffle_special_interior_entrances)):
if (entrance.type in ('Dungeon', 'Grotto', 'Grave') and entrance.reverse.name != 'Spirit Temple Lobby -> Desert Colossus From Spirit Lobby') or \
(entrance.type == 'Interior' and ootworld.shuffle_special_interior_entrances):
# In most cases, Dungeon, Grotto/Grave and Simple Interior exits shouldn't be assumed able to give access to their parent region
set_rule(assumed_return, lambda state, **kwargs: False)
assumed_forward.bind_two_way(assumed_return)
assumed_pool.append(assumed_forward)
return assumed_pool
@@ -308,6 +309,8 @@ entrance_shuffle_table = [
('Overworld', ('ZD Behind King Zora -> Zoras Fountain', { 'index': 0x0225 }),
('Zoras Fountain -> ZD Behind King Zora', { 'index': 0x01A1 })),
('Overworld', ('GV Lower Stream -> Lake Hylia', { 'index': 0x0219 })),
('OwlDrop', ('LH Owl Flight -> Hyrule Field', { 'index': 0x027E, 'addresses': [0xAC9F26] })),
('OwlDrop', ('DMT Owl Flight -> Kak Impas Rooftop', { 'index': 0x0554, 'addresses': [0xAC9EF2] })),
@@ -376,15 +379,24 @@ def shuffle_random_entrances(ootworld):
entrance_pools['Dungeon'] = ootworld.get_shufflable_entrances(type='Dungeon', only_primary=True)
if ootworld.open_forest == 'closed':
entrance_pools['Dungeon'].remove(world.get_entrance('KF Outside Deku Tree -> Deku Tree Lobby', player))
if ootworld.decouple_entrances:
entrance_pools['DungeonReverse'] = [entrance.reverse for entrance in entrance_pools['Dungeon']]
if ootworld.shuffle_interior_entrances != 'off':
entrance_pools['Interior'] = ootworld.get_shufflable_entrances(type='Interior', only_primary=True)
if ootworld.shuffle_special_interior_entrances:
entrance_pools['Interior'] += ootworld.get_shufflable_entrances(type='SpecialInterior', only_primary=True)
if ootworld.decouple_entrances:
entrance_pools['InteriorReverse'] = [entrance.reverse for entrance in entrance_pools['Interior']]
if ootworld.shuffle_grotto_entrances:
entrance_pools['GrottoGrave'] = ootworld.get_shufflable_entrances(type='Grotto', only_primary=True)
entrance_pools['GrottoGrave'] += ootworld.get_shufflable_entrances(type='Grave', only_primary=True)
if ootworld.decouple_entrances:
entrance_pools['GrottoGraveReverse'] = [entrance.reverse for entrance in entrance_pools['GrottoGrave']]
if ootworld.shuffle_overworld_entrances:
entrance_pools['Overworld'] = ootworld.get_shufflable_entrances(type='Overworld')
exclude_overworld_reverse = ootworld.mix_entrance_pools == 'all' and not ootworld.decouple_entrances
entrance_pools['Overworld'] = ootworld.get_shufflable_entrances(type='Overworld', only_primary=exclude_overworld_reverse)
if not ootworld.decouple_entrances:
entrance_pools['Overworld'].remove(world.get_entrance('GV Lower Stream -> Lake Hylia', player))
# Mark shuffled entrances
for entrance in chain(chain.from_iterable(one_way_entrance_pools.values()), chain.from_iterable(entrance_pools.values())):
@@ -392,6 +404,16 @@ def shuffle_random_entrances(ootworld):
if entrance.reverse:
entrance.reverse.shuffled = True
# Combine all entrance pools if mixing
if ootworld.mix_entrance_pools == 'all':
entrance_pools = {'Mixed': list(chain.from_iterable(entrance_pools.values()))}
elif ootworld.mix_entrance_pools == 'indoor':
if ootworld.shuffle_overworld_entrances:
ow_pool = entrance_pools['Overworld']
entrance_pools = {'Mixed': list(filter(lambda entrance: entrance.type != 'Overworld', chain.from_iterable(entrance_pools.values())))}
if ootworld.shuffle_overworld_entrances:
entrance_pools['Overworld'] = ow_pool
# Build target entrance pools
one_way_target_entrance_pools = {}
for pool_type, entrance_pool in one_way_entrance_pools.items():
@@ -403,7 +425,9 @@ def shuffle_random_entrances(ootworld):
elif pool_type in {'Spawn', 'WarpSong'}:
valid_target_types = ('Spawn', 'WarpSong', 'OwlDrop', 'Overworld', 'Interior', 'SpecialInterior', 'Extra')
one_way_target_entrance_pools[pool_type] = build_one_way_targets(ootworld, valid_target_types)
# Ensure that the last entrance doesn't assume the rest of the targets are reachable?
# Ensure that the last entrance doesn't assume the rest of the targets are reachable
for target in one_way_target_entrance_pools[pool_type]:
add_rule(target, (lambda entrances=entrance_pool: (lambda state: any(entrance.connected_region == None for entrance in entrances)))())
# Disconnect one-way entrances for priority placement
for entrance in chain.from_iterable(one_way_entrance_pools.values()):
entrance.disconnect()
@@ -419,7 +443,52 @@ def shuffle_random_entrances(ootworld):
if item_tuple[1] == player:
none_state.prog_items[item_tuple] = 0
# Plando entrances?
# Plando entrances
if world.plando_connections[player]:
rollbacks = []
all_targets = {**one_way_target_entrance_pools, **target_entrance_pools}
for conn in world.plando_connections[player]:
try:
entrance = ootworld.get_entrance(conn.entrance)
exit = ootworld.get_entrance(conn.exit)
if entrance is None:
raise EntranceShuffleError(f"Could not find entrance to plando: {conn.entrance}")
if exit is None:
raise EntranceShuffleError(f"Could not find entrance to plando: {conn.exit}")
target_region = exit.name.split(' -> ')[1]
target_parent = exit.parent_region.name
pool_type = entrance.type
matched_targets_to_region = list(filter(lambda target: target.connected_region and target.connected_region.name == target_region,
all_targets[pool_type]))
target = next(filter(lambda target: target.replaces.parent_region.name == target_parent, matched_targets_to_region))
replace_entrance(ootworld, entrance, target, rollbacks, locations_to_ensure_reachable, all_state, none_state)
if conn.direction == 'both' and entrance.reverse and ootworld.decouple_entrances:
replace_entrance(ootworld, entrance.reverse, target.reverse, rollbacks, locations_to_ensure_reachable, all_state, none_state)
except EntranceShuffleError as e:
raise RuntimeError(f"Failed to plando OoT entrances. Reason: {e}")
except StopIteration:
raise RuntimeError(f"Could not find entrance to plando: {conn.entrance} => {conn.exit}")
finally:
for (entrance, target) in rollbacks:
confirm_replacement(entrance, target)
# Check placed one way entrances and trim.
# The placed entrances are already pointing at their new regions.
placed_entrances = [entrance for entrance in chain.from_iterable(one_way_entrance_pools.values())
if entrance.replaces is not None]
replaced_entrances = [entrance.replaces for entrance in placed_entrances]
# Remove replaced entrances so we don't place two in one target.
for remaining_target in chain.from_iterable(one_way_target_entrance_pools.values()):
if remaining_target.replaces and remaining_target.replaces in replaced_entrances:
delete_target_entrance(remaining_target)
# Remove priority targets if any placed entrances point at their region(s).
for key, (regions, _) in priority_entrance_table.items():
if key in one_way_priorities:
for entrance in placed_entrances:
if entrance.connected_region and entrance.connected_region.name in regions:
del one_way_priorities[key]
break
# Place priority entrances
shuffle_one_way_priority_entrances(ootworld, one_way_priorities, one_way_entrance_pools, one_way_target_entrance_pools, locations_to_ensure_reachable, all_state, none_state, retry_count=2)
@@ -619,24 +688,25 @@ def validate_world(ootworld, entrance_placed, locations_to_ensure_reachable, all
time_travel_state.collect(ootworld.create_item('Time Travel'), event=True)
time_travel_state._oot_update_age_reachable_regions(player)
# For various reasons, we don't want the player to end up through certain entrances as the wrong age
# Unless entrances are decoupled, we don't want the player to end up through certain entrances as the wrong age
# This means we need to hard check that none of the relevant entrances are ever reachable as that age
# This is mostly relevant when shuffling special interiors (such as windmill or kak potion shop)
# Warp Songs and Overworld Spawns can also end up inside certain indoors so those need to be handled as well
CHILD_FORBIDDEN = ['OGC Great Fairy Fountain -> Castle Grounds', 'GV Carpenter Tent -> GV Fortress Side']
ADULT_FORBIDDEN = ['HC Great Fairy Fountain -> Castle Grounds', 'HC Storms Grotto -> Castle Grounds']
for entrance in ootworld.get_shufflable_entrances():
if entrance.shuffled and entrance.replaces:
if entrance.replaces.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.replaces.reverse]):
raise EntranceShuffleError(f'{entrance.replaces.name} replaced by an entrance with potential child access')
if entrance.replaces.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.replaces.reverse]):
raise EntranceShuffleError(f'{entrance.replaces.name} replaced by an entrance with potential adult access')
else:
if entrance.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.reverse]):
raise EntranceShuffleError(f'{entrance.name} potentially accessible as child')
if entrance.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.reverse]):
raise EntranceShuffleError(f'{entrance.name} potentially accessible as adult')
if not ootworld.decouple_entrances:
for entrance in ootworld.get_shufflable_entrances():
if entrance.shuffled and entrance.replaces:
if entrance.replaces.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.replaces.reverse]):
raise EntranceShuffleError(f'{entrance.replaces.name} replaced by an entrance with potential child access')
if entrance.replaces.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.replaces.reverse]):
raise EntranceShuffleError(f'{entrance.replaces.name} replaced by an entrance with potential adult access')
else:
if entrance.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.reverse]):
raise EntranceShuffleError(f'{entrance.name} potentially accessible as child')
if entrance.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.reverse]):
raise EntranceShuffleError(f'{entrance.name} potentially accessible as adult')
# Check if all locations are reachable if not beatable-only or game is not yet complete
if locations_to_ensure_reachable:
@@ -645,7 +715,8 @@ def validate_world(ootworld, entrance_placed, locations_to_ensure_reachable, all
if not all_state.can_reach(loc, 'Location', player):
raise EntranceShuffleError(f'{loc} is unreachable')
if ootworld.shuffle_interior_entrances and (entrance_placed == None or entrance_placed.type in ['Interior', 'SpecialInterior']):
if ootworld.shuffle_interior_entrances and (ootworld.misc_hints or ootworld.hints != 'none') and \
(entrance_placed == None or entrance_placed.type in ['Interior', 'SpecialInterior']):
# Ensure Kak Potion Shop entrances are in the same hint area so there is no ambiguity as to which entrance is used for hints
potion_front_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Front', player), 'Kakariko Village -> Kak Potion Shop Front', player)
potion_back_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Back', player), 'Kak Backyard -> Kak Potion Shop Back', player)
@@ -733,14 +804,14 @@ def get_entrance_replacing(region, entrance_name, player):
def change_connections(entrance, target):
entrance.connect(target.disconnect())
entrance.replaces = target.replaces
if entrance.reverse:
if entrance.reverse and not entrance.world.worlds[entrance.player].decouple_entrances:
target.replaces.reverse.connect(entrance.reverse.assumed.disconnect())
target.replaces.reverse.replaces = entrance.reverse
def restore_connections(entrance, target):
target.connect(entrance.disconnect())
entrance.replaces = None
if entrance.reverse:
if entrance.reverse and not entrance.world.worlds[entrance.player].decouple_entrances:
entrance.reverse.assumed.connect(target.replaces.reverse.disconnect())
target.replaces.reverse.replaces = None
@@ -757,7 +828,7 @@ def check_entrances_compatibility(entrance, target, rollbacks):
def confirm_replacement(entrance, target):
delete_target_entrance(target)
logging.getLogger('').debug(f'Connected {entrance} to {entrance.connected_region}')
if entrance.reverse:
if entrance.reverse and not entrance.world.worlds[entrance.player].decouple_entrances:
replaced_reverse = target.replaces.reverse
delete_target_entrance(entrance.reverse.assumed)
logging.getLogger('').debug(f'Connected {replaced_reverse} to {replaced_reverse.connected_region}')

View File

@@ -11,7 +11,7 @@ import json
from enum import Enum
from .HintList import getHint, getHintGroup, Hint, hintExclusions
from .Messages import update_message_by_id
from .Messages import COLOR_MAP, update_message_by_id
from .TextBox import line_wrap
from .Utils import data_path, read_json
@@ -266,17 +266,6 @@ def getSimpleHintNoPrefix(item):
def colorText(gossip_text):
colorMap = {
'White': '\x40',
'Red': '\x41',
'Green': '\x42',
'Blue': '\x43',
'Light Blue': '\x44',
'Pink': '\x45',
'Yellow': '\x46',
'Black': '\x47',
}
text = gossip_text.text
colors = list(gossip_text.colors) if gossip_text.colors is not None else []
color = 'White'
@@ -292,7 +281,7 @@ def colorText(gossip_text):
splitText[1] = splitText[1][len(prefix):]
break
splitText[1] = '\x05' + colorMap[color] + splitText[1] + '\x05\x40'
splitText[1] = '\x05' + COLOR_MAP[color] + splitText[1] + '\x05\x40'
text = ''.join(splitText)
return text
@@ -649,9 +638,9 @@ def buildWorldGossipHints(world, checkedLocations=None):
if checkedLocations is None:
checkedLocations = {player: set() for player in world.world.player_ids}
# If Ganondorf can be reached without Light Arrows, add to checkedLocations to prevent extra hinting
# If Ganondorf hints Light Arrows and is reachable without them, add to checkedLocations to prevent extra hinting
# Can only be forced with vanilla bridge or trials
if world.bridge != 'vanilla' and world.trials == 0:
if world.bridge != 'vanilla' and world.trials == 0 and world.misc_hints:
try:
light_arrow_location = world.world.find_item("Light Arrows", world.player)
checkedLocations[light_arrow_location.player].add(light_arrow_location.name)

View File

@@ -1329,9 +1329,10 @@ def get_pool_core(world):
# We can resolve this by starting with some extra keys
if world.dungeon_mq['Spirit Temple']:
# Yes somehow you need 3 keys. This dungeon is bonkers
world.world.push_precollected(world.create_item('Small Key (Spirit Temple)'))
world.world.push_precollected(world.create_item('Small Key (Spirit Temple)'))
world.world.push_precollected(world.create_item('Small Key (Spirit Temple)'))
items = [world.create_item('Small Key (Spirit Temple)') for i in range(3)]
for item in items:
world.world.push_precollected(item)
world.remove_from_start_inventory.append(item.name)
#if not world.dungeon_mq['Fire Temple']:
# world.state.collect(ItemFactory('Small Key (Fire Temple)'))
if world.shuffle_bosskeys == 'vanilla':

View File

@@ -1,5 +1,6 @@
# text details: https://wiki.cloudmodding.com/oot/Text_Format
import logging
import random
from .TextBox import line_wrap
@@ -316,6 +317,17 @@ KEYSANITY_MESSAGES = {
0x00A9: "\x13\x77\x08You found a \x05\x41Small Key\x05\x40\x01for the \x05\x45Shadow Temple\x05\x40!\x09",
}
COLOR_MAP = {
'White': '\x40',
'Red': '\x41',
'Green': '\x42',
'Blue': '\x43',
'Light Blue': '\x44',
'Pink': '\x45',
'Yellow': '\x46',
'Black': '\x47',
}
MISC_MESSAGES = {
0x507B: (bytearray(
b"\x08I tell you, I saw him!\x04" \
@@ -995,3 +1007,30 @@ def shuffle_messages(messages, except_hints=True, always_allow_skip=True):
]))
return permutation
# Update warp song text boxes for ER
def update_warp_song_text(messages, ootworld):
msg_list = {
0x088D: 'Minuet of Forest Warp -> Sacred Forest Meadow',
0x088E: 'Bolero of Fire Warp -> DMC Central Local',
0x088F: 'Serenade of Water Warp -> Lake Hylia',
0x0890: 'Requiem of Spirit Warp -> Desert Colossus',
0x0891: 'Nocturne of Shadow Warp -> Graveyard Warp Pad Region',
0x0892: 'Prelude of Light Warp -> Temple of Time',
}
for id, entr in msg_list.items():
destination = ootworld.world.get_entrance(entr, ootworld.player).connected_region
if destination.pretty_name:
destination_name = destination.pretty_name
elif destination.hint_text:
destination_name = destination.hint_text
elif destination.dungeon:
destination_name = destination.dungeon.hint
else:
destination_name = destination.name
color = COLOR_MAP[destination.font_color or 'White']
new_msg = f"\x08\x05{color}Warp to {destination_name}?\x05\40\x09\x01\x01\x1b\x05{color}OK\x01No\x05\40"
update_message_by_id(messages, id, new_msg)

View File

@@ -96,6 +96,7 @@ class StartingAge(Choice):
class InteriorEntrances(Choice):
"""Shuffles interior entrances. "Simple" shuffles houses and Great Fairies; "All" includes Windmill, Link's House, Temple of Time, and Kak potion shop."""
displayname = "Shuffle Interior Entrances"
option_off = 0
option_simple = 1
option_all = 2
@@ -105,26 +106,46 @@ class InteriorEntrances(Choice):
class GrottoEntrances(Toggle):
"""Shuffles grotto and grave entrances."""
displayname = "Shuffle Grotto/Grave Entrances"
class DungeonEntrances(Toggle):
"""Shuffles dungeon entrances, excluding Ganon's Castle. Opens Deku, Fire and BotW to both ages."""
displayname = "Shuffle Dungeon Entrances"
class OverworldEntrances(Toggle):
"""Shuffles overworld loading zones."""
displayname = "Shuffle Overworld Entrances"
class OwlDrops(Toggle):
"""Randomizes owl drops from Lake Hylia or Death Mountain Trail as child."""
displayname = "Randomize Owl Drops"
class WarpSongs(Toggle):
"""Randomizes warp song destinations."""
displayname = "Randomize Warp Songs"
class SpawnPositions(Toggle):
"""Randomizes the starting position on loading a save. Consistent between savewarps."""
displayname = "Randomize Spawn Positions"
class MixEntrancePools(Choice):
"""Shuffles entrances into a mixed pool instead of separate ones. "indoor" keeps overworld entrances separate; "all" mixes them in."""
displayname = "Mix Entrance Pools"
option_off = 0
option_indoor = 1
option_all = 2
alias_false = 0
class DecoupleEntrances(Toggle):
"""Decouple entrances when shuffling them. Also adds the one-way entrance from Gerudo Valley to Lake Hylia if overworld is shuffled."""
displayname = "Decouple Entrances"
class TriforceHunt(Toggle):
@@ -170,6 +191,8 @@ world_options: typing.Dict[str, type(Option)] = {
"owl_drops": OwlDrops,
"warp_songs": WarpSongs,
"spawn_positions": SpawnPositions,
"mix_entrance_pools": MixEntrancePools,
"decouple_entrances": DecoupleEntrances,
"triforce_hunt": TriforceHunt,
"triforce_goal": TriforceGoal,
"extra_triforce_percentage": ExtraTriforces,
@@ -540,6 +563,11 @@ class Hints(Choice):
alias_false = 0
class MiscHints(DefaultOnToggle):
"""Controls whether the Temple of Time altar gives dungeon prize info and whether Ganondorf hints the Light Arrows."""
displayname = "Misc Hints"
class HintDistribution(Choice):
"""Choose the hint distribution to use. Affects the frequency of strong hints, which items are always hinted, etc."""
displayname = "Hint Distribution"
@@ -607,6 +635,7 @@ class RupeeStart(Toggle):
misc_options: typing.Dict[str, type(Option)] = {
"correct_chest_sizes": CSMC,
"hints": Hints,
"misc_hints": MiscHints,
"hint_dist": HintDistribution,
"text_shuffle": TextShuffle,
"damage_multiplier": DamageMultiplier,

View File

@@ -9,7 +9,7 @@ from .LocationList import business_scrubs
from .Hints import writeGossipStoneHints, buildAltarHints, \
buildGanonText, getSimpleHintNoPrefix
from .Utils import data_path
from .Messages import read_messages, update_message_by_id, read_shop_items, \
from .Messages import read_messages, update_message_by_id, read_shop_items, update_warp_song_text, \
write_shop_items, remove_unused_messages, make_player_message, \
add_item_messages, repack_messages, shuffle_messages, \
get_message_by_id
@@ -1007,6 +1007,12 @@ def patch_rom(world, rom):
# Archipelago forces this item to be local so it can always be given to the player. Usually it's a song so it's no problem.
item = world.get_location('Song from Impa').item
save_context.give_raw_item(item.name)
if item.name == 'Slingshot':
save_context.give_raw_item("Deku Seeds (30)")
elif item.name == 'Bow':
save_context.give_raw_item("Arrows (30)")
elif item.name == 'Bomb Bag':
save_context.give_raw_item("Bombs (20)")
save_context.write_bits(0x0ED7, 0x04) # "Obtained Malon's Item"
save_context.write_bits(0x0ED7, 0x08) # "Woke Talon in castle"
save_context.write_bits(0x0ED7, 0x10) # "Talon has fled castle"
@@ -1634,7 +1640,7 @@ def patch_rom(world, rom):
rom.write_int16(chest_address + 2, 0x0190) # X pos
rom.write_int16(chest_address + 6, 0xFABC) # Z pos
else:
if location.item.advancement:
if not location.item.advancement:
rom.write_int16(chest_address + 2, 0x0190) # X pos
rom.write_int16(chest_address + 6, 0xFABC) # Z pos
@@ -1650,7 +1656,7 @@ def patch_rom(world, rom):
rom.write_int16(chest_address_0 + 6, 0x0172) # Z pos
rom.write_int16(chest_address_2 + 6, 0x0172) # Z pos
else:
if location.item.advancement:
if not location.item.advancement:
rom.write_int16(chest_address_0 + 6, 0x0172) # Z pos
rom.write_int16(chest_address_2 + 6, 0x0172) # Z pos
@@ -1741,6 +1747,10 @@ def patch_rom(world, rom):
elif world.text_shuffle == 'complete':
permutation = shuffle_messages(messages, except_hints=False)
# If Warp Song ER is on, update text boxes
if world.warp_songs:
update_warp_song_text(messages, world)
repack_messages(rom, messages, permutation)
# output a text dump, for testing...

View File

@@ -38,6 +38,8 @@ class OOTRegion(Region):
self.provides_time = TimeOfDay.NONE
self.scene = None
self.dungeon = None
self.pretty_name = None
self.font_color = None
def get_scene(self):
if self.scene:

View File

@@ -17,7 +17,7 @@ double_cache_prevention = threading.Lock()
class Rom(BigStream):
original = None
def __init__(self, file=None):
def __init__(self, file=None, force_use=False):
super().__init__([])
self.changed_address = {}
@@ -34,22 +34,25 @@ class Rom(BigStream):
self.symbols = {name: int(addr, 16) for name, addr in symbols.items()}
# If decompressed file already exists, read from it
if os.path.exists(decomp_file):
file = decomp_file
if not force_use:
if os.path.exists(decomp_file):
file = decomp_file
if file == '':
# if not specified, try to read from the previously decompressed rom
file = decomp_file
try:
if file == '':
# if not specified, try to read from the previously decompressed rom
file = decomp_file
try:
self.read_rom(file)
except FileNotFoundError:
# could not find the decompressed rom either
raise FileNotFoundError('Must specify path to base ROM')
else:
self.read_rom(file)
except FileNotFoundError:
# could not find the decompressed rom either
raise FileNotFoundError('Must specify path to base ROM')
else:
self.read_rom(file)
# decompress rom, or check if it's already decompressed
self.decompress_rom_file(file, decomp_file)
self.decompress_rom_file(file, decomp_file, force_use)
# Add file to maximum size
self.buffer.extend(bytearray([0x00] * (0x4000000 - len(self.buffer))))
@@ -69,7 +72,7 @@ class Rom(BigStream):
new_rom.force_patch = copy.copy(self.force_patch)
return new_rom
def decompress_rom_file(self, file, decomp_file):
def decompress_rom_file(self, file, decomp_file, skip_crc_check):
validCRC = [
[0xEC, 0x70, 0x11, 0xB7, 0x76, 0x16, 0xD7, 0x2B], # Compressed
[0x70, 0xEC, 0xB7, 0x11, 0x16, 0x76, 0x2B, 0xD7], # Byteswap compressed
@@ -79,7 +82,7 @@ class Rom(BigStream):
# Validate ROM file
file_name = os.path.splitext(file)
romCRC = list(self.buffer[0x10:0x18])
if romCRC not in validCRC:
if romCRC not in validCRC and not skip_crc_check:
# Bad CRC validation
raise RuntimeError('ROM file %s is not a valid OoT 1.0 US ROM.' % file)
elif len(self.buffer) < 0x2000000 or len(self.buffer) > (0x4000000) or file_name[1].lower() not in ['.z64',

View File

@@ -4,7 +4,7 @@ import subprocess
import Utils
from functools import lru_cache
__version__ = Utils.__version__ + ' f.LUM'
__version__ = '6.1.0 f.LUM'
def data_path(*args):

View File

@@ -147,7 +147,7 @@ class OOTWorld(World):
# Incompatible option handling
# ER and glitched logic are not compatible; glitched takes priority
if self.logic_rules == 'glitched':
self.shuffle_interior_entrances = False
self.shuffle_interior_entrances = 'off'
self.shuffle_grotto_entrances = False
self.shuffle_dungeon_entrances = False
self.shuffle_overworld_entrances = False
@@ -191,7 +191,6 @@ class OOTWorld(World):
self.keysanity = self.shuffle_smallkeys in ['keysanity', 'remove', 'any_dungeon', 'overworld']
# Hint stuff
self.misc_hints = True # this is just always on
self.clearer_hints = True # this is being enforced since non-oot items do not have non-clear hint text
self.gossip_hints = {}
self.required_locations = []
@@ -276,6 +275,10 @@ class OOTWorld(World):
for region in region_json:
new_region = OOTRegion(region['region_name'], RegionType.Generic, None, self.player)
new_region.world = self.world
if 'pretty_name' in region:
new_region.pretty_name = region['pretty_name']
if 'font_color' in region:
new_region.font_color = region['font_color']
if 'scene' in region:
new_region.scene = region['scene']
if 'hint' in region:
@@ -513,20 +516,6 @@ class OOTWorld(World):
else:
break
# Write entrances to spoiler log
all_entrances = self.get_shuffled_entrances()
all_entrances.sort(key=lambda x: x.name)
all_entrances.sort(key=lambda x: x.type)
for loadzone in all_entrances:
if loadzone.primary:
entrance = loadzone
else:
entrance = loadzone.reverse
if entrance.reverse is not None:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'both', self.player)
else:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player)
set_rules(self)
set_entrances_based_rules(self)
@@ -790,6 +779,24 @@ class OOTWorld(World):
create_patch_file(rom, output_path(output_directory, outfile_name + '.apz5'))
rom.restore()
# Write entrances to spoiler log
all_entrances = self.get_shuffled_entrances()
all_entrances.sort(key=lambda x: x.name)
all_entrances.sort(key=lambda x: x.type)
if not self.decouple_entrances:
for loadzone in all_entrances:
if loadzone.primary:
entrance = loadzone
else:
entrance = loadzone.reverse
if entrance.reverse is not None:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'both', self.player)
else:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player)
else:
for entrance in all_entrances:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player)
# Gathers hint data for OoT. Loops over all world locations for woth, barren, and major item locations.
@classmethod
def stage_generate_output(cls, world: MultiWorld, output_directory: str):

File diff suppressed because it is too large Load Diff

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