Compare commits

..

11 Commits

Author SHA1 Message Date
NewSoupVi
2404e18c03 Add Glass Factory Entry Panel as a location 2025-02-27 01:22:40 +01:00
Scipio Wright
6dc461609b Noita: Fix bug with Traps disabled in 1-player games #4651 2025-02-23 17:27:05 +01:00
threeandthreee
58d460678e LADX: drop rupee farm condition (#4189)
* drop rupee farm condition

* cleanup

* rupee farm backup for all spending checks

* not power bracelet

* oops
2025-02-23 17:11:24 +01:00
Scipio Wright
0f7fd48cdd TUNIC: Add some more rules for Monastery connections (#4564)
* Move a couple locations to monastery

* Connect Quarry Back to Monastery

* Quarry Back -> Monastery with laurels, Monastery -> Monastery Back with wand/sword

* Add Monastery Back region

* Move a couple non-ER locations to monastery back

* Monastery front -> back with sword, wand, or laurels zip

* also laurels zip for non-ER
2025-02-23 17:02:30 +01:00
Natalie Weizenbaum
18de035b4d DS3: Update setup documentation (#4437) 2025-02-22 08:33:58 -05:00
Fabian Dill
11fa43f0a4 Factorio: prevent players from getting stuck from Teleport Traps (#4537) 2025-02-20 00:17:19 +01:00
black-sliver
91a8fc91d6 CI: fix native tests toolchain on windows (#4668)
* CI: ctest: fix trigger on CMakeLists change

* CI: ctest: update cmake version

this removes a warning
and matches gtest

* CI: ctest: remove explicit build mode for MSVC

gtest switched to dynamic libc (/MD), which is default, so this just works now
2025-02-19 13:50:25 +01:00
Fabian Dill
15bde56551 Factorio: prevent invalid starting items count (#4658) 2025-02-17 18:58:38 +01:00
NewSoupVi
d744e086ef MultiServer: Fix hinting an item that someone else already hinted in their slot not resolving correctly (#4655)
* Fix get_hint not checking for finding_player

* Fix using the wrong variable for slot lookup
2025-02-17 15:16:18 +01:00
Scipio Wright
378fa5d5c4 Fix gun missing from combat_items, add new for combat logic cache, very slight refactor of check_combat_reqs to let it do the changeover in a less complicated fashion, fix area being a boss area rather than non-boss area for a check (#4657) 2025-02-17 01:30:40 +01:00
black-sliver
8349774c5c customserver: ignore static datapackage optimization for old games (#4650) 2025-02-16 23:51:36 +01:00
19 changed files with 229 additions and 60 deletions

View File

@@ -11,7 +11,7 @@ on:
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
pull_request:
paths:
@@ -21,7 +21,7 @@ on:
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
jobs:

View File

@@ -117,6 +117,7 @@ class WebHostContext(Context):
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
missing_checksum = False
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
@@ -132,11 +133,13 @@ class WebHostContext(Context):
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages:
if not game_data_packages and not missing_checksum:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups

View File

@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.5)
cmake_minimum_required(VERSION 3.16)
project(ap-cpp-tests)
enable_testing()
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
add_definitions("/source-charset:utf-8")
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
# set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
# set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# enable static analysis for gcc
add_compile_options(-fanalyzer -Werror)

View File

@@ -3,11 +3,13 @@
## Required Software
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
- [Dark Souls III AP Client](https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest)
- [Dark Souls III AP Client]
[Dark Souls III AP Client]: https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest
## Optional Software
- Map tracker not yet updated for 3.0.0
- [Map tracker](https://github.com/TVV1GK/DS3_AP_Maptracker)
## Setting Up
@@ -73,3 +75,65 @@ things to keep in mind:
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
[WINE]: https://www.winehq.org/
## Troubleshooting
### Enemy randomizer issues
The DS3 Archipelago randomizer uses [thefifthmatt's DS3 enemy randomizer],
essentially unchanged. Unfortunately, this randomizer has a few known issues,
including enemy AI not working, enemies spawning in places they can't be killed,
and, in a few rare cases, enemies spawning in ways that crash the game when they
load. These bugs should be [reported upstream], but unfortunately the
Archipelago devs can't help much with them.
[thefifthmatt's DS3 enemy randomizer]: https://www.nexusmods.com/darksouls3/mods/484
[reported upstream]: https://github.com/thefifthmatt/SoulsRandomizers/issues
Because in rare cases the enemy randomizer can cause seeds to be impossible to
complete, we recommend disabling it for large async multiworlds for safety
purposes.
### `launchmod_darksouls3.bat` isn't working
Sometimes `launchmod_darksouls3.bat` will briefly flash a terminal on your
screen and then terminate without actually starting the game. This is usually
caused by some issue communicating with Steam either to find `DarkSoulsIII.exe`
or to launch it properly. If this is happening to you, make sure:
* You have DS3 1.15.2 installed. This is the latest patch as of January 2025.
(Note that older versions of Archipelago required an older patch, but that
_will not work_ with the current version.)
* You own the DS3 DLC if your randomizer config has DLC enabled. (It's possible,
but unconfirmed, that you need the DLC even when it's disabled in your config).
* Steam is not running in administrator mode. To fix this, right-click
`steam.exe` (by default this is in `C:\Program Files\Steam`), select
"Properties", open the "Compatiblity" tab, and uncheck "Run this program as an
administrator".
* There is no `dinput8.dll` file in your DS3 game directory. This is the old way
of installing mods, and it can interfere with the new ModEngine2 workflow.
If you've checked all of these, you can also try:
* Running `launchmod_darksouls3.bat` as an administrator.
* Reinstalling DS3 or even reinstalling Steam itself.
* Making sure DS3 is installed on the same drive as Steam and as the randomizer.
(A number of users are able to run these on different drives, but this has
helped some users.)
If none of this works, unfortunately there's not much we can do. We use
ModEngine2 to launch DS3 with the Archipelago mod enabled, but unfortunately
it's no longer maintained and its successor, ModEngine3, isn't usable yet.
### `DS3Randomizer.exe` isn't working
This is almost always caused by using a version of the randomizer client that's
not compatible with the version used to generate the multiworld. If you're
generating your multiworld on archipelago.gg, you *must* use the latest [Dark
Souls III AP Client]. If you want to use a different client version, you *must*
generate the multiworld locally using the apworld bundled with the client.

View File

@@ -235,6 +235,12 @@ class FactorioStartItems(OptionDict):
"""Mapping of Factorio internal item-name to amount granted on start."""
display_name = "Starting Items"
default = {"burner-mining-drill": 4, "stone-furnace": 4, "raw-fish": 50}
schema = Schema(
{
str: And(int, lambda n: n > 0,
error="amount of starting items has to be a positive integer"),
}
)
class FactorioFreeSampleBlacklist(OptionSet):
@@ -257,7 +263,8 @@ class AttackTrapCount(TrapCount):
class TeleportTrapCount(TrapCount):
"""Trap items that when received trigger a random teleport."""
"""Trap items that when received trigger a random teleport.
It is ensured the player can walk back to where they got teleported from."""
display_name = "Teleport Traps"

View File

@@ -49,6 +49,73 @@ function fire_entity_at_entities(entity_name, entities, speed)
end
end
local teleport_requests = {}
local teleport_attempts = {}
local max_attempts = 100
function attempt_teleport_player(player, attempt)
-- global attempt storage as metadata can't be stored
if attempt == nil then
attempt = teleport_attempts[player.index]
else
teleport_attempts[player.index] = attempt
end
if attempt > max_attempts then
player.print("Teleport failed: No valid position found after " .. max_attempts .. " attempts!")
teleport_attempts[player.index] = 0
return
end
local surface = player.character.surface
local prototype_name = player.character.prototype.name
local original_position = player.character.position
local candidate_position = random_offset_position(original_position, 1024)
local non_colliding_position = surface.find_non_colliding_position(
prototype_name, candidate_position, 0, 1
)
if non_colliding_position then
-- Request pathfinding asynchronously
local path_id = surface.request_path{
bounding_box = player.character.prototype.collision_box,
collision_mask = { layers = { ["player"] = true } },
start = original_position,
goal = non_colliding_position,
force = player.force.name,
radius = 1,
pathfind_flags = {cache = true, low_priority = true, allow_paths_through_own_entities = true},
}
-- Store the request with the player index as the key
teleport_requests[player.index] = path_id
else
attempt_teleport_player(player, attempt + 1)
end
end
function handle_teleport_attempt(event)
for player_index, path_id in pairs(teleport_requests) do
-- Check if the event matches the stored path_id
if path_id == event.id then
local player = game.players[player_index]
if event.path then
if player.character then
player.character.teleport(event.path[#event.path].position) -- Teleport to the last point in the path
-- Clear the attempts for this player
teleport_attempts[player_index] = 0
return
end
return
end
attempt_teleport_player(player, nil)
break
end
end
end
function spill_character_inventory(character)
if not (character and character.valid) then
return false

View File

@@ -134,6 +134,9 @@ end
script.on_event(defines.events.on_player_changed_position, on_player_changed_position)
{% endif %}
-- Handle the pathfinding result of teleport traps
script.on_event(defines.events.on_script_path_request_finished, handle_teleport_attempt)
function count_energy_bridges()
local count = 0
for i, bridge in pairs(storage.energy_link_bridges) do
@@ -143,9 +146,11 @@ function count_energy_bridges()
end
return count
end
function get_energy_increment(bridge)
return ENERGY_INCREMENT + (ENERGY_INCREMENT * 0.3 * bridge.quality.level)
end
function on_check_energy_link(event)
--- assuming 1 MJ increment and 5MJ battery:
--- first 2 MJ request fill, last 2 MJ push energy, middle 1 MJ does nothing
@@ -722,12 +727,10 @@ end,
game.forces["enemy"].set_evolution_factor(new_factor, "nauvis")
game.print({"", "New evolution factor:", new_factor})
end,
["Teleport Trap"] = function ()
["Teleport Trap"] = function()
for _, player in ipairs(game.forces["player"].players) do
current_character = player.character
if current_character ~= nil then
current_character.teleport(current_character.surface.find_non_colliding_position(
current_character.prototype.name, random_offset_position(current_character.position, 1024), 0, 1))
if player.character then
attempt_teleport_player(player, 1)
end
end
end,

View File

@@ -11,7 +11,7 @@ class World:
mabe_village = Location("Mabe Village")
Location().add(HeartPiece(0x2A4)).connect(mabe_village, r.bush) # well
Location().add(FishingMinigame()).connect(mabe_village, AND(r.bush, COUNT("RUPEES", 20))) # fishing game, heart piece is directly done by the minigame.
Location().add(FishingMinigame()).connect(mabe_village, AND(r.can_farm, COUNT("RUPEES", 20))) # fishing game, heart piece is directly done by the minigame.
Location().add(Seashell(0x0A3)).connect(mabe_village, r.bush) # bushes below the shop
Location().add(Seashell(0x0D2)).connect(mabe_village, PEGASUS_BOOTS) # smash into tree next to lv1
Location().add(Song(0x092)).connect(mabe_village, OCARINA) # Marins song
@@ -23,7 +23,7 @@ class World:
papahl_house.connect(mamasha_trade, TRADING_ITEM_YOSHI_DOLL)
trendy_shop = Location("Trendy Shop")
trendy_shop.connect(Location().add(TradeSequenceItem(0x2A0, TRADING_ITEM_YOSHI_DOLL)), FOUND("RUPEES", 50))
trendy_shop.connect(Location().add(TradeSequenceItem(0x2A0, TRADING_ITEM_YOSHI_DOLL)), AND(r.can_farm, FOUND("RUPEES", 50)))
outside_trendy = Location()
outside_trendy.connect(mabe_village, r.bush)
@@ -43,8 +43,8 @@ class World:
self._addEntrance("start_house", mabe_village, start_house, None)
shop = Location("Shop")
Location().add(ShopItem(0)).connect(shop, OR(COUNT("RUPEES", 500), SWORD))
Location().add(ShopItem(1)).connect(shop, OR(COUNT("RUPEES", 1480), SWORD))
Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD))
Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD))
self._addEntrance("shop", mabe_village, shop, None)
dream_hut = Location("Dream Hut")
@@ -164,7 +164,7 @@ class World:
self._addEntrance("prairie_left_cave2", ukuku_prairie, prairie_left_cave2, BOMB)
self._addEntranceRequirementExit("prairie_left_cave2", None) # if exiting, you do not need bombs
mamu = Location().connect(Location().add(Song(0x2FB)), AND(OCARINA, COUNT("RUPEES", 1480)))
mamu = Location().connect(Location().add(Song(0x2FB)), AND(OCARINA, r.can_farm, COUNT("RUPEES", 1480)))
self._addEntrance("mamu", ukuku_prairie, mamu, AND(OR(AND(FEATHER, PEGASUS_BOOTS), ROOSTER), OR(HOOKSHOT, ROOSTER), POWER_BRACELET))
dungeon3_entrance = Location().connect(ukuku_prairie, OR(FEATHER, ROOSTER, FLIPPERS))
@@ -377,7 +377,7 @@ class World:
# Raft game.
raft_house = Location("Raft House")
Location().add(KeyLocation("RAFT")).connect(raft_house, AND(r.bush, COUNT("RUPEES", 100))) # add bush requirement for farming in case player has to try again
Location().add(KeyLocation("RAFT")).connect(raft_house, AND(r.can_farm, COUNT("RUPEES", 100)))
raft_return_upper = Location()
raft_return_lower = Location().connect(raft_return_upper, None, one_way=True)
outside_raft_house = Location().connect(below_right_taltal, HOOKSHOT).connect(below_right_taltal, FLIPPERS, one_way=True)

View File

@@ -253,6 +253,7 @@ def isConsumable(item) -> bool:
class RequirementsSettings:
def __init__(self, options):
self.can_farm = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, BOOMERANG, BOMB, HOOKSHOT, BOW)
self.bush = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, POWER_BRACELET, BOOMERANG, BOMB)
self.pit_bush = OR(SWORD, MAGIC_POWDER, MAGIC_ROD, BOOMERANG, BOMB) # unique
self.attack = OR(SWORD, BOMB, BOW, MAGIC_ROD, BOOMERANG)

View File

@@ -110,15 +110,6 @@ class LinksAwakeningLocation(Location):
add_item_rule(self, filter_item)
def has_free_weapon(state: CollectionState, player: int) -> bool:
return state.has("Progressive Sword", player) or state.has("Magic Rod", player) or state.has("Boomerang", player) or state.has("Hookshot", player)
# If the player has access to farm enough rupees to afford a game, we assume that they can keep beating the game
def can_farm_rupees(state: CollectionState, player: int) -> bool:
return has_free_weapon(state, player) and (state.has("Can Play Trendy Game", player=player) or state.has("RAFT", player=player))
class LinksAwakeningRegion(Region):
dungeon_index = None
ladxr_region = None
@@ -154,9 +145,7 @@ class GameStateAdapater:
def get(self, item, default):
# Don't allow any money usage if you can't get back wasted rupees
if item == "RUPEES":
if can_farm_rupees(self.state, self.player):
return self.state.prog_items[self.player]["RUPEES"]
return 0
return self.state.prog_items[self.player]["RUPEES"]
elif item.endswith("_USED"):
return 0
else:

View File

@@ -52,8 +52,8 @@ def create_kantele(victory_condition: VictoryCondition) -> List[str]:
def create_random_items(world: NoitaWorld, weights: Dict[str, int], count: int) -> List[str]:
filler_pool = weights.copy()
if not world.options.bad_effects:
del filler_pool["Trap"]
del filler_pool["Greed Die"]
filler_pool["Trap"] = 0
filler_pool["Greed Die"] = 0
return world.random.choices(population=list(filler_pool.keys()),
weights=list(filler_pool.values()),

View File

@@ -140,24 +140,14 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d
# need sword for bosses
if data.is_boss:
return False
equipment.remove("Sword")
if has_magic:
if "Magic" not in equipment:
equipment.append("Magic")
# +4 mp pretty much makes up for the lack of sword, at least in Quarry
extra_mp_needed += 4
if stick_bool:
# stick is a backup plan, and doesn't scale well, so let's require a little less
equipment.append("Stick")
extra_att_needed -= 2
else:
extra_mp_needed += 2
extra_att_needed -= 32
elif stick_bool:
if stick_bool:
equipment.remove("Sword")
equipment.append("Stick")
# may revise this later based on feedback
extra_att_needed += 3
extra_def_needed += 2
# this is for when it changes over to the magic-only state if it needs to later
extra_mp_needed += 4
else:
return False
@@ -204,7 +194,7 @@ def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_d
equip_list.append("Magic")
more_modified_stats = AreaStats(modified_stats.att_level - 32, modified_stats.def_level,
modified_stats.potion_level, modified_stats.hp_level,
modified_stats.sp_level, modified_stats.mp_level + 4,
modified_stats.sp_level, modified_stats.mp_level + 2,
modified_stats.potion_count, equip_list, data.is_boss)
if check_combat_reqs("none", state, player, more_modified_stats):
return True
@@ -222,7 +212,7 @@ def has_required_stats(data: AreaStats, state: CollectionState, player: int) ->
player_att, att_offerings = get_att_level(state, player)
# if you have 2 more attack than needed, we can forego needing mp
if data.mp_level > 1:
if data.mp_level > 1 and "Magic" in data.equipment:
if player_att < data.att_level + 2:
player_mp, mp_offerings = get_mp_level(state, player)
if player_mp < data.mp_level:

View File

@@ -990,7 +990,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_hard, state, world))
monastery_front_to_back = regions["Monastery Front"].connect(
connecting_region=regions["Monastery Back"])
connecting_region=regions["Monastery Back"],
rule=lambda state: has_sword(state, player) or state.has(fire_wand, player)
or laurels_zip(state, world))
# laurels through the gate, no setup needed
regions["Monastery Back"].connect(
connecting_region=regions["Monastery Front"],
@@ -1832,7 +1834,7 @@ def set_er_location_rules(world: "TunicWorld") -> None:
if world.options.combat_logic == CombatLogic.option_on:
combat_logic_to_loc("Overworld - [Northeast] Flowers Holy Cross", "Garden Knight")
combat_logic_to_loc("Overworld - [Northwest] Chest Near Quarry Gate", "Before Well", dagger=True)
combat_logic_to_loc("Overworld - [Northeast] Chest Above Patrol Cave", "Garden Knight", dagger=True)
combat_logic_to_loc("Overworld - [Northeast] Chest Above Patrol Cave", "West Garden", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret", "Overworld", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret 2", "Overworld")
combat_logic_to_loc("Overworld - [Southwest] Bombable Wall Near Fountain", "Before Well", dagger=True)

View File

@@ -212,7 +212,7 @@ slot_data_item_names = [
combat_items: List[str] = [name for name, data in item_table.items()
if data.combat_ic and IC.progression in data.combat_ic]
combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's Laurels"])
combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's Laurels", "Gun"])
item_name_to_id: Dict[str, int] = {name: item_base_id + data.item_id_offset for name, data in item_table.items()}

View File

@@ -206,7 +206,7 @@ location_table: Dict[str, TunicLocationData] = {
"Fountain Cross Door - Page Pickup": TunicLocationData("Overworld Holy Cross", "Fountain Cross Room", location_group="Holy Cross"),
"Secret Gathering Place - Holy Cross Chest": TunicLocationData("Overworld Holy Cross", "Secret Gathering Place", location_group="Holy Cross"),
"Top of the Mountain - Page At The Peak": TunicLocationData("Overworld Holy Cross", "Top of the Mountain", location_group="Holy Cross"),
"Monastery - Monastery Chest": TunicLocationData("Monastery", "Monastery Back"),
"Monastery - Monastery Chest": TunicLocationData("Monastery Back", "Monastery Back"),
"Quarry - [Back Entrance] Bushes Holy Cross": TunicLocationData("Quarry Back", "Quarry Back", location_group="Holy Cross"),
"Quarry - [Back Entrance] Chest": TunicLocationData("Quarry Back", "Quarry Back"),
"Quarry - [Central] Near Shortcut Ladder": TunicLocationData("Quarry Back", "Quarry Back"),
@@ -220,12 +220,12 @@ location_table: Dict[str, TunicLocationData] = {
"Quarry - [Central] Obscured Below Entry Walkway": TunicLocationData("Quarry Back", "Quarry Back"),
"Quarry - [Central] Top Floor Overhang": TunicLocationData("Quarry", "Quarry"),
"Quarry - [East] Near Bridge": TunicLocationData("Quarry", "Quarry"),
"Quarry - [Central] Above Ladder": TunicLocationData("Quarry", "Quarry Monastery Entry"),
"Quarry - [Central] Above Ladder": TunicLocationData("Monastery", "Quarry Monastery Entry"),
"Quarry - [Central] Obscured Behind Staircase": TunicLocationData("Quarry", "Quarry"),
"Quarry - [Central] Above Ladder Dash Chest": TunicLocationData("Quarry", "Quarry Monastery Entry"),
"Quarry - [Central] Above Ladder Dash Chest": TunicLocationData("Monastery", "Quarry Monastery Entry"),
"Quarry - [West] Upper Area Bombable Wall": TunicLocationData("Quarry Back", "Quarry Back"),
"Quarry - [East] Bombable Wall": TunicLocationData("Quarry", "Quarry"),
"Hero's Grave - Ash Relic": TunicLocationData("Monastery", "Hero Relic - Quarry"),
"Hero's Grave - Ash Relic": TunicLocationData("Monastery Back", "Hero Relic - Quarry"),
"Quarry - [West] Shooting Range Secret Path": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Quarry - [West] Near Shooting Range": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Quarry - [West] Below Shooting Range": TunicLocationData("Lower Quarry", "Lower Quarry"),

View File

@@ -13,9 +13,10 @@ tunic_regions: dict[str, tuple[str]] = {
"Library": tuple(),
"Eastern Vault Fortress": ("Beneath the Vault",),
"Beneath the Vault": ("Eastern Vault Fortress",),
"Quarry Back": ("Quarry",),
"Quarry Back": ("Quarry", "Monastery"),
"Quarry": ("Monastery", "Lower Quarry"),
"Monastery": tuple(),
"Monastery": ("Monastery Back",),
"Monastery Back": tuple(),
"Lower Quarry": ("Rooted Ziggurat",),
"Rooted Ziggurat": tuple(),
"Swamp": ("Cathedral",),

View File

@@ -124,6 +124,11 @@ def set_region_rules(world: "TunicWorld") -> None:
and (state.has_any({grapple, laurels, gun}, player) or can_ladder_storage(state, world))
world.get_entrance("Quarry Back -> Quarry").access_rule = \
lambda state: has_sword(state, player) or state.has(fire_wand, player)
world.get_entrance("Quarry Back -> Monastery").access_rule = \
lambda state: state.has(laurels, player)
world.get_entrance("Monastery -> Monastery Back").access_rule = \
lambda state: (has_sword(state, player) or state.has(fire_wand, player)
or laurels_zip(state, world))
world.get_entrance("Quarry -> Lower Quarry").access_rule = \
lambda state: has_mask(state, world)
world.get_entrance("Lower Quarry -> Rooted Ziggurat").access_rule = \

View File

@@ -4,7 +4,7 @@ from collections import Counter
from . import TunicTestBase
from .. import options
from ..combat_logic import (check_combat_reqs, area_data, get_money_count, calc_effective_hp, get_potion_level,
get_hp_level, get_def_level, get_sp_level)
get_hp_level, get_def_level, get_sp_level, has_combat_reqs)
from ..items import item_table
from .. import TunicWorld
@@ -81,3 +81,39 @@ class TestCombat(TunicTestBase):
f"Free Def and Offerings: {player_def - def_offerings}, {def_offerings}\n"
f"Free SP and Offerings: {player_sp - sp_offerings}, {sp_offerings}")
prev_statuses[area] = curr_statuses[area]
# the issue was that a direct check of the logic and the cache had different results
# it was actually due to the combat_items in items.py not having the Gun in it
# but this test is still helpful for verifying the cache
def test_combat_magic_weapons(self):
combat_items = self.combat_items.copy()
combat_items.remove("Magic Wand")
combat_items.remove("Gun")
area_names = list(area_data.keys())
self.multiworld.worlds[1].random.shuffle(combat_items)
self.multiworld.worlds[1].random.shuffle(area_names)
current_items = Counter()
state = self.multiworld.state.copy()
player = self.player
gun = TunicWorld.create_item(self.world, "Gun")
for current_item_name in combat_items:
current_item = TunicWorld.create_item(self.world, current_item_name)
state.collect(current_item)
current_items[current_item_name] += 1
for area in area_names:
if check_combat_reqs(area, state, player) != has_combat_reqs(area, state, player):
raise Exception(f"Cache for {area} does not match a direct check "
f"after collecting {current_item_name}.\n"
f"Current items: {current_items}.\n"
f"Cache {'succeeded' if has_combat_reqs(area, state, player) else 'failed'}\n"
f"Direct {'succeeded' if check_combat_reqs(area, state, player) else 'failed'}")
state.collect(gun)
for area in area_names:
if check_combat_reqs(area, state, player) != has_combat_reqs(area, state, player):
raise Exception(f"Cache for {area} does not match a direct check "
f"after collecting the Gun.\n"
f"Current items: {current_items}.\n"
f"Cache {'succeeded' if has_combat_reqs(area, state, player) else 'failed'}\n"
f"Direct {'succeeded' if check_combat_reqs(area, state, player) else 'failed'}")
state.remove(gun)

View File

@@ -18,6 +18,7 @@ GENERAL_LOCATIONS = {
"Outside Tutorial Outpost Entry Panel",
"Outside Tutorial Outpost Exit Panel",
"Glass Factory Entry Panel",
"Glass Factory Discard",
"Glass Factory Back Wall 5",
"Glass Factory Front 3",