Merge branch 'ArchipelagoMW:main' into main

This commit is contained in:
Adrian Priestley
2025-01-16 13:45:01 +00:00
committed by GitHub
53 changed files with 8487 additions and 180 deletions

20
Fill.py
View File

@@ -571,6 +571,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})")
more_locations = locations_counter - items_counter
more_items = items_counter - locations_counter
for player in multiworld.player_ids:
if more_locations[player]:
logging.error(
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
elif more_items[player]:
logging.warning(
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
if unfilled:
raise FillError(
f"Unable to fill all locations.\n" +
f"Unfilled locations({len(unfilled)}): {unfilled}"
)
else:
logging.warning(
f"Unable to place all items.\n" +
f"Unplaced items({len(unplaced)}): {unplaced}"
)
def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute

View File

@@ -743,16 +743,17 @@ class Context:
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
# only remember hints that were not already found at the time of creation
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
@@ -1887,7 +1888,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
for location in args["locations"]:
if type(location) is not int:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Locations has to be a list of integers',
"original_cmd": cmd}])
return

View File

@@ -940,7 +940,7 @@ def freeze_support() -> None:
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True) -> None:
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
"""Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -956,16 +956,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
Items without ID will be shown in italics.
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
Example usage in World code:
from Utils import visualize_regions
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
state = self.multiworld.get_all_state(False)
state.update_reachable_regions(self.player)
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
regions_to_highlight=state.reachable_regions[self.player])
Example usage in Main code:
from Utils import visualize_regions
for player in multiworld.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
"""
if regions_to_highlight is None:
regions_to_highlight = set()
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
from collections import deque
@@ -1018,7 +1024,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
def visualize_region(region: Region) -> None:
uml.append(f"class \"{fmt(region)}\"")
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
if show_locations:
visualize_locations(region)
visualize_exits(region)

View File

@@ -99,6 +99,9 @@
# Lingo
/worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @threeandthreee
# Lufia II Ancient Cave
/worlds/lufia2ac/ @el-u
/worlds/lufia2ac/docs/ @wordfcuk @el-u
@@ -236,9 +239,6 @@
# Final Fantasy (1)
# /worlds/ff1/
# Links Awakening DX
# /worlds/ladx/
# Ocarina of Time
# /worlds/oot/

View File

@@ -117,3 +117,12 @@ class TestImplemented(unittest.TestCase):
f"\nUnexpectedly reachable locations in sphere {sphere_num}:"
f"\n{reachable_only_with_explicit}")
self.fail("Unreachable")
def test_no_items_or_locations_or_regions_submitted_in_init(self):
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, ())
self.assertEqual(len(multiworld.itempool), 0)
self.assertEqual(len(multiworld.get_locations()), 0)
self.assertEqual(len(multiworld.get_regions()), 0)

View File

@@ -119,7 +119,9 @@ def KholdstareDefeatRule(state, player: int) -> bool:
def VitreousDefeatRule(state, player: int) -> bool:
return can_shoot_arrows(state, player) or has_melee_weapon(state, player)
return ((can_shoot_arrows(state, player) and can_use_bombs(state, player, 10))
or can_shoot_arrows(state, player, 35) or state.has("Silver Bow", player)
or has_melee_weapon(state, player))
def TrinexxDefeatRule(state, player: int) -> bool:

View File

@@ -484,8 +484,7 @@ def generate_itempool(world):
if multiworld.randomize_cost_types[player]:
# Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic
for item in items:
if (item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart")
or "Arrow Upgrade" in item.name):
if item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart"):
item.classification = ItemClassification.progression
else:
# Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives)
@@ -713,7 +712,7 @@ def get_pool_core(world, player: int):
pool.remove("Rupees (20)")
if retro_bow:
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (50)'}
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (70)'}
pool = ['Rupees (5)' if item in replace else item for item in pool]
if world.small_key_shuffle[player] == small_key_shuffle.option_universal:
pool.extend(diff.universal_keys)

View File

@@ -7,7 +7,7 @@ from worlds.AutoWorld import World
def GetBeemizerItem(world, player: int, item):
item_name = item if isinstance(item, str) else item.name
if item_name not in trap_replaceable:
if item_name not in trap_replaceable or player in world.groups:
return item
# first roll - replaceable item should be replaced, within beemizer_total_chance
@@ -110,9 +110,9 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\
'Crystal 7': ItemData(IC.progression, 'Crystal', (0x08, 0x34, 0x64, 0x40, 0x7C, 0x06), None, None, None, None, None, None, "a blue crystal"),
'Single Arrow': ItemData(IC.filler, None, 0x43, 'a lonely arrow\nsits here.', 'and the arrow', 'stick-collecting kid', 'sewing needle for sale', 'fungus for arrow', 'archer boy sews again', 'an arrow'),
'Arrows (10)': ItemData(IC.filler, None, 0x44, 'This will give\nyou ten shots\nwith your bow!', 'and the arrow pack','stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again','ten arrows'),
'Arrow Upgrade (+10)': ItemData(IC.useful, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (+5)': ItemData(IC.useful, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (70)': ItemData(IC.useful, None, 0x4D, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (+10)': ItemData(IC.progression_skip_balancing, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (+5)': ItemData(IC.progression_skip_balancing, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Arrow Upgrade (70)': ItemData(IC.progression_skip_balancing, None, 0x4D, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
'Single Bomb': ItemData(IC.filler, None, 0x27, 'I make things\ngo BOOM! But\njust once.', 'and the explosion', 'the bomb-holding kid', 'firecracker for sale', 'blend fungus into bomb', '\'splosion boy explodes again', 'a bomb'),
'Bombs (3)': ItemData(IC.filler, None, 0x28, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'),
'Bombs (10)': ItemData(IC.filler, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),

View File

@@ -170,7 +170,8 @@ def push_shop_inventories(multiworld):
# Retro Bow arrows will already have been pushed
if (not multiworld.retro_bow[location.player]) or ((item_name, location.item.player)
!= ("Single Arrow", location.player)):
location.shop.push_inventory(location.shop_slot, item_name, location.shop_price,
location.shop.push_inventory(location.shop_slot, item_name,
round(location.shop_price * get_price_modifier(location.item)),
1, location.item.player if location.item.player != location.player else 0,
location.shop_price_type)
location.shop_price = location.shop.inventory[location.shop_slot]["price"] = min(location.shop_price,

View File

@@ -15,18 +15,18 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
shop in state.multiworld.shops)
shop in state.multiworld.shops)
def can_buy(state: CollectionState, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
shop in state.multiworld.shops)
shop in state.multiworld.shops)
def can_shoot_arrows(state: CollectionState, player: int) -> bool:
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:
if state.multiworld.retro_bow[player]:
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_buy(state, 'Single Arrow', player)
return state.has('Bow', player) or state.has('Silver Bow', player)
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_hold_arrows(state, player, count)
def has_triforce_pieces(state: CollectionState, player: int) -> bool:
@@ -61,13 +61,13 @@ def heart_count(state: CollectionState, player: int) -> int:
# Warning: This only considers items that are marked as advancement items
diff = state.multiworld.worlds[player].difficulty_requirements
return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ state.count('Sanctuary Heart Container', player) \
+ state.count('Sanctuary Heart Container', player) \
+ min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
+ 3 # starting hearts
+ 3 # starting hearts
def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
basemagic = 8
if state.has('Magic Upgrade (1/4)', player):
basemagic = 32
@@ -84,11 +84,18 @@ def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
def can_hold_arrows(state: CollectionState, player: int, quantity: int):
arrows = 30 + ((state.count("Arrow Upgrade (+5)", player) * 5) + (state.count("Arrow Upgrade (+10)", player) * 10)
+ (state.count("Bomb Upgrade (50)", player) * 50))
# Arrow Upgrade (+5) beyond the 6th gives +10
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
return min(70, arrows) >= quantity
if state.multiworld.worlds[player].options.shuffle_capacity_upgrades:
if quantity == 0:
return True
if state.has("Arrow Upgrade (70)", player):
arrows = 70
else:
arrows = (30 + (state.count("Arrow Upgrade (+5)", player) * 5)
+ (state.count("Arrow Upgrade (+10)", player) * 10))
# Arrow Upgrade (+5) beyond the 6th gives +10
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
return min(70, arrows) >= quantity
return quantity <= 30 or state.has("Capacity Upgrade Shop", player)
def can_use_bombs(state: CollectionState, player: int, quantity: int = 1) -> bool:
@@ -146,19 +153,19 @@ def can_get_good_bee(state: CollectionState, player: int) -> bool:
def can_retrieve_tablet(state: CollectionState, player: int) -> bool:
return state.has('Book of Mudora', player) and (has_beam_sword(state, player) or
(state.multiworld.swordless[player] and
state.has("Hammer", player)))
state.has("Hammer", player)))
def has_sword(state: CollectionState, player: int) -> bool:
return state.has('Fighter Sword', player) \
or state.has('Master Sword', player) \
or state.has('Tempered Sword', player) \
or state.has('Golden Sword', player)
or state.has('Master Sword', player) \
or state.has('Tempered Sword', player) \
or state.has('Golden Sword', player)
def has_beam_sword(state: CollectionState, player: int) -> bool:
return state.has('Master Sword', player) or state.has('Tempered Sword', player) or state.has('Golden Sword',
player)
player)
def has_melee_weapon(state: CollectionState, player: int) -> bool:
@@ -171,9 +178,9 @@ def has_fire_source(state: CollectionState, player: int) -> bool:
def can_melt_things(state: CollectionState, player: int) -> bool:
return state.has('Fire Rod', player) or \
(state.has('Bombos', player) and
(state.multiworld.swordless[player] or
has_sword(state, player)))
(state.has('Bombos', player) and
(state.multiworld.swordless[player] or
has_sword(state, player)))
def has_misery_mire_medallion(state: CollectionState, player: int) -> bool:

View File

@@ -77,5 +77,5 @@ class TestMiseryMire(TestDungeon):
["Misery Mire - Boss", False, [], ['Bomb Upgrade (+5)', 'Bomb Upgrade (+10)', 'Bomb Upgrade (50)']],
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Sword', 'Pegasus Boots']],
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Hammer', 'Pegasus Boots']],
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Pegasus Boots']],
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Arrow Upgrade (+5)', 'Pegasus Boots']],
])

View File

@@ -93,7 +93,7 @@ class AquariaWorld(World):
options: AquariaOptions
"Every options of the world"
regions: AquariaRegions
regions: AquariaRegions | None
"Used to manage Regions"
exclude: List[str]
@@ -101,10 +101,17 @@ class AquariaWorld(World):
def __init__(self, multiworld: MultiWorld, player: int):
"""Initialisation of the Aquaria World"""
super(AquariaWorld, self).__init__(multiworld, player)
self.regions = AquariaRegions(multiworld, player)
self.regions = None
self.ingredients_substitution = []
self.exclude = []
def generate_early(self) -> None:
"""
Run before any general steps of the MultiWorld other than options. Useful for getting and adjusting option
results and determining layouts for entrance rando etc. start inventory gets pushed after this step.
"""
self.regions = AquariaRegions(self.multiworld, self.player)
def create_regions(self) -> None:
"""
Create every Region in `regions`

View File

@@ -103,6 +103,9 @@ class BlasphemousWorld(World):
if not self.options.wall_climb_shuffle:
self.multiworld.push_precollected(self.create_item("Wall Climb Ability"))
if self.options.thorn_shuffle == "local_only":
self.options.local_items.value.add("Thorn Upgrade")
if not self.options.boots_of_pleading:
self.disabled_locations.append("RE401")
@@ -200,9 +203,6 @@ class BlasphemousWorld(World):
if not self.options.skill_randomizer:
self.place_items_from_dict(skill_dict)
if self.options.thorn_shuffle == "local_only":
self.options.local_items.value.add("Thorn Upgrade")
def place_items_from_set(self, location_set: Set[str], name: str):

View File

@@ -1,5 +1,4 @@
import logging
import asyncio
from NetUtils import ClientStatus, color
from worlds.AutoSNIClient import SNIClient
@@ -32,7 +31,7 @@ class DKC3SNIClient(SNIClient):
async def validate_rom(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
from SNIClient import snes_read
rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE)
if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3":

View File

@@ -1,6 +1,6 @@
import typing
from BaseClasses import Item, ItemClassification
from BaseClasses import Item
from .Names import ItemName

View File

@@ -1,7 +1,6 @@
from dataclasses import dataclass
import typing
from Options import Choice, Range, Toggle, DeathLink, DefaultOnToggle, OptionGroup, PerGameCommonOptions
from Options import Choice, Range, Toggle, DefaultOnToggle, OptionGroup, PerGameCommonOptions
class Goal(Choice):

View File

@@ -1,10 +1,9 @@
import typing
from BaseClasses import MultiWorld, Region, Entrance
from .Items import DKC3Item
from BaseClasses import Region, Entrance
from worlds.AutoWorld import World
from .Locations import DKC3Location
from .Names import LocationName, ItemName
from worlds.AutoWorld import World
def create_regions(world: World, active_locations):

View File

@@ -2,7 +2,6 @@ import Utils
from Utils import read_snes_rom
from worlds.AutoWorld import World
from worlds.Files import APDeltaPatch
from .Locations import lookup_id_to_name, all_locations
from .Levels import level_list, level_dict
USHASH = '120abf304f0c40fe059f6a192ed4f947'
@@ -436,7 +435,7 @@ level_music_ids = [
class LocalRom:
def __init__(self, file, patch=True, vanillaRom=None, name=None, hash=None):
def __init__(self, file, name=None, hash=None):
self.name = name
self.hash = hash
self.orig_buffer = None

View File

@@ -1,8 +1,8 @@
import math
from worlds.AutoWorld import World
from worlds.generic.Rules import add_rule
from .Names import LocationName, ItemName
from worlds.AutoWorld import LogicMixin, World
from worlds.generic.Rules import add_rule, set_rule
def set_rules(world: World):

View File

@@ -1,15 +1,13 @@
import dataclasses
import os
import typing
import math
import os
import threading
import typing
import settings
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
from Options import PerGameCommonOptions
import Patch
import settings
from worlds.AutoWorld import WebWorld, World
from .Client import DKC3SNIClient
from .Items import DKC3Item, ItemData, item_table, inventory_table, junk_table
from .Levels import level_list

View File

@@ -3,13 +3,23 @@ from __future__ import annotations
from dataclasses import dataclass
import typing
from schema import Schema, Optional, And, Or
from schema import Schema, Optional, And, Or, SchemaError
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
StartInventoryPool, PerGameCommonOptions, OptionGroup
# schema helpers
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
class FloatRange:
def __init__(self, low, high):
self._low = low
self._high = high
def validate(self, value):
if not isinstance(value, (float, int)):
raise SchemaError(f"should be instance of float or int, but was {value!r}")
if not self._low <= value <= self._high:
raise SchemaError(f"{value} is not between {self._low} and {self._high}")
LuaBool = Or(bool, And(int, lambda n: n in (0, 1)))

View File

@@ -717,8 +717,10 @@ TRAP_TABLE = {
game.surfaces["nauvis"].build_enemy_base(game.forces["player"].get_spawn_position(game.get_surface(1)), 25)
end,
["Evolution Trap"] = function ()
game.forces["enemy"].evolution_factor = game.forces["enemy"].evolution_factor + (TRAP_EVO_FACTOR * (1 - game.forces["enemy"].evolution_factor))
game.print({"", "New evolution factor:", game.forces["enemy"].evolution_factor})
local new_factor = game.forces["enemy"].get_evolution_factor("nauvis") +
(TRAP_EVO_FACTOR * (1 - game.forces["enemy"].get_evolution_factor("nauvis")))
game.forces["enemy"].set_evolution_factor(new_factor, "nauvis")
game.print({"", "New evolution factor:", new_factor})
end,
["Teleport Trap"] = function ()
for _, player in ipairs(game.forces["player"].players) do

View File

@@ -0,0 +1,39 @@
"""Tests for error messages from YAML validation."""
import os
import unittest
import WebHostLib.check
FACTORIO_YAML="""
game: Factorio
Factorio:
world_gen:
autoplace_controls:
coal:
richness: 1
frequency: {}
size: 1
"""
def yamlWithFrequency(f):
return FACTORIO_YAML.format(f)
class TestFileValidation(unittest.TestCase):
def test_out_of_range(self):
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency(1000)})
self.assertIn("between 0 and 6", results["bob.yaml"])
def test_bad_non_numeric(self):
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency("not numeric")})
self.assertIn("float", results["bob.yaml"])
self.assertIn("int", results["bob.yaml"])
def test_good_float(self):
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency(1.0)})
self.assertIs(results["bob.yaml"], True)
def test_good_int(self):
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency(1)})
self.assertIs(results["bob.yaml"], True)

View File

@@ -44,8 +44,13 @@ class FaxanaduWorld(World):
location_name_to_id = {loc.name: loc.id for loc in Locations.locations if loc.id is not None}
def __init__(self, world: MultiWorld, player: int):
self.filler_ratios: Dict[str, int] = {}
self.filler_ratios: Dict[str, int] = {
item.name: item.count
for item in Items.items
if item.classification in [ItemClassification.filler, ItemClassification.trap]
}
# Remove poison by default to respect itemlinking
self.filler_ratios["Poison"] = 0
super().__init__(world, player)
def create_regions(self):
@@ -160,19 +165,13 @@ class FaxanaduWorld(World):
for i in range(item.progression_count):
itempool.append(FaxanaduItem(item.name, ItemClassification.progression, item.id, self.player))
# Set up filler ratios
self.filler_ratios = {
item.name: item.count
for item in Items.items
if item.classification in [ItemClassification.filler, ItemClassification.trap]
}
# Adjust filler ratios
# If red potions are locked in shops, remove the count from the ratio.
self.filler_ratios["Red Potion"] -= red_potion_in_shop_count
# Remove poisons if not desired
if not self.options.include_poisons:
self.filler_ratios["Poison"] = 0
# Add poisons if desired
if self.options.include_poisons:
self.filler_ratios["Poison"] = self.item_name_to_item["Poison"].count
# Randomly add fillers to the pool with ratios based on og game occurrence counts.
filler_count = len(Locations.locations) - len(itempool) - prefilled_count

View File

@@ -181,6 +181,7 @@ class HKWorld(World):
charm_costs: typing.List[int]
cached_filler_items = {}
grub_count: int
grub_player_count: typing.Dict[int, int]
def __init__(self, multiworld, player):
super(HKWorld, self).__init__(multiworld, player)
@@ -190,7 +191,6 @@ class HKWorld(World):
self.ranges = {}
self.created_shop_items = 0
self.vanilla_shop_costs = deepcopy(vanilla_shop_costs)
self.grub_count = 0
def generate_early(self):
options = self.options
@@ -204,7 +204,14 @@ class HKWorld(World):
mini.value = min(mini.value, maxi.value)
self.ranges[term] = mini.value, maxi.value
self.multiworld.push_precollected(HKItem(starts[options.StartLocation.current_key],
True, None, "Event", self.player))
True, None, "Event", self.player))
# defaulting so completion condition isn't incorrect before pre_fill
self.grub_count = (
46 if options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
else options.GrubHuntGoal
)
self.grub_player_count = {self.player: self.grub_count}
def white_palace_exclusions(self):
exclusions = set()
@@ -469,25 +476,20 @@ class HKWorld(World):
elif goal == Goal.option_godhome_flower:
multiworld.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player)
elif goal == Goal.option_grub_hunt:
pass # will set in stage_pre_fill()
multiworld.completion_condition[player] = lambda state: self.can_grub_goal(state)
else:
# Any goal
multiworld.completion_condition[player] = lambda state: _hk_siblings_ending(state, player) and \
_hk_can_beat_radiance(state, player) and state.count("Godhome_Flower_Quest", player)
_hk_can_beat_radiance(state, player) and state.count("Godhome_Flower_Quest", player) and \
self.can_grub_goal(state)
set_rules(self)
def can_grub_goal(self, state: CollectionState) -> bool:
return all(state.has("Grub", owner, count) for owner, count in self.grub_player_count.items())
@classmethod
def stage_pre_fill(cls, multiworld: "MultiWorld"):
def set_goal(player, grub_rule: typing.Callable[[CollectionState], bool]):
world = multiworld.worlds[player]
if world.options.Goal == "grub_hunt":
multiworld.completion_condition[player] = grub_rule
else:
old_rule = multiworld.completion_condition[player]
multiworld.completion_condition[player] = lambda state: old_rule(state) and grub_rule(state)
worlds = [world for world in multiworld.get_game_worlds(cls.game) if world.options.Goal in ["any", "grub_hunt"]]
if worlds:
grubs = [item for item in multiworld.get_items() if item.name == "Grub"]
@@ -525,13 +527,13 @@ class HKWorld(World):
for player, grub_player_count in per_player_grubs_per_player.items():
if player in all_grub_players:
set_goal(player, lambda state, g=grub_player_count: all(state.has("Grub", owner, count) for owner, count in g.items()))
multiworld.worlds[player].grub_player_count = grub_player_count
for world in worlds:
if world.player not in all_grub_players:
world.grub_count = world.options.GrubHuntGoal.value
player = world.player
set_goal(player, lambda state, p=player, c=world.grub_count: state.has("Grub", p, c))
world.grub_player_count = {player: world.grub_count}
def fill_slot_data(self):
slot_data = {}

View File

@@ -68,10 +68,12 @@ DEFAULT_ITEM_POOL = {
class ItemPool:
def __init__(self, logic, settings, rnd):
def __init__(self, logic, settings, rnd, stabilize_item_pool: bool):
self.__pool = {}
self.__setup(logic, settings)
self.__randomizeRupees(settings, rnd)
if not stabilize_item_pool:
self.__randomizeRupees(settings, rnd)
def add(self, item, count=1):
self.__pool[item] = self.__pool.get(item, 0) + count

View File

@@ -527,6 +527,13 @@ class InGameHints(DefaultOnToggle):
display_name = "In-game Hints"
class StabilizeItemPool(DefaultOffToggle):
"""
By default, rupees in the item pool may be randomly swapped with bombs, arrows, powders, or capacity upgrades. This option disables that swapping, which is useful for plando.
"""
display_name = "Stabilize Item Pool"
class ForeignItemIcons(Choice):
"""
Choose how to display foreign items.
@@ -562,6 +569,7 @@ ladx_option_groups = [
TrendyGame,
InGameHints,
NagMessages,
StabilizeItemPool,
Quickswap,
HardMode,
BootsControls
@@ -631,6 +639,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
no_flash: NoFlash
in_game_hints: InGameHints
overworld: Overworld
stabilize_item_pool: StabilizeItemPool
warp_improvements: Removed
additional_warp_points: Removed

View File

@@ -138,7 +138,30 @@ class LinksAwakeningWorld(World):
world_setup = LADXRWorldSetup()
world_setup.randomize(self.ladxr_settings, self.random)
self.ladxr_logic = LADXRLogic(configuration_options=self.ladxr_settings, world_setup=world_setup)
self.ladxr_itempool = LADXRItemPool(self.ladxr_logic, self.ladxr_settings, self.random).toDict()
self.ladxr_itempool = LADXRItemPool(self.ladxr_logic, self.ladxr_settings, self.random, bool(self.options.stabilize_item_pool)).toDict()
def generate_early(self) -> None:
self.dungeon_item_types = {
}
for dungeon_item_type in ["maps", "compasses", "small_keys", "nightmare_keys", "stone_beaks", "instruments"]:
option_name = "shuffle_" + dungeon_item_type
option: DungeonItemShuffle = getattr(self.options, option_name)
self.dungeon_item_types[option.ladxr_item] = option.value
# The color dungeon does not contain an instrument
num_items = 8 if dungeon_item_type == "instruments" else 9
# For any and different world, set item rule instead
if option.value == DungeonItemShuffle.option_own_world:
self.options.local_items.value |= {
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, num_items + 1)
}
elif option.value == DungeonItemShuffle.option_different_world:
self.options.non_local_items.value |= {
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, num_items + 1)
}
def create_regions(self) -> None:
# Initialize
@@ -185,32 +208,9 @@ class LinksAwakeningWorld(World):
def create_items(self) -> None:
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
dungeon_item_types = {
}
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
self.prefill_own_dungeons = []
self.pre_fill_items = []
# For any and different world, set item rule instead
for dungeon_item_type in ["maps", "compasses", "small_keys", "nightmare_keys", "stone_beaks", "instruments"]:
option_name = "shuffle_" + dungeon_item_type
option: DungeonItemShuffle = getattr(self.options, option_name)
dungeon_item_types[option.ladxr_item] = option.value
# The color dungeon does not contain an instrument
num_items = 8 if dungeon_item_type == "instruments" else 9
if option.value == DungeonItemShuffle.option_own_world:
self.options.local_items.value |= {
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, num_items + 1)
}
elif option.value == DungeonItemShuffle.option_different_world:
self.options.non_local_items.value |= {
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, num_items + 1)
}
# option_original_dungeon = 0
# option_own_dungeons = 1
# option_own_world = 2
@@ -226,7 +226,7 @@ class LinksAwakeningWorld(World):
for _ in range(count):
if item_name in exclude:
exclude.remove(item_name) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item("Nothing"))
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
else:
item = self.create_item(item_name)
@@ -238,7 +238,7 @@ class LinksAwakeningWorld(World):
if isinstance(item.item_data, DungeonItemData):
item_type = item.item_data.ladxr_id[:-1]
shuffle_type = dungeon_item_types[item_type]
shuffle_type = self.dungeon_item_types[item_type]
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
# Find instrument, lock
@@ -500,8 +500,14 @@ class LinksAwakeningWorld(World):
state.prog_items[self.player]["RUPEES"] -= self.rupees[item.name]
return change
# Same fill choices and weights used in LADXR.itempool.__randomizeRupees
filler_choices = ("Bomb", "Single Arrow", "10 Arrows", "Magic Powder", "Medicine")
filler_weights = ( 10, 5, 10, 10, 1)
def get_filler_item_name(self) -> str:
return "Nothing"
if self.options.stabilize_item_pool:
return "Nothing"
return self.random.choices(self.filler_choices, self.filler_weights)[0]
def fill_slot_data(self):
slot_data = {}

View File

@@ -128,6 +128,9 @@ class LingoWorld(World):
pool.append(self.create_item("Puzzle Skip"))
if traps:
if self.options.speed_boost_mode:
self.options.trap_weights.value["Slowness Trap"] = 0
total_weight = sum(self.options.trap_weights.values())
if total_weight == 0:
@@ -171,7 +174,7 @@ class LingoWorld(World):
"death_link", "victory_condition", "shuffle_colors", "shuffle_doors", "shuffle_paintings", "shuffle_panels",
"enable_pilgrimage", "sunwarp_access", "mastery_achievements", "level_2_requirement", "location_checks",
"early_color_hallways", "pilgrimage_allows_roof_access", "pilgrimage_allows_paintings", "shuffle_sunwarps",
"group_doors"
"group_doors", "speed_boost_mode"
]
slot_data = {
@@ -188,5 +191,8 @@ class LingoWorld(World):
return slot_data
def get_filler_item_name(self) -> str:
filler_list = [":)", "The Feeling of Being Lost", "Wanderlust", "Empty White Hallways"]
return self.random.choice(filler_list)
if self.options.speed_boost_mode:
return "Speed Boost"
else:
filler_list = [":)", "The Feeling of Being Lost", "Wanderlust", "Empty White Hallways"]
return self.random.choice(filler_list)

Binary file not shown.

View File

@@ -17,6 +17,7 @@ special_items:
Iceland Trap: 444411
Atbash Trap: 444412
Puzzle Skip: 444413
Speed Boost: 444680
panels:
Starting Room:
HI: 444400

View File

@@ -85,6 +85,7 @@ def load_item_data():
"The Feeling of Being Lost": ItemClassification.filler,
"Wanderlust": ItemClassification.filler,
"Empty White Hallways": ItemClassification.filler,
"Speed Boost": ItemClassification.filler,
**{trap_name: ItemClassification.trap for trap_name in TRAP_ITEMS},
"Puzzle Skip": ItemClassification.useful,
}

View File

@@ -232,6 +232,14 @@ class TrapWeights(OptionDict):
default = {trap_name: 1 for trap_name in TRAP_ITEMS}
class SpeedBoostMode(Toggle):
"""
If on, the player's default speed is halved, as if affected by a Slowness Trap. Speed Boosts are added to
the item pool, which temporarily return the player to normal speed. Slowness Traps are removed from the pool.
"""
display_name = "Speed Boost Mode"
class PuzzleSkipPercentage(Range):
"""Replaces junk items with puzzle skips, at the specified rate."""
display_name = "Puzzle Skip Percentage"
@@ -260,6 +268,7 @@ lingo_option_groups = [
Level2Requirement,
TrapPercentage,
TrapWeights,
SpeedBoostMode,
PuzzleSkipPercentage,
])
]
@@ -287,6 +296,7 @@ class LingoOptions(PerGameCommonOptions):
shuffle_postgame: ShufflePostgame
trap_percentage: TrapPercentage
trap_weights: TrapWeights
speed_boost_mode: SpeedBoostMode
puzzle_skip_percentage: PuzzleSkipPercentage
death_link: DeathLink
start_inventory_from_pool: StartInventoryPool

View File

@@ -59,4 +59,11 @@ class TestShuffleSunwarpsAccess(LingoTestBase):
"victory_condition": "pilgrimage",
"shuffle_sunwarps": "true",
"sunwarp_access": "individual"
}
}
class TestSpeedBoostMode(LingoTestBase):
options = {
"location_checks": "insanity",
"speed_boost_mode": "true",
}

View File

@@ -216,3 +216,6 @@ config.each do |room_name, room_data|
end
File.write(outputpath, old_generated.to_yaml)
puts "Next item ID: #{next_item_id}"
puts "Next location ID: #{next_location_id}"

View File

@@ -2200,7 +2200,7 @@ def patch_rom(world, rom):
elif world.shuffle_bosses != 'off':
vanilla_reward = world.get_location(boss_name).vanilla_item
vanilla_reward_location = world.multiworld.find_item(vanilla_reward, world.player) # hinted_dungeon_reward_locations[vanilla_reward.name]
area = HintArea.at(vanilla_reward_location).text(world.clearer_hints, preposition=True)
area = HintArea.at(vanilla_reward_location).text(world.hint_rng, world.clearer_hints, preposition=True)
compass_message = "\x13\x75\x08You found the \x05\x41Compass\x05\x40\x01for %s\x05\x40!\x01The %s can be found\x01%s!\x09" % (dungeon_name, vanilla_reward, area)
else:
boss_location = next(filter(lambda loc: loc.type == 'Boss', world.get_entrance(f'{dungeon} Boss Door -> {boss_name} Boss Room').connected_region.locations))

View File

@@ -582,8 +582,7 @@ class OOTWorld(World):
new_exit = OOTEntrance(self.player, self.multiworld, '%s -> %s' % (new_region.name, exit), new_region)
new_exit.vanilla_connected_region = exit
new_exit.rule_string = rule
if self.options.logic_rules != 'no_logic':
self.parser.parse_spot_rule(new_exit)
self.parser.parse_spot_rule(new_exit)
if new_exit.never:
logger.debug('Dropping unreachable exit: %s', new_exit.name)
else:

View File

@@ -11,7 +11,7 @@ from ..strings.craftable_names import Bomb, Fence, Sprinkler, WildSeeds, Floor,
from ..strings.crop_names import Fruit, Vegetable
from ..strings.currency_names import Currency
from ..strings.fertilizer_names import Fertilizer, RetainingSoil, SpeedGro
from ..strings.fish_names import Fish, WaterItem, ModTrash
from ..strings.fish_names import Fish, WaterItem, ModTrash, Trash
from ..strings.flower_names import Flower
from ..strings.food_names import Meal
from ..strings.forageable_names import Forageable, SVEForage, DistantLandsForageable, Mushroom
@@ -378,4 +378,12 @@ recycling_bin = skill_recipe(ModMachine.recycling_bin, ModSkill.binning, 7, {Met
advanced_recycling_machine = skill_recipe(ModMachine.advanced_recycling_machine, ModSkill.binning, 9,
{MetalBar.iridium: 5, ArtisanGood.battery_pack: 2, MetalBar.quartz: 10}, ModNames.binning_skill)
coppper_slot_machine = skill_recipe(ModMachine.copper_slot_machine, ModSkill.luck, 2, {MetalBar.copper: 15, Material.stone: 1, Material.wood: 1,
Material.fiber: 1, Material.sap: 1, Loot.slime: 1,
Forageable.salmonberry: 1, Material.clay: 1, Trash.joja_cola: 1}, ModNames.luck_skill)
gold_slot_machine = skill_recipe(ModMachine.gold_slot_machine, ModSkill.luck, 4, {MetalBar.gold: 15, ModMachine.copper_slot_machine: 1}, ModNames.luck_skill)
iridium_slot_machine = skill_recipe(ModMachine.iridium_slot_machine, ModSkill.luck, 4, {MetalBar.iridium: 15, ModMachine.gold_slot_machine: 1}, ModNames.luck_skill)
radioactive_slot_machine = skill_recipe(ModMachine.radioactive_slot_machine, ModSkill.luck, 4, {MetalBar.radioactive: 15, ModMachine.iridium_slot_machine: 1}, ModNames.luck_skill)
all_crafting_recipes_by_name = {recipe.item: recipe for recipe in all_crafting_recipes}

View File

@@ -2935,6 +2935,10 @@ id,region,name,tags,mod_name
7433,Farm,Craft Composter,CRAFTSANITY,Binning Skill
7434,Farm,Craft Recycling Bin,CRAFTSANITY,Binning Skill
7435,Farm,Craft Advanced Recycling Machine,CRAFTSANITY,Binning Skill
7440,Farm,Craft Copper Slot Machine,"CRAFTSANITY",Luck Skill
7441,Farm,Craft Gold Slot Machine,"CRAFTSANITY",Luck Skill
7442,Farm,Craft Iridium Slot Machine,"CRAFTSANITY",Luck Skill
7443,Farm,Craft Radioactive Slot Machine,"CRAFTSANITY",Luck Skill
7451,Adventurer's Guild,Magic Elixir Recipe,"CHEFSANITY,CHEFSANITY_PURCHASE",Magic
7452,Adventurer's Guild,Travel Core Recipe,CRAFTSANITY,Magic
7453,Alesia Shop,Haste Elixir Recipe,CRAFTSANITY,Stardew Valley Expanded
@@ -3241,7 +3245,7 @@ id,region,name,tags,mod_name
8199,Shipping,Shipsanity: Hardwood Display,SHIPSANITY,Archaeology
8200,Shipping,Shipsanity: Wooden Display,SHIPSANITY,Archaeology
8201,Shipping,Shipsanity: Dwarf Gadget: Infinite Volcano Simulation,"SHIPSANITY,GINGER_ISLAND",Archaeology
8202,Shipping,Shipsanity: Water Shifter,SHIPSANITY,Archaeology
8202,Shipping,Shipsanity: Water Shifter,"SHIPSANITY,DEPRECATED",Archaeology
8203,Shipping,Shipsanity: Brown Amanita,"SHIPSANITY,SHIPSANITY_FULL_SHIPMENT",Distant Lands - Witch Swamp Overhaul
8204,Shipping,Shipsanity: Swamp Herb,"SHIPSANITY,SHIPSANITY_FULL_SHIPMENT",Distant Lands - Witch Swamp Overhaul
8205,Shipping,Shipsanity: Void Mint Seeds,SHIPSANITY,Distant Lands - Witch Swamp Overhaul
1 id region name tags mod_name
2935 7433 Farm Craft Composter CRAFTSANITY Binning Skill
2936 7434 Farm Craft Recycling Bin CRAFTSANITY Binning Skill
2937 7435 Farm Craft Advanced Recycling Machine CRAFTSANITY Binning Skill
2938 7440 Farm Craft Copper Slot Machine CRAFTSANITY Luck Skill
2939 7441 Farm Craft Gold Slot Machine CRAFTSANITY Luck Skill
2940 7442 Farm Craft Iridium Slot Machine CRAFTSANITY Luck Skill
2941 7443 Farm Craft Radioactive Slot Machine CRAFTSANITY Luck Skill
2942 7451 Adventurer's Guild Magic Elixir Recipe CHEFSANITY,CHEFSANITY_PURCHASE Magic
2943 7452 Adventurer's Guild Travel Core Recipe CRAFTSANITY Magic
2944 7453 Alesia Shop Haste Elixir Recipe CRAFTSANITY Stardew Valley Expanded
3245 8199 Shipping Shipsanity: Hardwood Display SHIPSANITY Archaeology
3246 8200 Shipping Shipsanity: Wooden Display SHIPSANITY Archaeology
3247 8201 Shipping Shipsanity: Dwarf Gadget: Infinite Volcano Simulation SHIPSANITY,GINGER_ISLAND Archaeology
3248 8202 Shipping Shipsanity: Water Shifter SHIPSANITY SHIPSANITY,DEPRECATED Archaeology
3249 8203 Shipping Shipsanity: Brown Amanita SHIPSANITY,SHIPSANITY_FULL_SHIPMENT Distant Lands - Witch Swamp Overhaul
3250 8204 Shipping Shipsanity: Swamp Herb SHIPSANITY,SHIPSANITY_FULL_SHIPMENT Distant Lands - Witch Swamp Overhaul
3251 8205 Shipping Shipsanity: Void Mint Seeds SHIPSANITY Distant Lands - Witch Swamp Overhaul

View File

@@ -110,6 +110,8 @@ class LocationTags(enum.Enum):
MAGIC_LEVEL = enum.auto()
ARCHAEOLOGY_LEVEL = enum.auto()
DEPRECATED = enum.auto()
@dataclass(frozen=True)
class LocationData:
@@ -519,6 +521,10 @@ def create_locations(location_collector: StardewLocationCollector,
location_collector(location_data.name, location_data.code, location_data.region)
def filter_deprecated_locations(locations: Iterable[LocationData]) -> Iterable[LocationData]:
return [location for location in locations if LocationTags.DEPRECATED not in location.tags]
def filter_farm_type(options: StardewValleyOptions, locations: Iterable[LocationData]) -> Iterable[LocationData]:
# On Meadowlands, "Feeding Animals" replaces "Raising Animals"
if options.farm_type == FarmType.option_meadowlands:
@@ -549,7 +555,8 @@ def filter_modded_locations(options: StardewValleyOptions, locations: Iterable[L
def filter_disabled_locations(options: StardewValleyOptions, content: StardewContent, locations: Iterable[LocationData]) -> Iterable[LocationData]:
locations_farm_filter = filter_farm_type(options, locations)
locations_deprecated_filter = filter_deprecated_locations(locations)
locations_farm_filter = filter_farm_type(options, locations_deprecated_filter)
locations_island_filter = filter_ginger_island(options, locations_farm_filter)
locations_qi_filter = filter_qi_order_locations(options, locations_island_filter)
locations_masteries_filter = filter_masteries_locations(content, locations_qi_filter)

View File

@@ -757,6 +757,14 @@ class Gifting(Toggle):
default = 1
all_mods = {ModNames.deepwoods, ModNames.tractor, ModNames.big_backpack,
ModNames.luck_skill, ModNames.magic, ModNames.socializing_skill, ModNames.archaeology,
ModNames.cooking_skill, ModNames.binning_skill, ModNames.juna,
ModNames.jasper, ModNames.alec, ModNames.yoba, ModNames.eugene,
ModNames.wellwick, ModNames.ginger, ModNames.shiko, ModNames.delores,
ModNames.ayeisha, ModNames.riley, ModNames.skull_cavern_elevator, ModNames.sve, ModNames.distant_lands,
ModNames.alecto, ModNames.lacey, ModNames.boarding_house}
# These mods have been disabled because either they are not updated for the current supported version of Stardew Valley,
# or we didn't find the time to validate that they work or fix compatibility issues if they do.
# Once a mod is validated to be functional, it can simply be removed from this list
@@ -766,8 +774,7 @@ disabled_mods = {ModNames.deepwoods, ModNames.magic,
ModNames.wellwick, ModNames.shiko, ModNames.delores, ModNames.riley,
ModNames.boarding_house}
if 'unittest' in sys.modules.keys() or 'pytest' in sys.modules.keys():
disabled_mods = {}
enabled_mods = all_mods.difference(disabled_mods)
class Mods(OptionSet):
@@ -775,13 +782,11 @@ class Mods(OptionSet):
visibility = Visibility.all & ~Visibility.simple_ui
internal_name = "mods"
display_name = "Mods"
valid_keys = {ModNames.deepwoods, ModNames.tractor, ModNames.big_backpack,
ModNames.luck_skill, ModNames.magic, ModNames.socializing_skill, ModNames.archaeology,
ModNames.cooking_skill, ModNames.binning_skill, ModNames.juna,
ModNames.jasper, ModNames.alec, ModNames.yoba, ModNames.eugene,
ModNames.wellwick, ModNames.ginger, ModNames.shiko, ModNames.delores,
ModNames.ayeisha, ModNames.riley, ModNames.skull_cavern_elevator, ModNames.sve, ModNames.distant_lands,
ModNames.alecto, ModNames.lacey, ModNames.boarding_house}.difference(disabled_mods)
valid_keys = enabled_mods
# In tests, we keep even the disabled mods active, because we expect some of them to eventually get updated for SV 1.6
# In that case, we want to maintain content and logic for them, and therefore keep testing them
if 'unittest' in sys.modules.keys() or 'pytest' in sys.modules.keys():
valid_keys = all_mods
class BundlePlando(OptionSet):

View File

@@ -201,6 +201,10 @@ class ModMachine:
composter = "Composter"
recycling_bin = "Recycling Bin"
advanced_recycling_machine = "Advanced Recycling Machine"
copper_slot_machine = "Copper Slot Machine"
gold_slot_machine = "Gold Slot Machine"
iridium_slot_machine = "Iridium Slot Machine"
radioactive_slot_machine = "Radioactive Slot Machine"
class ModFloor:

View File

@@ -1,5 +1,6 @@
from . import SVTestBase, allsanity_no_mods_6_x_x, \
allsanity_mods_6_x_x, minimal_locations_maximal_items, minimal_locations_maximal_items_with_island, get_minsanity_options, default_6_x_x
allsanity_mods_6_x_x, minimal_locations_maximal_items, minimal_locations_maximal_items_with_island, get_minsanity_options, default_6_x_x, \
allsanity_mods_6_x_x_exclude_disabled
from .. import location_table
from ..items import Group, item_table
@@ -70,7 +71,7 @@ class TestAllSanitySettingsHasAllExpectedLocations(SVTestBase):
options = allsanity_no_mods_6_x_x()
def test_allsanity_without_mods_has_at_least_locations(self):
expected_locations = 2238
expected_locations = 2256
real_locations = self.get_real_locations()
number_locations = len(real_locations)
print(f"Stardew Valley - Allsanity Locations without mods: {number_locations}")
@@ -83,10 +84,10 @@ class TestAllSanitySettingsHasAllExpectedLocations(SVTestBase):
class TestAllSanityWithModsSettingsHasAllExpectedLocations(SVTestBase):
options = allsanity_mods_6_x_x()
options = allsanity_mods_6_x_x_exclude_disabled()
def test_allsanity_with_mods_has_at_least_locations(self):
expected_locations = 3096
expected_locations = 2908
real_locations = self.get_real_locations()
number_locations = len(real_locations)
print(f"Stardew Valley - Allsanity Locations with all mods: {number_locations}")

View File

@@ -13,6 +13,7 @@ from .assertion import RuleAssertMixin
from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default
from .. import StardewValleyWorld, options, StardewItem
from ..options import StardewValleyOption
from ..options.options import enabled_mods
logger = logging.getLogger(__name__)
@@ -98,6 +99,12 @@ def allsanity_mods_6_x_x():
return allsanity
def allsanity_mods_6_x_x_exclude_disabled():
allsanity = allsanity_no_mods_6_x_x()
allsanity.update({options.Mods.internal_name: frozenset(enabled_mods)})
return allsanity
def get_minsanity_options():
return {
options.ArcadeMachineLocations.internal_name: options.ArcadeMachineLocations.option_disabled,

View File

@@ -40,7 +40,7 @@ guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en)
### Where do I get a config file?
The Player Options page on the website allows you to configure your personal options and export a config file from
them. Player options page: [The Legend of Zelda Player Sptions Page](/games/The%20Legend%20of%20Zelda/player-options)
them. Player options page: [The Legend of Zelda Player Options Page](/games/The%20Legend%20of%20Zelda/player-options)
### Verifying your config file

View File

@@ -1,19 +1,20 @@
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set
from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names,
combat_items)
from .locations import location_table, location_name_groups, location_name_to_id, hexagon_locations
from .locations import location_table, location_name_groups, standard_location_name_to_id, hexagon_locations, sphere_one
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules
from .regions import tunic_regions
from .er_scripts import create_er_regions
from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations
from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage)
from .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection
from Options import PlandoConnection, OptionError
from decimal import Decimal, ROUND_HALF_UP
from settings import Group, Bool
@@ -22,7 +23,11 @@ class TunicSettings(Group):
class DisableLocalSpoiler(Bool):
"""Disallows the TUNIC client from creating a local spoiler log."""
class LimitGrassRando(Bool):
"""Limits the impact of Grass Randomizer on the multiworld by disallowing local_fill percentages below 95."""
disable_local_spoiler: Union[DisableLocalSpoiler, bool] = False
limit_grass_rando: Union[LimitGrassRando, bool] = True
class TunicWeb(WebWorld):
@@ -73,10 +78,13 @@ class TunicWorld(World):
settings: ClassVar[TunicSettings]
item_name_groups = item_name_groups
location_name_groups = location_name_groups
location_name_groups.update(grass_location_name_groups)
item_name_to_id = item_name_to_id
location_name_to_id = location_name_to_id
location_name_to_id = standard_location_name_to_id.copy()
location_name_to_id.update(grass_location_name_to_id)
player_location_table: Dict[str, int]
ability_unlocks: Dict[str, int]
slot_data_items: List[TunicItem]
tunic_portal_pairs: Dict[str, str]
@@ -85,6 +93,11 @@ class TunicWorld(World):
shop_num: int = 1 # need to make it so that you can walk out of shops, but also that they aren't all connected
er_regions: Dict[str, RegionInfo] # absolutely needed so outlet regions work
# for the local_fill option
fill_items: List[TunicItem]
fill_locations: List[TunicLocation]
amount_to_local_fill: int
# so we only loop the multiworld locations once
# if these are locations instead of their info, it gives a memory leak error
item_link_locations: Dict[int, Dict[str, List[Tuple[int, str]]]] = {}
@@ -132,6 +145,7 @@ class TunicWorld(World):
self.options.hexagon_quest.value = self.passthrough["hexagon_quest"]
self.options.entrance_rando.value = self.passthrough["entrance_rando"]
self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"]
self.options.grass_randomizer.value = self.passthrough.get("grass_randomizer", 0)
self.options.fixed_shop.value = self.options.fixed_shop.option_false
self.options.laurels_location.value = self.options.laurels_location.option_anywhere
self.options.combat_logic.value = self.passthrough["combat_logic"]
@@ -140,6 +154,22 @@ class TunicWorld(World):
else:
self.using_ut = False
self.player_location_table = standard_location_name_to_id.copy()
if self.options.local_fill == -1:
if self.options.grass_randomizer:
self.options.local_fill.value = 95
else:
self.options.local_fill.value = 0
if self.options.grass_randomizer:
if self.settings.limit_grass_rando and self.options.local_fill < 95 and self.multiworld.players > 1:
raise OptionError(f"TUNIC: Player {self.player_name} has their Local Fill option set too low. "
f"They must either bring it above 95% or the host needs to disable limit_grass_rando "
f"in their host.yaml settings")
self.player_location_table.update(grass_location_name_to_id)
@classmethod
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
@@ -245,6 +275,14 @@ class TunicWorld(World):
self.get_location("Secret Gathering Place - 10 Fairy Reward").place_locked_item(laurels)
items_to_create["Hero's Laurels"] = 0
if self.options.grass_randomizer:
items_to_create["Grass"] = len(grass_location_table)
tunic_items.append(self.create_item("Glass Cannon", ItemClassification.progression))
items_to_create["Glass Cannon"] = 0
for grass_location in excluded_grass_locations:
self.get_location(grass_location).place_locked_item(self.create_item("Grass"))
items_to_create["Grass"] -= len(excluded_grass_locations)
if self.options.keys_behind_bosses:
for rgb_hexagon, location in hexagon_locations.items():
hex_item = self.create_item(gold_hexagon if self.options.hexagon_quest else rgb_hexagon)
@@ -332,8 +370,73 @@ class TunicWorld(World):
if tunic_item.name in slot_data_item_names:
self.slot_data_items.append(tunic_item)
# pull out the filler so that we can place it manually during pre_fill
self.fill_items = []
if self.options.local_fill > 0 and self.multiworld.players > 1:
# skip items marked local or non-local, let fill deal with them in its own way
# discard grass from non_local if it's meant to be limited
if self.settings.limit_grass_rando:
self.options.non_local_items.value.discard("Grass")
all_filler: List[TunicItem] = []
non_filler: List[TunicItem] = []
for tunic_item in tunic_items:
if (tunic_item.excludable
and tunic_item.name not in self.options.local_items
and tunic_item.name not in self.options.non_local_items):
all_filler.append(tunic_item)
else:
non_filler.append(tunic_item)
self.amount_to_local_fill = int(self.options.local_fill.value * len(all_filler) / 100)
self.fill_items += all_filler[:self.amount_to_local_fill]
del all_filler[:self.amount_to_local_fill]
tunic_items = all_filler + non_filler
self.multiworld.itempool += tunic_items
def pre_fill(self) -> None:
self.fill_locations = []
if self.options.local_fill > 0 and self.multiworld.players > 1:
# we need to reserve a couple locations so that we don't fill up every sphere 1 location
reserved_locations: Set[str] = set(self.random.sample(sphere_one, 2))
viable_locations = [loc for loc in self.multiworld.get_unfilled_locations(self.player)
if loc.name not in reserved_locations
and loc.name not in self.options.priority_locations.value]
if len(viable_locations) < self.amount_to_local_fill:
raise OptionError(f"TUNIC: Not enough locations for local_fill option for {self.player_name}. "
f"This is likely due to excess plando or priority locations.")
self.fill_locations += viable_locations
@classmethod
def stage_pre_fill(cls, multiworld: MultiWorld) -> None:
tunic_fill_worlds: List[TunicWorld] = [world for world in multiworld.get_game_worlds("TUNIC")
if world.options.local_fill.value > 0]
if tunic_fill_worlds:
grass_fill: List[TunicItem] = []
non_grass_fill: List[TunicItem] = []
grass_fill_locations: List[Location] = []
non_grass_fill_locations: List[Location] = []
for world in tunic_fill_worlds:
if world.options.grass_randomizer:
grass_fill.extend(world.fill_items)
grass_fill_locations.extend(world.fill_locations)
else:
non_grass_fill.extend(world.fill_items)
non_grass_fill_locations.extend(world.fill_locations)
multiworld.random.shuffle(grass_fill)
multiworld.random.shuffle(non_grass_fill)
multiworld.random.shuffle(grass_fill_locations)
multiworld.random.shuffle(non_grass_fill_locations)
for filler_item in grass_fill:
multiworld.push_item(grass_fill_locations.pop(), filler_item, collect=False)
for filler_item in non_grass_fill:
multiworld.push_item(non_grass_fill_locations.pop(), filler_item, collect=False)
def create_regions(self) -> None:
self.tunic_portal_pairs = {}
self.er_portal_hints = {}
@@ -346,7 +449,8 @@ class TunicWorld(World):
self.ability_unlocks["Pages 52-53 (Icebolt)"] = self.passthrough["Hexagon Quest Icebolt"]
# Ladders and Combat Logic uses ER rules with vanilla connections for easier maintenance
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
if (self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic
or self.options.grass_randomizer):
portal_pairs = create_er_regions(self)
if self.options.entrance_rando:
# these get interpreted by the game to tell it which entrances to connect
@@ -362,7 +466,7 @@ class TunicWorld(World):
region = self.get_region(region_name)
region.add_exits(exits)
for location_name, location_id in self.location_name_to_id.items():
for location_name, location_id in self.player_location_table.items():
region = self.get_region(location_table[location_name].region)
location = TunicLocation(self.player, location_name, location_id, region)
region.locations.append(location)
@@ -375,7 +479,8 @@ class TunicWorld(World):
def set_rules(self) -> None:
# same reason as in create_regions, could probably be put into create_regions
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
if (self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic
or self.options.grass_randomizer):
set_er_location_rules(self)
else:
set_region_rules(self)
@@ -463,6 +568,7 @@ class TunicWorld(World):
"maskless": self.options.maskless.value,
"entrance_rando": int(bool(self.options.entrance_rando.value)),
"shuffle_ladders": self.options.shuffle_ladders.value,
"grass_randomizer": self.options.grass_randomizer.value,
"combat_logic": self.options.combat_logic.value,
"Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"],
"Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"],

View File

@@ -629,14 +629,16 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Beneath the Well Back": RegionInfo("Sewer"), # the back two portals, and all 4 upper chests
"West Garden before Terry": RegionInfo("Archipelagos Redux"), # the lower entry point, near hero grave
"West Garden after Terry": RegionInfo("Archipelagos Redux"), # after Terry, up until next chompignons
"West Garden West Combat": RegionInfo("Archipelagos Redux"), # for grass rando basically
"West Garden at Dagger House": RegionInfo("Archipelagos Redux"), # just outside magic dagger house
"West Garden South Checkpoint": RegionInfo("Archipelagos Redux"),
"West Garden South Checkpoint": RegionInfo("Archipelagos Redux"), # the checkpoint and the blue lines area
"Magic Dagger House": RegionInfo("archipelagos_house", dead_end=DeadEnd.all_cats),
"West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted, outlet_region="West Garden by Portal"),
"West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted,
outlet_region="West Garden by Portal"),
"West Garden by Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Portal Item": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Laurels Exit Region": RegionInfo("Archipelagos Redux"),
"West Garden before Boss": RegionInfo("Archipelagos Redux"), # main west garden
"West Garden before Boss": RegionInfo("Archipelagos Redux"), # up the ladder before garden knight
"West Garden after Boss": RegionInfo("Archipelagos Redux"),
"West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden before Terry"),
"Ruined Atoll": RegionInfo("Atoll Redux"),
@@ -1165,8 +1167,10 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"West Garden after Terry": {
"West Garden before Terry":
[],
"West Garden South Checkpoint":
"West Garden West Combat":
[],
"West Garden South Checkpoint":
[["Hyperdash"]],
"West Garden Laurels Exit Region":
[["LS1"]],
},
@@ -1176,6 +1180,8 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"West Garden at Dagger House":
[],
"West Garden after Terry":
[["Hyperdash"]],
"West Garden West Combat":
[],
},
"West Garden before Boss": {

View File

@@ -1,12 +1,13 @@
from typing import Dict, FrozenSet, Tuple, TYPE_CHECKING
from worlds.generic.Rules import set_rule, add_rule, forbid_item
from BaseClasses import Region, CollectionState
from .options import IceGrappling, LadderStorage, CombatLogic
from .rules import (has_ability, has_sword, has_melee, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
laurels_zip, bomb_walls)
from .er_data import Portal, get_portal_outlet_region
from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls
from .combat_logic import has_combat_reqs
from BaseClasses import Region, CollectionState
from .grass import set_grass_location_rules
if TYPE_CHECKING:
from . import TunicWorld
@@ -555,7 +556,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Dark Tomb Upper"].connect(
connecting_region=regions["Dark Tomb Entry Point"])
# ice grapple through the wall, get the little secret sound to trigger
regions["Dark Tomb Upper"].connect(
connecting_region=regions["Dark Tomb Main"],
rule=lambda state: has_ladder("Ladder in Dark Tomb", state, world)
@@ -577,11 +577,24 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
wg_after_to_before_terry = regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden before Terry"])
regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden South Checkpoint"])
wg_checkpoint_to_after_terry = regions["West Garden South Checkpoint"].connect(
wg_after_terry_to_west_combat = regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden West Combat"])
regions["West Garden West Combat"].connect(
connecting_region=regions["West Garden after Terry"])
wg_checkpoint_to_west_combat = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden West Combat"])
regions["West Garden West Combat"].connect(
connecting_region=regions["West Garden South Checkpoint"])
# if not laurels, it goes through the west combat region instead
regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden South Checkpoint"],
rule=lambda state: state.has(laurels, player))
regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden after Terry"],
rule=lambda state: state.has(laurels, player))
wg_checkpoint_to_dagger = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden at Dagger House"])
regions["West Garden at Dagger House"].connect(
@@ -1402,11 +1415,15 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
set_rule(wg_after_to_before_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
or has_combat_reqs("West Garden", state, player))
# laurels through, probably to the checkpoint, or just fight
set_rule(wg_checkpoint_to_after_terry,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player))
set_rule(wg_checkpoint_to_before_boss,
set_rule(wg_after_terry_to_west_combat,
lambda state: has_combat_reqs("West Garden", state, player))
set_rule(wg_checkpoint_to_west_combat,
lambda state: has_combat_reqs("West Garden", state, player))
# maybe a little too generous? probably fine though
set_rule(wg_checkpoint_to_before_boss,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player))
add_rule(btv_front_to_main,
lambda state: has_combat_reqs("Beneath the Vault", state, player))
@@ -1528,6 +1545,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
def set_er_location_rules(world: "TunicWorld") -> None:
player = world.player
if world.options.grass_randomizer:
set_grass_location_rules(world)
forbid_item(world.get_location("Secret Gathering Place - 20 Fairy Reward"), fairies, player)
# Ability Shuffle Exclusive Rules
@@ -1852,6 +1872,8 @@ def set_er_location_rules(world: "TunicWorld") -> None:
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Faeries", "West Garden")
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Save Point", "West Garden")
combat_logic_to_loc("West Garden - [West Highlands] Upper Left Walkway", "West Garden")
combat_logic_to_loc("West Garden - [Central Highlands] Holy Cross (Blue Lines)", "West Garden")
combat_logic_to_loc("West Garden - [Central Highlands] Behind Guard Captain", "West Garden")
# with combat logic on, I presume the player will want to be able to see to avoid the spiders
set_rule(world.get_location("Beneath the Fortress - Bridge"),

View File

@@ -1,6 +1,6 @@
from typing import Dict, List, Set, Tuple, TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location
from .locations import location_table
from .locations import all_locations
from .er_data import Portal, portal_mapping, traversal_requirements, DeadEnd, RegionInfo
from .er_rules import set_er_region_rules
from Options import PlandoConnection
@@ -53,8 +53,8 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
set_er_region_rules(world, regions, portal_pairs)
for location_name, location_id in world.location_name_to_id.items():
region = regions[location_table[location_name].er_region]
for location_name, location_id in world.player_location_table.items():
region = regions[all_locations[location_name].er_region]
location = TunicERLocation(world.player, location_name, location_id, region)
region.locations.append(location)

7944
worlds/tunic/grass.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -166,6 +166,7 @@ item_table: Dict[str, TunicItemData] = {
"Ladders in Library": TunicItemData(IC.progression, 0, 148, "Ladders"),
"Ladders in Lower Quarry": TunicItemData(IC.progression, 0, 149, "Ladders"),
"Ladders in Swamp": TunicItemData(IC.progression, 0, 150, "Ladders"),
"Grass": TunicItemData(IC.filler, 0, 151),
}
# items to be replaced by fool traps
@@ -214,7 +215,7 @@ combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's La
item_name_to_id: Dict[str, int] = {name: item_base_id + data.item_id_offset for name, data in item_table.items()}
filler_items: List[str] = [name for name, data in item_table.items() if data.classification == IC.filler]
filler_items: List[str] = [name for name, data in item_table.items() if data.classification == IC.filler and name != "Grass"]
def get_item_group(item_name: str) -> str:

View File

@@ -1,4 +1,5 @@
from typing import Dict, NamedTuple, Set, Optional
from typing import Dict, NamedTuple, Set, Optional, List
from .grass import grass_location_table
class TunicLocationData(NamedTuple):
@@ -320,7 +321,27 @@ hexagon_locations: Dict[str, str] = {
"Blue Questagon": "Rooted Ziggurat Lower - Hexagon Blue",
}
location_name_to_id: Dict[str, int] = {name: location_base_id + index for index, name in enumerate(location_table)}
sphere_one: List[str] = [
"Overworld - [Central] Chest Across From Well",
"Overworld - [Northwest] Chest Near Quarry Gate",
"Overworld - [Northwest] Shadowy Corner Chest",
"Overworld - [Southwest] Chest Guarded By Turret",
"Overworld - [Southwest] South Chest Near Guard",
"Overworld - [Southwest] Obscured in Tunnel to Beach",
"Overworld - [Northwest] Chest Near Turret",
"Overworld - [Northwest] Page By Well",
"Overworld - [West] Chest Behind Moss Wall",
"Overworld - [Southwest] Key Pickup",
"Overworld - [West] Key Pickup",
"Overworld - [West] Obscured Behind Windmill",
"Overworld - [West] Obscured Near Well",
"Overworld - [West] Page On Teleporter"
]
standard_location_name_to_id: Dict[str, int] = {name: location_base_id + index for index, name in enumerate(location_table)}
all_locations = location_table.copy()
all_locations.update(grass_location_table)
location_name_groups: Dict[str, Set[str]] = {}
for loc_name, loc_data in location_table.items():

View File

@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Dict, Any
from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections,
PerGameCommonOptions, OptionGroup, Visibility)
PerGameCommonOptions, OptionGroup, Visibility, NamedRange)
from .er_data import portal_mapping
@@ -154,6 +154,33 @@ class ShuffleLadders(Toggle):
display_name = "Shuffle Ladders"
class GrassRandomizer(Toggle):
"""
Turns over 6,000 blades of grass and bushes in the game into checks.
"""
internal_name = "grass_randomizer"
display_name = "Grass Randomizer"
class LocalFill(NamedRange):
"""
Choose the percentage of your filler/trap items that will be kept local or distributed to other TUNIC players with this option enabled.
If you have Grass Randomizer enabled, this option must be set to 95% or higher to avoid flooding the item pool. The host can remove this restriction by turning off the limit_grass_rando setting in host.yaml.
This option defaults to 95% if you have Grass Randomizer enabled, and to 0% otherwise.
This option ignores items placed in your local_items or non_local_items.
This option does nothing in single player games.
"""
internal_name = "local_fill"
display_name = "Local Fill Percent"
range_start = 0
range_end = 100
special_range_names = {
"default": -1
}
default = -1
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
class TunicPlandoConnections(PlandoConnections):
"""
Generic connection plando. Format is:
@@ -278,12 +305,13 @@ class TunicOptions(PerGameCommonOptions):
combat_logic: CombatLogic
lanternless: Lanternless
maskless: Maskless
grass_randomizer: GrassRandomizer
local_fill: LocalFill
laurels_zips: LaurelsZips
ice_grappling: IceGrappling
ladder_storage: LadderStorage
ladder_storage_without_items: LadderStorageWithoutItems
plando_connections: TunicPlandoConnections
logic_rules: LogicRules