From b0ae2fd76602b77bfabfc32f409c3b557da36bc6 Mon Sep 17 00:00:00 2001 From: prabu-openclaw Date: Thu, 23 Jul 2026 20:21:06 -0500 Subject: [PATCH] fix: device-pass fixes for replay modes, room transitions, and settings Findings from a device playtest (2026-07-23), plus the test layer that would have caught the worst of them. Extraction resolved only on an explicit tap of the pad, or at the end of an enemy phase -- and once the last enemy is dead there is no enemy phase. Walking the final runner onto the pad produced the objective pulse and nothing else, with no message, because every rejection path lives inside requestExtraction and that was never reached. Side contracts were therefore uncompletable. adjudicateExtractionIfEligible now runs on the move commit alongside the terminal/grimoire step-on handlers; it is internally guarded and idempotent. Backtracking into a cleared room sent the squad to the room's ORIGINAL entry point, at the far end from the door they actually came through -- M1 room_2 -> room_1 landed them at the top when the connection authors the bottom. RoomManager now uses the connection's targetSpawn in both directions, which is what BattleScene's own transition comment already specified; the two had disagreed. A barrier dropped by removeOnFirstKill is painted into the room BACKGROUND art, and re-entry reloads that art pristine, so the wall came back visually while staying walk-through. The pixel patch is now replayed on room load. Replay modes ran the campaign intro AND outro VNs -- story beats for a mission the player never started. Both are skipped for gauntlet floors and side contracts. Also: the tutorial-tips replay button was a one-way door (no inverse for resetAll, and .disabled locked it afterward), and it lied about its state after reopening Settings because it tracked @State rather than the stored flags. TEAM was on both the main screen and the SELECT RUN bar wired to the same action; the main-screen copy is gone. New: backtrack patrols. Re-entering a cleared room has a 35% chance of 1-2 of the cheapest authored types, seeded on (attempt, room, visit) so a visit cannot be re-rolled by walking out and back, capped one per room per attempt, never in boss rooms. Doors re-lock until the patrol dies, matching the ReinforcementService contract. Tests: 113 -> 126. StepOnSemanticsTests certifies the player-input path. WP6's mission certification drives authority directly (applyRoomEntry, requestExtractionResolution), so the tap/move -> intent layer had no coverage at all -- which is exactly why contracts could be broken with the whole suite green. These enter through moveCharacter instead. ReplayModeCertificationTests gives gauntlet, contracts and arenas the WP6 treatment they never got (MissionCertificationMatrix.md mentions them zero times): all 20 arenas driven through the real contract path via seed sweep, all 3 tiers, 8 gauntlet floors -- every run finished by walking onto the pad. Both suites are mutation-checked: disabling the extraction fix fails 4 tests in the first and 3 in the second. Not yet verified on device. Co-Authored-By: Claude Opus 4.8 --- Game/CombatFlowController.swift | 13 + Game/MissionSetupService.swift | 78 ++++++ HexWire.xcodeproj/project.pbxproj | 8 + HexwireApp.swift | 40 ++- Missions/RoomManager.swift | 64 +++-- Rendering/BattleScene.swift | 72 +++++- UI/SettingsView.swift | 17 +- UI/TutorialCoach.swift | 17 ++ tests/ContractBoardTests.swift | 86 +++++++ tests/ReplayModeCertificationTests.swift | 307 +++++++++++++++++++++++ tests/ReplaySpawnTests.swift | 96 +++++++ tests/StepOnSemanticsTests.swift | 298 ++++++++++++++++++++++ 12 files changed, 1057 insertions(+), 39 deletions(-) create mode 100644 tests/ReplayModeCertificationTests.swift create mode 100644 tests/StepOnSemanticsTests.swift diff --git a/Game/CombatFlowController.swift b/Game/CombatFlowController.swift index 9bda12b..183afdb 100644 --- a/Game/CombatFlowController.swift +++ b/Game/CombatFlowController.swift @@ -1015,6 +1015,19 @@ struct CombatFlowController { } checkDataTerminalPickup(gameState: gameState, atX: tileX, y: tileY, by: char) checkGrimoirePickup(gameState: gameState, atX: tileX, y: tileY, by: char) + // STEPPING ONTO EXTRACTION RESOLVES IT. Previously the pad only + // adjudicated on an explicit tap, or at the end of an enemy phase — + // and once the last enemy is dead there IS no enemy phase. A player who + // walked the final runner onto the pad (rather than tapping it) got the + // objective pulse above and then nothing at all: no extraction, no + // message, since every messaged rejection lives inside requestExtraction + // and that was never reached. Data terminals already get a step-on + // handler one line up; extraction deserves the same. + // adjudicate* is internally guarded (enemies clear, pendingSpawns empty, + // extraction active, data satisfied, runner actually on the pad) and is + // idempotent via extractionAnimationInProgress, so this is safe to call + // on every move commit. + adjudicateExtractionIfEligible(gameState: gameState) // Movement consumes the character's action choice, but does not auto-advance. // Keep player input open so the player can explicitly end early via END. } diff --git a/Game/MissionSetupService.swift b/Game/MissionSetupService.swift index 579edf5..8468cb5 100644 --- a/Game/MissionSetupService.swift +++ b/Game/MissionSetupService.swift @@ -336,10 +336,21 @@ struct MissionSetupService { gameState.addLog(gameState.generateCombinedPressurePreview()) gameState.addLog(gameState.generateMissionBriefing()) + // A room can declare extraction EITHER as an explicit extractionPoint + // OR as an extraction tile painted into its map — `roomHasExtraction` + // accepts both, so this must too. Honouring only extractionPoint left + // tile-based rooms (the procedural contract/arena rooms) rendering a + // labelled extraction pad whose coords were the off-map sentinel: + // isExtractionActive() passed, the player stood on the pad, and + // nothing ever fired because (-1,-1) matched no one. if let _ = firstRoom.extractionPoint { gameState.extractionX = firstRoomExtraction.x gameState.extractionY = firstRoomExtraction.y gameState.addLog("🚁 Extraction marker active — reach it when all rooms are clear!") + } else if let pad = firstExtractionTile(in: firstRoom.map) { + gameState.extractionX = pad.x + gameState.extractionY = pad.y + gameState.addLog("🚁 Extraction marker active — reach it when all rooms are clear!") } else { // CRITICAL: reset extraction coords to a SAFE OFF-MAP sentinel // when the first room has no extraction point. Without this, @@ -382,6 +393,17 @@ struct MissionSetupService { gameState.currentMissionTiles = adjusted } + /// First extraction tile painted into a room's map, scanning top-left to + /// bottom-right so the choice is deterministic across runs. + private static func firstExtractionTile(in map: [[Int]]) -> (x: Int, y: Int)? { + for (y, row) in map.enumerated() { + if let x = row.firstIndex(of: TileType.extraction.rawValue) { + return (x: x, y: y) + } + } + return nil + } + private static func tilesWithoutDataTerminals(_ tiles: [[Int]]) -> [[Int]] { tiles.map { row in row.map { tile in @@ -759,6 +781,62 @@ struct MissionSetupService { /// Seeded by (missionAttemptId, room id): re-entering an uncleared room /// in the same attempt rebuilds the SAME squad (no reroll-scumming by /// door-flapping), while every new attempt rolls fresh. + /// Odds that re-entering an already-cleared room turns up a patrol. + private static let backtrackPatrolChance = 0.35 + + /// A small patrol that has wandered into a room the player already cleared. + /// Empty most of the time — backtracking is usually meant to be a quick + /// errand, and a guaranteed fight would make returning for a missed + /// objective a chore rather than a risk. + /// + /// Seeded on (attempt, room, visit index) so the roll is fixed for a given + /// visit: stepping out and back in cannot re-roll it. Capped at one patrol + /// per room per attempt by `patrolRespawnedRoomIds`, so a corridor between + /// two objectives can't be farmed. + /// + /// Squad is 1–2 of the CHEAPEST types authored in this mission, placed on + /// the room's own authored spawn tiles (known-good hexes) farthest from + /// where the player walked in — never on top of the party. + static func backtrackPatrol(for room: Room, + gameState: GameState, + entryX: Int, + entryY: Int) -> [EnemySpawn] { + guard !RoomManager.shared.patrolRespawnedRoomIds.contains(room.id) else { return [] } + guard !room.enemies.isEmpty else { return [] } // nothing authored to draw from + guard room.bossSpawn == nil else { return [] } // never re-populate a boss arena + + var roomHash: UInt64 = 5381 + for b in room.id.utf8 { roomHash = (roomHash << 5) &+ roomHash &+ UInt64(b) } + let visit = UInt64(RoomManager.shared.reentryCount(room.id)) + var rng = SquadRNG(seed: UInt64(bitPattern: Int64(gameState.missionAttemptId)) + &* 0x9E37_79B9_7F4A_7C15 + &+ roomHash + &+ visit &* 0x1000_0000_0000_0193) + + guard Double(rng.next() % 1000) / 1000.0 < backtrackPatrolChance else { return [] } + + // Cheapest authored types keep a wandering patrol from out-punching the + // squad that originally held the room. + let pool = room.enemies + .map(\.type) + .filter { spawnCost[$0] != nil } + .sorted { (spawnCost[$0] ?? 0, $0) < (spawnCost[$1] ?? 0, $1) } + guard let cheapest = pool.first else { return [] } + let count = (rng.next() % 2 == 0) ? 1 : 2 + + // Farthest authored tiles from the door the player just came through. + let tiles = room.enemies + .map { (x: $0.x, y: $0.y) } + .sorted { + let a = CombatMechanics.hexDistance(x1: entryX, y1: entryY, x2: $0.x, y2: $0.y) + let b = CombatMechanics.hexDistance(x1: entryX, y1: entryY, x2: $1.x, y2: $1.y) + return a == b ? ($0.x, $0.y) < ($1.x, $1.y) : a > b + } + .prefix(count) + + return tiles.map { EnemySpawn(type: cheapest, x: $0.x, y: $0.y, delay: 0) } + } + static func replaySquad(for room: Room, gameState: GameState) -> [EnemySpawn] { let missionId = gameState.currentMissionDisplayId ?? "" let isReplay = MissionStatsStore.shared.record(for: missionId).attempts > 0 diff --git a/HexWire.xcodeproj/project.pbxproj b/HexWire.xcodeproj/project.pbxproj index cd2f623..69c8fd3 100644 --- a/HexWire.xcodeproj/project.pbxproj +++ b/HexWire.xcodeproj/project.pbxproj @@ -566,6 +566,7 @@ 96CF0260ABA89A792A0A0174 /* samurai_walk_0.png in Resources */ = {isa = PBXBuildFile; fileRef = C36DB263E4A1EF8EE6677827 /* samurai_walk_0.png */; }; 97128FEE73077350D49A5968 /* vfx_atlas_mission04_reference.png in Resources */ = {isa = PBXBuildFile; fileRef = 7D76851BE42DD7DB26FEACE3 /* vfx_atlas_mission04_reference.png */; }; 974684260F89ECB9BD2214AC /* packet_route_caught.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A60554EA526A4CCB6DA0DD48 /* packet_route_caught.mp3 */; }; + 977093A32D5E680DB7481265 /* ReplayModeCertificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AA6FD9ECD12AFA6D04CF9E3 /* ReplayModeCertificationTests.swift */; }; 9783AA49B54172262EC2F850 /* mech_attack_1.png in Resources */ = {isa = PBXBuildFile; fileRef = 278E5CDA8F89F1CA66C02BA5 /* mech_attack_1.png */; }; 979434F36249ACE828184B47 /* vfx_aztech_gold_02.png in Resources */ = {isa = PBXBuildFile; fileRef = 6A07D19C9026EC979B5128F7 /* vfx_aztech_gold_02.png */; }; 97D692888F6F29C565628222 /* turret_idle_0.png in Resources */ = {isa = PBXBuildFile; fileRef = 84B3749F69445AF4E73A946C /* turret_idle_0.png */; }; @@ -579,6 +580,7 @@ 9B7D3258BF544428DDBB571F /* ui_select.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = D75E32330E11C400B941085C /* ui_select.mp3 */; }; 9B94A806634AB6DE7AC4525F /* bossmage_walk_3.png in Resources */ = {isa = PBXBuildFile; fileRef = 2B8A2D7A0654E1CFA5AE1021 /* bossmage_walk_3.png */; }; 9BBA8E8CE9EBB8F001A4FC27 /* daemon_idle_1.png in Resources */ = {isa = PBXBuildFile; fileRef = FCEDC41D765E142A2A66DAF1 /* daemon_idle_1.png */; }; + 9BF2ED2D8277FD7BDE24A6FD /* StepOnSemanticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC673880A90BB6982F91E0A /* StepOnSemanticsTests.swift */; }; 9C5011E826205EC02E0A8E76 /* vfx_arc_yellow_05.png in Resources */ = {isa = PBXBuildFile; fileRef = 2B60C37AF3CDB68D9F17E0B0 /* vfx_arc_yellow_05.png */; }; 9C68CE5509CF541DE50ACA63 /* sable_astral_hit_0.png in Resources */ = {isa = PBXBuildFile; fileRef = 6ABA350330C890F557D25907 /* sable_astral_hit_0.png */; }; 9CB7527221C6D5E008C7C6B1 /* vfx_embers_08.png in Resources */ = {isa = PBXBuildFile; fileRef = F632BE4171CFD7B2C84F68E4 /* vfx_embers_08.png */; }; @@ -1066,6 +1068,7 @@ 1A2451FC0596410082323471 /* vfx_ritual_pulse_06.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vfx_ritual_pulse_06.png; sourceTree = ""; }; 1A37D1650680D2AC23FE3964 /* vargas_heavy_2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vargas_heavy_2.png; sourceTree = ""; }; 1A9FDF9A546E08F9FBF762E4 /* tilesheet_1_r5c1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = tilesheet_1_r5c1.png; sourceTree = ""; }; + 1AA6FD9ECD12AFA6D04CF9E3 /* ReplayModeCertificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayModeCertificationTests.swift; sourceTree = ""; }; 1AE8FFCD8C10F387FBD60E53 /* tilesheet_1_r2c1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = tilesheet_1_r2c1.png; sourceTree = ""; }; 1B89713440C5A5D0535E950E /* decker_walk_0.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = decker_walk_0.png; sourceTree = ""; }; 1BEC410CFF78B80EB524F072 /* departing_2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = departing_2.png; sourceTree = ""; }; @@ -1718,6 +1721,7 @@ CCCB08CF43E011D12E1CEEB3 /* vfx_code_red_01.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vfx_code_red_01.png; sourceTree = ""; }; CD2E778EAE3945C1E65A0AEB /* juggernaut_hit_0.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = juggernaut_hit_0.png; sourceTree = ""; }; CD95561ECD30506FB889B33A /* arena_19.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = arena_19.png; sourceTree = ""; }; + CDC673880A90BB6982F91E0A /* StepOnSemanticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepOnSemanticsTests.swift; sourceTree = ""; }; CE05B115F9A16646DAE129B4 /* vfx_floor_arrow_06.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vfx_floor_arrow_06.png; sourceTree = ""; }; CE63FF758948FE468578DE9E /* mirror_sable_p1_cast.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = mirror_sable_p1_cast.png; sourceTree = ""; }; CE78CFCF357074E3D59AA6D3 /* shop_a.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = shop_a.mp3; sourceTree = ""; }; @@ -3018,7 +3022,9 @@ 314D8FE0D2A7A8F59077B931 /* IntentFacadeTests.swift */, 2C02C09D2C87FE4FB8E44805 /* MissionCertificationTests.swift */, 6C177BD4B62002EC7E424852 /* PersistenceCertificationTests.swift */, + 1AA6FD9ECD12AFA6D04CF9E3 /* ReplayModeCertificationTests.swift */, E56C142FB9005C31E924D0E2 /* ReplaySpawnTests.swift */, + CDC673880A90BB6982F91E0A /* StepOnSemanticsTests.swift */, 37BD8879A1281B72455E8028 /* test_consequence_engine.swift */, 902D06260F6E82C1A8D2EBF2 /* TurnAuthorityTests.swift */, ); @@ -4020,7 +4026,9 @@ 7A9DC9A3D52BF85F0BEF1C11 /* IntentFacadeTests.swift in Sources */, 9328711035603ACA20F974B8 /* MissionCertificationTests.swift in Sources */, 33EA2F7D2B78579224240336 /* PersistenceCertificationTests.swift in Sources */, + 977093A32D5E680DB7481265 /* ReplayModeCertificationTests.swift in Sources */, 6A57386BA07A6F741EB724D8 /* ReplaySpawnTests.swift in Sources */, + 9BF2ED2D8277FD7BDE24A6FD /* StepOnSemanticsTests.swift in Sources */, 76343F06BEB1C5203EEA0D13 /* TurnAuthorityTests.swift in Sources */, FCED7472DF49F975BBFA0E8A /* test_consequence_engine.swift in Sources */, ); diff --git a/HexwireApp.swift b/HexwireApp.swift index 9da3196..c189811 100644 --- a/HexwireApp.swift +++ b/HexwireApp.swift @@ -1442,17 +1442,9 @@ struct TitleView: View { ) .shadow(color: Color(hex: "FF00AA").opacity(0.35), radius: 6) } - // View the team's runners — levels / stats / equipment. - Button(action: { HapticsManager.shared.buttonTap(); showRoster = true }) { - Text("TEAM") - .font(.headline) - .foregroundColor(Color(hex: "00D4FF")) - .frame(width: btnW, height: btnH) - .background(Color.black.opacity(0.35)) - .overlay(RoundedRectangle(cornerRadius: 8) - .stroke(Color(hex: "00D4FF").opacity(0.8), lineWidth: 2)) - .shadow(color: Color(hex: "00D4FF").opacity(0.35), radius: 6) - } + // TEAM lives on the SELECT RUN bar instead — it's the screen + // where roster state actually matters, and having it in both + // places just doubled up the same `showRoster` action. Button(action: { HapticsManager.shared.buttonTap(); showSettings = true }) { Text("SETTINGS") .font(.headline) @@ -3807,6 +3799,13 @@ final class PhaseManager: ObservableObject { var stateStack: [GamePhase] { stateHistory } + /// Replay modes: the Endless Gauntlet floors and the procedural side + /// contracts. They recombine shipped rooms and carry no authored story, + /// so the campaign intro/outro VNs are skipped for them. + static func isReplayMissionId(_ id: String) -> Bool { + id == GauntletStore.gauntletMissionId || ContractStore.isContractId(id) + } + /// Canonical transition matrix implementation (active authority). /// Keep in lockstep with `PhaseFlowAuthorityMatrix.md`. private func computeNext(from state: GamePhase, event: StateTransition) -> GamePhase { @@ -3815,7 +3814,14 @@ final class PhaseManager: ObservableObject { case (.title, .viewPrologue): return .prologue case (.prologue, .finishPrologue): return .title // loop back, don't auto-start case (.prologue, .returnToTitle): return .title // skip mid-scene - case (.missionSelect, .selectMission): return .missionIntro // tactical missions go through cutscene first + case (.missionSelect, .selectMission(let id)): + // Replay modes (gauntlet floors, side contracts) have no story to + // tell — they recombine shipped rooms, and the contract board has + // already briefed the job. Running the campaign VN in front of them + // just gates the fight behind beats about a mission you aren't on. + // Straight to briefing (loadout still matters); campaign missions + // keep the cutscene. + return Self.isReplayMissionId(id) ? .briefing : .missionIntro // Standard tactical missions (M1–M6) go intro → briefing → combat. // M4.5 "Basement Brawl" uses the same intro VN infrastructure but // routes straight from the intro into its bespoke gameplay scene — @@ -3852,7 +3858,15 @@ final class PhaseManager: ObservableObject { case (.coldTrace, .returnToTitle): return .missionSelect // abort mid-dive case (.briefing, .beginMission): return .combat // Victory → mission outro VN scene before scoring; defeat → straight to debrief. - case (.combat, .endCombat(let won)): return won ? .missionOutro : .debrief + case (.combat, .endCombat(let won)): + // Same reasoning as the intro skip above — a won contract/gauntlet + // floor goes straight to debrief rather than playing campaign + // closing beats for a mission the player never started. + guard won else { return .debrief } + // Unknown id falls back to the campaign path — a missing outro is a + // worse regression than an extra one. + let isReplay = selectedMissionId.map(Self.isReplayMissionId) ?? false + return isReplay ? .debrief : .missionOutro case (.combat, .returnToTitle): return .missionSelect // abort from combat → menu case (.missionOutro, .finishMissionOutro): return .debrief case (.missionOutro, .viewDebrief): return .debrief // safety: caller-driven skip diff --git a/Missions/RoomManager.swift b/Missions/RoomManager.swift index e3ed8a2..afe3eca 100644 --- a/Missions/RoomManager.swift +++ b/Missions/RoomManager.swift @@ -50,6 +50,25 @@ final class RoomManager: ObservableObject { /// drop — without this set, re-entering M1 room_1 restores the wall row /// permanently and the mission can never be finished. var barrierDroppedRoomIds: Set = [] + /// How many times the player has RE-entered each room (first entry = 0). + /// Seeds the backtrack-patrol roll so a given visit always rolls the same + /// way — walking out and back in can't be used to re-roll a patrol you + /// didn't like, but successive visits still differ. + private var reentryCounts: [String: Int] = [:] + /// Rooms that have already coughed up a backtrack patrol this mission. + /// One patrol per room per attempt — without this, a corridor between two + /// objectives becomes an infinite XP/nuyen faucet. + var patrolRespawnedRoomIds: Set = [] + + /// Bump and return the re-entry index for a room (0 on first entry). + @discardableResult + func noteRoomEntry(_ roomId: String) -> Int { + let next = (reentryCounts[roomId] ?? -1) + 1 + reentryCounts[roomId] = next + return next + } + + func reentryCount(_ roomId: String) -> Int { reentryCounts[roomId] ?? 0 } /// Re-apply this room's already-dropped barriers (as floor) to a tile grid /// rebuilt from JSON. Call from every model/visual tile-build path. @@ -101,6 +120,8 @@ final class RoomManager: ObservableObject { bossDeployedRoomIds.removeAll() bossPendingRoomIds.removeAll() barrierDroppedRoomIds.removeAll() + reentryCounts.removeAll() + patrolRespawnedRoomIds.removeAll() return mission } return nil @@ -120,6 +141,8 @@ final class RoomManager: ObservableObject { bossDeployedRoomIds.removeAll() bossPendingRoomIds.removeAll() barrierDroppedRoomIds.removeAll() + reentryCounts.removeAll() + patrolRespawnedRoomIds.removeAll() } /// Mark a room as having received its reinforcement wave so future @@ -154,6 +177,18 @@ final class RoomManager: ObservableObject { return removed } + /// Same as `unmarkCurrentRoomCleared` but targeted by id — the room + /// transition needs this because it runs BEFORE `completeTransition`, so + /// `currentRoom` is still the room being left, not the one being entered. + @discardableResult + func unmarkRoomCleared(_ roomId: String) -> Bool { + let removed = clearedRoomIds.remove(roomId) != nil + if removed { + dlog("[RoomManager] unmarkRoomCleared() room=\(roomId) — door re-locks for backtrack patrol") + } + return removed + } + func isRoomCleared(_ roomId: String) -> Bool { let cleared = clearedRoomIds.contains(roomId) dlog("[RoomManager] isRoomCleared(\(roomId)) = \(cleared) clearedRoomIds = \(Array(clearedRoomIds))") @@ -233,21 +268,20 @@ final class RoomManager: ObservableObject { return nil } - // Spawn placement: - // - Going FORWARD into a new room, use the connection's authored - // targetSpawn (a deliberate entry point on the far side of the door). - // - BACKTRACKING into a room you've already cleared, land at that - // room's canonical entry (playerSpawn) — the near side where you - // first walked in. The forward connection's targetSpawn drops you - // beside the door toward the NEXT room (the far end), which reads as - // "spawned at the wrong end of the room" on the way back. - if isBacktrack { - pendingConnectionTargetX = targetRoom.playerSpawn.x - pendingConnectionTargetY = targetRoom.playerSpawn.y - } else { - pendingConnectionTargetX = connection.targetSpawnX - pendingConnectionTargetY = connection.targetSpawnY - } + // Spawn placement: ALWAYS the connection's authored targetSpawn, for + // forward and backward alike. Each connection already authors the tile + // beside the door it leads to, so walking back through a door puts you + // where that door actually is — the mirror of how you left. + // + // The previous special case sent backtrackers to targetRoom.playerSpawn + // instead. That is the room's ORIGINAL entry point, at the far end from + // the door you just came through: returning M1 room_2 → room_1 landed + // the squad at (3,9), the top, when the connection authors (2,1) at the + // bottom. It also contradicted BattleScene's own transition comment + // (playtest 2026-05-23), which had already settled on door-driven + // spawns for both directions — the two disagreed and this side won. + pendingConnectionTargetX = connection.targetSpawnX + pendingConnectionTargetY = connection.targetSpawnY return targetRoom } diff --git a/Rendering/BattleScene.swift b/Rendering/BattleScene.swift index 593d512..ebb070f 100644 --- a/Rendering/BattleScene.swift +++ b/Rendering/BattleScene.swift @@ -1087,7 +1087,18 @@ final class BattleScene: SKScene { mapNode.addChild(group) } - private func dropBarrierTiles(coords: [[String: Int]]) { + /// Re-apply the background floor patches for barriers this room already + /// dropped, so a re-entered room doesn't show its retracted wall again. + private func replayDroppedBarrierPatches(for room: Room) { + guard RoomManager.shared.barrierDroppedRoomIds.contains(room.id), + let drops = room.removeOnFirstKill, !drops.isEmpty else { return } + dropBarrierTiles(coords: drops.map { ["x": $0.x, "y": $0.y] }, animated: false) + } + + /// `animated: false` replays the patch silently on room re-entry — the + /// barrier already dropped in an earlier visit, so it must look gone the + /// instant the room appears, with no fade or spark to re-sell the moment. + private func dropBarrierTiles(coords: [[String: Int]], animated: Bool = true) { guard let mapNode = childNode(withName: "TileMap") else { return } let bg = childNode(withName: "roomBackground") as? SKSpriteNode let bgTex = bg?.texture @@ -1096,10 +1107,14 @@ final class BattleScene: SKScene { for c in coords { guard let x = c["x"], let y = c["y"] else { continue } // Fade + remove the wall sprite so pathfinding visualisation matches. - mapNode.childNode(withName: "tile_\(x)_\(y)")?.run(SKAction.sequence([ - SKAction.fadeOut(withDuration: 0.35), - SKAction.removeFromParent() - ])) + if animated { + mapNode.childNode(withName: "tile_\(x)_\(y)")?.run(SKAction.sequence([ + SKAction.fadeOut(withDuration: 0.35), + SKAction.removeFromParent() + ])) + } else { + mapNode.childNode(withName: "tile_\(x)_\(y)")?.removeFromParent() + } let center = TileMap.tileCenter(x: x, y: y) @@ -1171,9 +1186,17 @@ final class BattleScene: SKScene { // The patch is added to mapNode so it scrolls with the map. mapNode.addChild(patch) _ = bg // keep ref-warm in case future code wants it - patch.run(SKAction.fadeIn(withDuration: 0.4)) + if animated { + patch.run(SKAction.fadeIn(withDuration: 0.4)) + } else { + patch.alpha = 1 + } } + // The spark sells the drop as it happens; on a silent replay the + // barrier fell minutes ago, so skip straight past it. + guard animated else { continue } + // Brief amber spark at the tile to sell "barrier disengaged". let spark = SKShapeNode(circleOfRadius: 4) spark.fillColor = UIColor(hex: "#FFAA00") @@ -1257,6 +1280,8 @@ final class BattleScene: SKScene { // Mark room as entered (for future back-navigation) RoomManager.shared.markRoomEntered(targetRoom.id) + // Visit index seeds the backtrack-patrol roll below. + RoomManager.shared.noteRoomEntry(targetRoom.id) // Replace GameState enemies with this room's enemies only. // Cleared rooms stay empty on return; uncleared rooms rebuild from @@ -1323,6 +1348,35 @@ final class BattleScene: SKScene { newPendingSpawns.append(GameState.PendingSpawn(enemy: enemy, delayRounds: dueAt)) } } + } else { + // Cleared room: usually stays empty, but a patrol occasionally + // wanders in so backtracking carries some risk. Rolls empty most + // of the time, once per room per attempt — see backtrackPatrol. + let patrol = MissionSetupService.backtrackPatrol( + for: targetRoom, + gameState: GameState.shared, + entryX: spawnX, + entryY: spawnY + ) + if !patrol.isEmpty { + RoomManager.shared.patrolRespawnedRoomIds.insert(targetRoom.id) + for spawn in patrol { + let enemy = MissionSetupService.makeEnemy( + gameState: GameState.shared, + for: spawn.type, + archetype: .interceptor + ) + NGPlusStore.shared.scaleForTier(enemy) + enemy.positionX = spawn.x + enemy.positionY = spawn.y + newEnemies.append(enemy) + } + // Live enemies mean the room is no longer clear — same contract + // ReinforcementService uses, so doors re-lock until the patrol + // is put down and onRoomCleared re-marks it. + _ = RoomManager.shared.unmarkRoomCleared(targetRoom.id) + GameState.shared.addLog("⚠️ Patrol contact — this room isn't as clear as you left it.") + } } // WP4 seam: the scene CONSTRUCTS the room's authored spawn lists // above; every gameplay mutation (squad placement, NG+ extras, @@ -3184,6 +3238,12 @@ final class BattleScene: SKScene { // from loadMap (initial load), not from loadRoom (transitions). addRoomBackgroundImage(for: newTileMap) addChild(mapNode) + // Barriers dropped on an earlier visit: the tile grid already reads as + // floor (applyDroppedBarriers), but the barrier is PAINTED INTO the + // room background art, which is reloaded pristine above. Without + // replaying the pixel patch the wall reappears while staying + // walk-through — visibly there, physically gone. + replayDroppedBarrierPatches(for: room) addObjectiveMarkers(to: mapNode, room: room) restampScorchScars(on: mapNode, room: room) // Mission-specific ambient VFX — must run on every room load, diff --git a/UI/SettingsView.swift b/UI/SettingsView.swift index 0d31fa8..9ae3ff9 100644 --- a/UI/SettingsView.swift +++ b/UI/SettingsView.swift @@ -87,14 +87,21 @@ struct SettingsSheet: View { if on { HapticsManager.shared.selectAffirm() } } + // Toggles both ways: arming a replay used to be irreversible, + // and the disabled state also lied after reopening Settings + // because it tracked @State rather than the stored flags. Button(action: { - TutorialCoach.shared.resetAll() - tipsReset = true + if tipsReset { + TutorialCoach.shared.markAllSeen() + } else { + TutorialCoach.shared.resetAll() + } + tipsReset.toggle() HapticsManager.shared.buttonTap() }) { HStack(spacing: 8) { - Image(systemName: "questionmark.circle") - Text(tipsReset ? "TUTORIAL TIPS WILL REPLAY" : "REPLAY TUTORIAL TIPS") + Image(systemName: tipsReset ? "checkmark.circle" : "questionmark.circle") + Text(tipsReset ? "TIPS WILL REPLAY — TAP TO CANCEL" : "REPLAY TUTORIAL TIPS") .tracking(1) } .font(.system(size: 12, weight: .black, design: .monospaced)) @@ -104,7 +111,7 @@ struct SettingsSheet: View { .overlay(RoundedRectangle(cornerRadius: 8) .stroke(Color.white.opacity(0.35), lineWidth: 1)) } - .disabled(tipsReset) + .onAppear { tipsReset = TutorialCoach.shared.anyUnseen } Spacer() diff --git a/UI/TutorialCoach.swift b/UI/TutorialCoach.swift index 28f9175..6283d9b 100644 --- a/UI/TutorialCoach.swift +++ b/UI/TutorialCoach.swift @@ -216,6 +216,23 @@ final class TutorialCoach: ObservableObject { current = nil } + /// Inverse of `resetAll()` — mark every tip as already seen so none replay. + /// Without this, arming a replay was a one-way door: the only way back was + /// to sit through every tip again. + func markAllSeen() { + for t in TutorialTip.allCases { + UserDefaults.standard.set(true, forKey: t.udKey) + } + pending.removeAll() + inFlight.removeAll() + current = nil + } + + /// True when at least one tip is still queued to show. + var anyUnseen: Bool { + TutorialTip.allCases.contains { !UserDefaults.standard.bool(forKey: $0.udKey) } + } + private func advance() { guard current == nil else { return } while let next = pending.first { diff --git a/tests/ContractBoardTests.swift b/tests/ContractBoardTests.swift index 2a008a1..3be5f86 100644 --- a/tests/ContractBoardTests.swift +++ b/tests/ContractBoardTests.swift @@ -170,6 +170,92 @@ final class ContractBoardTests: XCTestCase { // MARK: - End-to-end: accept → clear → extract → paid → consumed + /// Regression: killing the last enemy in a contract must ARM extraction on + /// its own. `testContractPlaysThroughToPayoutEndToEnd` calls + /// `markCurrentRoomCleared()` by hand, so it proves everything downstream + /// of a cleared room while never checking that clearing the room actually + /// marks it — which is the half the player experiences as a dead + /// extraction pad. + func testKillingLastEnemyArmsContractExtraction() throws { + let offer = ContractStore.makeOffer(tier: 1, seed: 5150) + ContractStore.shared.setOffers([offer]) + guard MissionLoader.shared.loadMultiRoomMission(named: offer.id) != nil, + RoomManager.shared.loadMission(named: offer.id) != nil else { + throw XCTSkip("mission JSONs not bundled") + } + _ = gs.prepareMissionForCombat(named: offer.id) + gs.missionComplete = false + gs.combatEnded = false + gs.pendingSpawns = [] + XCTAssertFalse(gs.enemies.isEmpty, "contract room should spawn a squad") + + // Kill through the real path — no hand-marking the room cleared. + for enemy in gs.enemies { + enemy.currentHP = 0 + gs.handleEnemyKilledByEnvironment(enemy, cause: "test") + } + + XCTAssertTrue(gs.livingEnemies.isEmpty, "squad is down") + XCTAssertTrue(RoomManager.shared.isExtractionActive(), + "clearing the room must arm extraction — otherwise isExtractionTile() " + + "returns false and tapping the pad silently does nothing") + + // The pad the player SEES is the extraction tile painted into the live + // grid. The pad the tap RESOLVES against is gs.extractionX/Y. If those + // ever disagree, the visible pad is inert and the tap is a silent no-op. + var padsInGrid: [(x: Int, y: Int)] = [] + for (y, row) in gs.currentMissionTiles.enumerated() { + for (x, tile) in row.enumerated() where tile == TileType.extraction.rawValue { + padsInGrid.append((x: x, y: y)) + } + } + XCTAssertFalse(padsInGrid.isEmpty, "the room renders an extraction pad") + XCTAssertTrue(padsInGrid.contains { $0.x == gs.extractionX && $0.y == gs.extractionY }, + "extraction coords (\(gs.extractionX),\(gs.extractionY)) must land ON a " + + "rendered pad \(padsInGrid) — otherwise the player taps a pad that " + + "isExtractionTile() refuses to recognise") + } + + /// The player's actual sequence: clear the room, then WALK the runner onto + /// the pad instead of tapping it. Before the step-on adjudication hook this + /// produced the objective pulse and nothing else — the contract could not + /// be completed at all. + func testWalkingOntoExtractionPadCompletesContract() throws { + let offer = ContractStore.makeOffer(tier: 1, seed: 5150) + ContractStore.shared.setOffers([offer]) + guard MissionLoader.shared.loadMultiRoomMission(named: offer.id) != nil, + RoomManager.shared.loadMission(named: offer.id) != nil else { + throw XCTSkip("mission JSONs not bundled") + } + _ = gs.prepareMissionForCombat(named: offer.id) + gs.missionComplete = false + gs.combatEnded = false + gs.pendingSpawns = [] + + for enemy in gs.enemies { + enemy.currentHP = 0 + gs.handleEnemyKilledByEnvironment(enemy, cause: "test") + } + if gs.missionRequiresData && !gs.dataAcquired { + _ = gs.requestObjectiveDataAcquired(source: "contract-test") + } + guard let runner = gs.playerTeam.first(where: { $0.isAlive }) else { + return XCTFail("no living runner") + } + + // Walk on. No tap, no enemy phase — the two triggers that used to be + // the only ways extraction could ever resolve. + CombatFlowController.setCombatPhase(gameState: gs, .playerInput) + gs.characterHasMovedThisTurn[runner.id] = false + runner.hasActedThisRound = false + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + + XCTAssertEqual(runner.positionX, gs.extractionX) + XCTAssertEqual(runner.positionY, gs.extractionY) + XCTAssertTrue(gs.extractionAnimationInProgress || gs.combatEnded || gs.missionComplete, + "stepping onto an armed extraction pad must resolve the run") + } + func testContractPlaysThroughToPayoutEndToEnd() throws { let offer = ContractStore.makeOffer(tier: 1, seed: 5150) ContractStore.shared.setOffers([offer]) diff --git a/tests/ReplayModeCertificationTests.swift b/tests/ReplayModeCertificationTests.swift new file mode 100644 index 0000000..690c7ea --- /dev/null +++ b/tests/ReplayModeCertificationTests.swift @@ -0,0 +1,307 @@ +import XCTest +#if canImport(HexWire) +@testable import HexWire + +/// Replay-mode certification — the WP6 treatment for content WP6 never covered. +/// +/// `docs/audit/MissionCertificationMatrix.md` certifies the six campaign +/// missions and mentions gauntlet, contract and arena exactly zero times. Those +/// are the newest shipped modes and the least proven: side contracts were +/// uncompletable on 2026-07-23 with the whole suite green, because nothing drove +/// them the way a player does. +/// +/// Every test here finishes a run the player's way — walking a runner onto the +/// extraction pad via `moveCharacter` — rather than calling the extraction +/// intent directly. Setup (room traversal, clearing) uses authority, which WP6 +/// already certifies. +@MainActor +final class ReplayModeCertificationTests: XCTestCase { + + private let gs = GameState.shared + private let persistenceKeys = [ + "HexWire.Contracts.Offers.v1", "HexWire.Contracts.Completed.v1", + "HexWire.MissionStats.v1", "HexWire.MissionStats.v1.lastGood", + "HexWire.PlayerNuyen.v1", "HexWire.PaidThisRun.v1", + "HexWire.Roster.v1", "HexWire.Roster.v1.lastGood", + "HexWire.NGPlusTier.v1", "HexWire.FactionAttention.v1", + "HexWire.Gauntlet.v1", + ] + private var snapshot: [String: Any] = [:] + private var savedOffers: [ContractOffer] = [] + private var savedTeam: [Character] = [] + private var savedTier = 0 + + override func setUp() async throws { + snapshot = [:] + for key in persistenceKeys { + if let v = UserDefaults.standard.object(forKey: key) { snapshot[key] = v } + } + savedOffers = ContractStore.shared.offers + savedTeam = gs.playerTeam + savedTier = NGPlusStore.shared.tier + MissionStatsStore.shared.resetAll() + NGPlusStore.shared.tier = 0 + MissionStatsStore.shared.resetFactionAttention() + RosterStore.shared.reset() + gs.factionAttention = [.corp: 0, .gang: 0, .unknown: 0] + } + + override func tearDown() async throws { + ContractStore.shared.disarmForNonContractLoad() + GauntletStore.shared.disarmForNonGauntletLoad() + ContractStore.shared.setOffers(savedOffers) + MissionStatsStore.shared.resetAll() + NGPlusStore.shared.tier = savedTier + for key in persistenceKeys { UserDefaults.standard.removeObject(forKey: key) } + for (key, v) in snapshot { UserDefaults.standard.set(v, forKey: key) } + gs.playerTeam = savedTeam + gs.enemies = [] + gs.pendingSpawns = [] + gs.missionComplete = false + gs.combatEnded = false + gs.extractionAnimationInProgress = false + RoomManager.shared.unloadMission() + } + + // MARK: - Harness + + private func resetRunState() { + RoomManager.shared.unloadMission() + gs.enemies = [] + gs.pendingSpawns = [] + gs.missionComplete = false + gs.combatEnded = false + gs.extractionAnimationInProgress = false + } + + private var runResolved: Bool { + gs.extractionAnimationInProgress || gs.combatEnded || gs.missionComplete + } + + /// Load a replay mission, clear every room, and walk the last runner onto + /// the pad. Returns nil if the mission could not be loaded at all. + /// `label` is only used to make failures identifiable. + @discardableResult + private func playThroughViaWalkOn(missionId: String, label: String) -> Bool? { + resetRunState() + guard let mission = MissionLoader.shared.loadMultiRoomMission(named: missionId), + RoomManager.shared.loadMission(named: missionId) != nil else { return nil } + _ = gs.prepareMissionForCombat(named: missionId) + gs.missionComplete = false + gs.combatEnded = false + gs.extractionAnimationInProgress = false + + var extractionRoom: Room? + for (i, room) in mission.rooms.enumerated() { + if i > 0 { + gs.applyRoomEntry(to: room, enemies: [], pendingSpawns: [], + spawnAnchor: room.playerSpawn) + } + gs.pendingSpawns = [] + for enemy in gs.enemies { + enemy.currentHP = 0 + gs.handleEnemyKilledByEnvironment(enemy, cause: "cert") + } + _ = RoomManager.shared.markCurrentRoomCleared() + if RoomManager.shared.roomHasExtraction(room) { extractionRoom = room } + } + guard let finalRoom = extractionRoom else { + XCTFail("\(label): no room exposes an extraction objective") + return false + } + if RoomManager.shared.currentRoom?.id != finalRoom.id { + gs.applyRoomEntry(to: finalRoom, enemies: [], pendingSpawns: [], + spawnAnchor: finalRoom.playerSpawn) + gs.enemies.forEach { $0.currentHP = 0 } + gs.pendingSpawns = [] + _ = RoomManager.shared.markCurrentRoomCleared() + } + XCTAssertTrue(RoomManager.shared.isExtractionActive(), + "\(label): a fully cleared replay run must arm extraction") + XCTAssertGreaterThanOrEqual(gs.extractionX, 0, "\(label): extraction coords unset") + + if gs.missionRequiresData && !gs.dataAcquired { + _ = gs.requestObjectiveDataAcquired(source: "cert") + } + guard let runner = gs.playerTeam.first(where: { $0.isAlive }) else { + XCTFail("\(label): no living runner") + return false + } + CombatFlowController.setCombatPhase(gameState: gs, .playerInput) + gs.characterHasMovedThisTurn[runner.id] = false + runner.hasActedThisRound = false + + // The player's way out. + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + return runResolved + } + + /// Seeds that reach each distinct arena, so every arena can be driven + /// through the REAL contract path rather than constructed by hand. + private func seedsCoveringEveryArena() -> [String: Int] { + var found: [String: Int] = [:] + let total = ArenaPool.load().count + for seed in 0..<20_000 where found.count < total { + if let id = ArenaPool.arenaId(forSeed: seed), found[id] == nil { + found[id] = seed + } + } + return found + } + + // MARK: - Arenas + + /// EVERY arena must be able to host a completable contract. There are 20 and + /// a player only ever sees the handful their seeds roll — a broken one could + /// sit undiscovered for months. + func testEveryArenaHostsACompletableContract() throws { + let seeds = seedsCoveringEveryArena() + try XCTSkipIf(seeds.isEmpty, "arena pool did not load") + XCTAssertEqual(seeds.count, ArenaPool.load().count, + "every arena must be reachable by some contract seed") + + var played = 0 + for (arenaId, seed) in seeds.sorted(by: { $0.key < $1.key }) { + let offer = ContractStore.makeOffer(tier: 1, seed: seed) + ContractStore.shared.setOffers([offer]) + guard let resolved = playThroughViaWalkOn(missionId: offer.id, + label: "contract on \(arenaId)") else { + XCTFail("\(arenaId): contract mission failed to load") + continue + } + XCTAssertTrue(resolved, + "\(arenaId): walking onto the pad must complete the contract") + played += 1 + } + XCTAssertEqual(played, seeds.count, "every arena must have been played") + } + + /// Structural gate: an arena that can't place the party or has no exit is a + /// broken room no amount of runtime luck fixes. + func testEveryArenaIsStructurallyPlayable() { + let entries = ArenaPool.load() + XCTAssertFalse(entries.isEmpty, "arena pool must load") + for entry in entries { + let room = entry.room + let id = room.id + let h = room.map.count + XCTAssertGreaterThan(h, 0, "\(id): empty map") + let w = room.map.first?.count ?? 0 + + // Party spawn must be inside the map and not a wall. + XCTAssertTrue(room.playerSpawn.y >= 0 && room.playerSpawn.y < h, + "\(id): playerSpawn off-map vertically") + XCTAssertTrue(room.playerSpawn.x >= 0 && room.playerSpawn.x < w, + "\(id): playerSpawn off-map horizontally") + XCTAssertNotEqual(room.map[room.playerSpawn.y][room.playerSpawn.x], + TileType.wall.rawValue, "\(id): playerSpawn inside a wall") + + // The exit door the contract turns into an extraction pad. + XCTAssertTrue(entry.exitDoor.y >= 0 && entry.exitDoor.y < h, + "\(id): exitDoor off-map vertically") + XCTAssertTrue(entry.exitDoor.x >= 0 && entry.exitDoor.x < w, + "\(id): exitDoor off-map horizontally") + + // Sealing the arena must actually produce a usable extraction. + let sealed = ArenaPool.finalRoom(from: entry) + XCTAssertNotNil(sealed.extractionPoint, "\(id): sealed room has no extraction point") + XCTAssertTrue(RoomManager.shared.roomHasExtraction(sealed), + "\(id): sealed room does not register as having extraction") + if let pt = sealed.extractionPoint { + XCTAssertEqual(sealed.map[pt.y][pt.x], TileType.extraction.rawValue, + "\(id): extraction point is not an extraction tile") + } + + // Enemies must sit on real, non-wall tiles. + for e in room.enemies { + XCTAssertTrue(e.y >= 0 && e.y < h && e.x >= 0 && e.x < w, + "\(id): enemy \(e.type) off-map at (\(e.x),\(e.y))") + if e.y >= 0, e.y < h, e.x >= 0, e.x < w { + XCTAssertNotEqual(room.map[e.y][e.x], TileType.wall.rawValue, + "\(id): enemy \(e.type) spawns inside a wall") + } + XCTAssertTrue(MissionCertificationTests.knownSpawnTypes.contains(e.type), + "\(id): unknown enemy type '\(e.type)' would fall back to a corp guard") + } + } + } + + // MARK: - Contracts + + /// All three tiers must be completable, not just the tier-1 offer the + /// existing board test happens to use. + func testEveryContractTierCompletesViaWalkOn() { + for tier in 1...3 { + let offer = ContractStore.makeOffer(tier: tier, seed: 4200 + tier) + ContractStore.shared.setOffers([offer]) + guard let resolved = playThroughViaWalkOn(missionId: offer.id, + label: "tier \(tier) contract") else { + XCTFail("tier \(tier): contract mission failed to load") + continue + } + XCTAssertTrue(resolved, "tier \(tier): walking onto the pad must complete the contract") + } + } + + // MARK: - Gauntlet + + /// Gauntlet floors must build and be completable the player's way, across + /// the scaling band (floor composition changes at 4, scaling caps at 10). + func testGauntletFloorsBuildAndCompleteViaWalkOn() { + var floorsPlayed = 0 + for _ in 0..<8 { + let floor = GauntletStore.shared.currentFloor + guard let resolved = playThroughViaWalkOn( + missionId: GauntletStore.gauntletMissionId, + label: "gauntlet floor \(floor)") else { + XCTFail("gauntlet floor \(floor): mission failed to build") + break + } + XCTAssertTrue(resolved, + "gauntlet floor \(floor): walking onto the pad must clear the floor") + floorsPlayed += 1 + _ = GauntletStore.shared.recordFloorVictory() + } + XCTAssertGreaterThanOrEqual(floorsPlayed, 8, "every sampled gauntlet floor must play") + } + + /// A gauntlet floor is multi-room and must end in exactly one extraction — + /// a floor with none is unfinishable, a floor with several lets the player + /// skip arenas they were meant to fight through. + func testGauntletFloorHasExactlyOneExtractionRoom() throws { + resetRunState() + guard let mission = MissionLoader.shared.loadMultiRoomMission( + named: GauntletStore.gauntletMissionId) else { + throw XCTSkip("gauntlet floor failed to build") + } + XCTAssertGreaterThan(mission.rooms.count, 1, "a gauntlet floor chains multiple arenas") + let withExtraction = mission.rooms.filter { RoomManager.shared.roomHasExtraction($0) } + XCTAssertEqual(withExtraction.count, 1, + "exactly one arena on a floor may hold the exit") + XCTAssertEqual(withExtraction.first?.id, mission.rooms.last?.id, + "the exit belongs to the LAST arena on the floor") + } + + /// Floor victory advances the pit and banks the best; defeat resets the run + /// to floor 1 but must NOT erase the leaderboard stat — that survivorship is + /// the whole point of bestFloor. + func testFloorVictoryAdvancesAndDefeatResetsWithoutLosingBest() { + let start = GauntletStore.shared.currentFloor + + // Returns the floor just COMPLETED; currentFloor is what advances. + let completed = GauntletStore.shared.recordFloorVictory() + XCTAssertEqual(completed, start, "return value is the floor just cleared") + XCTAssertEqual(GauntletStore.shared.currentFloor, start + 1, + "a cleared floor advances the pit") + _ = GauntletStore.shared.recordFloorVictory() + let best = GauntletStore.shared.bestFloor + XCTAssertGreaterThanOrEqual(best, start + 1, "best floor banks the deepest clear") + + GauntletStore.shared.recordFloorDefeat() + XCTAssertEqual(GauntletStore.shared.currentFloor, 1, + "a wipe sends the next run back to floor 1") + XCTAssertEqual(GauntletStore.shared.bestFloor, best, + "a wipe must not erase the banked best floor") + } +} +#endif diff --git a/tests/ReplaySpawnTests.swift b/tests/ReplaySpawnTests.swift index aeb0fbc..90f3f37 100644 --- a/tests/ReplaySpawnTests.swift +++ b/tests/ReplaySpawnTests.swift @@ -7,6 +7,102 @@ import XCTest @MainActor final class ReplaySpawnTests: XCTestCase { + // MARK: - Backtrack patrols + + private func patrolRoom(id: String = "patrol_room") -> Room { + Room(id: id, title: "t", + map: [ + [1, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [1, 1, 1, 1, 1, 1], + ], + playerSpawn: SpawnPoint(x: 1, y: 1), + extractionPoint: nil, + enemies: [ + EnemySpawn(type: "guard", x: 1, y: 3, delay: 0), + EnemySpawn(type: "juggernaut", x: 4, y: 3, delay: 0), + EnemySpawn(type: "drone", x: 4, y: 1, delay: 0), + ], + connections: [], removeOnFirstKill: nil, bossSpawn: nil) + } + + /// The roll must be fixed for a given visit — walking out and back in + /// cannot be used to re-roll a patrol the player didn't like. + func testBacktrackPatrolIsDeterministicForAGivenVisit() { + let room = patrolRoom() + RoomManager.shared.patrolRespawnedRoomIds.removeAll() + let a = MissionSetupService.backtrackPatrol( + for: room, gameState: GameState.shared, entryX: 1, entryY: 1) + let b = MissionSetupService.backtrackPatrol( + for: room, gameState: GameState.shared, entryX: 1, entryY: 1) + XCTAssertEqual(a.map { "\($0.type)@\($0.x),\($0.y)" }, + b.map { "\($0.type)@\($0.x),\($0.y)" }, + "same attempt + room + visit must roll identically") + } + + /// Occasional means occasional: across many rooms it must fire sometimes + /// and stay quiet sometimes. A patrol every single time turns backtracking + /// into a chore; never firing means the feature does nothing. + func testBacktrackPatrolFiresSometimesNotAlways() { + var fired = 0 + let trials = 60 + for i in 0.. Character { + guard let mission = MissionLoader.shared.loadMultiRoomMission(named: id), + RoomManager.shared.loadMission(named: id) != nil else { + throw XCTSkip("mission JSONs not bundled") + } + _ = gs.prepareMissionForCombat(named: id) + gs.missionComplete = false + gs.combatEnded = false + gs.extractionAnimationInProgress = false + + var extractionRoom: Room? + for (i, room) in mission.rooms.enumerated() { + if i > 0 { + gs.applyRoomEntry(to: room, enemies: [], pendingSpawns: [], + spawnAnchor: room.playerSpawn) + } + let isFinal = RoomManager.shared.roomHasExtraction(room) + if isFinal { extractionRoom = room } + // Leave the final room hot when the test asked for it. applyRoomEntry + // above was handed an empty enemy list (it is a setup call), so the + // opposition has to be built here or "enemies alive" would be a lie. + if isFinal && !clearFinalRoom { + gs.enemies = room.enemies.map { spawn in + let e = MissionSetupService.makeEnemy(gameState: gs, + for: spawn.type, + archetype: .enforcer) + e.positionX = spawn.x + e.positionY = spawn.y + return e + } + break + } + gs.pendingSpawns = [] + for enemy in gs.enemies { + enemy.currentHP = 0 + gs.handleEnemyKilledByEnvironment(enemy, cause: "test") + } + _ = RoomManager.shared.markCurrentRoomCleared() + } + guard let finalRoom = extractionRoom else { + throw XCTSkip("\(id): no room exposes an extraction objective") + } + if RoomManager.shared.currentRoom?.id != finalRoom.id { + gs.applyRoomEntry(to: finalRoom, enemies: [], pendingSpawns: [], + spawnAnchor: finalRoom.playerSpawn) + if clearFinalRoom { + gs.pendingSpawns = [] + gs.enemies.forEach { $0.currentHP = 0 } + _ = RoomManager.shared.markCurrentRoomCleared() + } + } + guard let runner = gs.playerTeam.first(where: { $0.isAlive }) else { + throw XCTSkip("no living runner") + } + CombatFlowController.setCombatPhase(gameState: gs, .playerInput) + gs.characterHasMovedThisTurn[runner.id] = false + runner.hasActedThisRound = false + return runner + } + + /// Reset the per-turn move budget so a test can take another step. + private func refreshMove(_ runner: Character) { + gs.characterHasMovedThisTurn[runner.id] = false + runner.hasActedThisRound = false + CombatFlowController.setCombatPhase(gameState: gs, .playerInput) + } + + private func firstTile(_ type: TileType) -> (x: Int, y: Int)? { + for (y, row) in gs.currentMissionTiles.enumerated() { + if let x = row.firstIndex(of: type.rawValue) { return (x: x, y: y) } + } + return nil + } + + private var someoneIsOnThePad: Bool { + gs.livingPlayers.contains { $0.positionX == gs.extractionX && $0.positionY == gs.extractionY } + } + + private var runResolved: Bool { + gs.extractionAnimationInProgress || gs.combatEnded || gs.missionComplete + } + + // MARK: - Extraction + + /// THE regression. Walking onto an armed pad must end the run, with no tap + /// and no enemy phase — the only two things that used to trigger it. + func testWalkingOntoArmedExtractionPadResolvesTheRun() throws { + let runner = try walkToExtractionRoom() + XCTAssertGreaterThanOrEqual(gs.extractionX, 0, "extraction coords must be set") + if gs.missionRequiresData && !gs.dataAcquired { + _ = gs.requestObjectiveDataAcquired(source: "step-on-test") + } + XCTAssertTrue(RoomManager.shared.isExtractionActive(), + "a fully cleared mission must arm extraction") + + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + + XCTAssertTrue(someoneIsOnThePad, "the move committed") + XCTAssertTrue(runResolved, "stepping onto an armed pad must resolve the run") + } + + /// The mirror case. A pad that is NOT armed must stay inert — stepping on + /// it cannot end a run that still has enemies in it. + func testWalkingOntoPadWithEnemiesAliveDoesNotResolve() throws { + let runner = try walkToExtractionRoom(clearFinalRoom: false) + XCTAssertGreaterThanOrEqual(gs.extractionX, 0, "extraction coords must be set") + try XCTSkipIf(gs.livingEnemies.isEmpty, "final room authored with no enemies") + + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + + XCTAssertFalse(runResolved, + "a pad must not extract while the room still has living enemies") + } + + /// Stepping on and off and on again must not stack two resolutions — the + /// adjudicator runs on EVERY move commit now, so idempotency matters. + func testRepeatedStepsOnPadResolveOnlyOnce() throws { + let runner = try walkToExtractionRoom() + XCTAssertGreaterThanOrEqual(gs.extractionX, 0, "extraction coords must be set") + if gs.missionRequiresData && !gs.dataAcquired { + _ = gs.requestObjectiveDataAcquired(source: "step-on-test") + } + XCTAssertTrue(RoomManager.shared.isExtractionActive(), + "a fully cleared mission must arm extraction") + + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + XCTAssertTrue(runResolved) + let firstFlag = gs.extractionAnimationInProgress + + refreshMove(runner) + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + + XCTAssertEqual(gs.extractionAnimationInProgress, firstFlag, + "re-stepping must not queue a second extraction") + } + + /// A move onto ordinary floor must not resolve anything. Guards against an + /// over-eager adjudicator that fires on any move once the room is clear. + func testWalkingOntoPlainFloorResolvesNothing() throws { + let runner = try walkToExtractionRoom() + guard let floor = firstTile(.floor) else { throw XCTSkip("no floor tile") } + try XCTSkipIf(floor.x == gs.extractionX && floor.y == gs.extractionY, + "picked the pad by accident") + + gs.moveCharacter(id: runner.id, toTileX: floor.x, toTileY: floor.y) + + XCTAssertFalse(runResolved, "stepping on plain floor must not end the run") + } + + /// Data-gated missions must hold the pad shut until the terminal is cracked. + /// Sweeps for a mission that actually carries the gate rather than assuming + /// M1 does — asserting against a mission with no terminal proves nothing. + func testDataGatedMissionDoesNotExtractBeforeTheTerminalIsCracked() throws { + var gated = 0 + for id in MissionCertificationTests.allMissionIds { + RoomManager.shared.unloadMission() + gs.extractionAnimationInProgress = false + gs.missionComplete = false + gs.combatEnded = false + + guard let runner = try? walkToExtractionRoom(mission: id) else { continue } + guard gs.missionRequiresData, !gs.dataAcquired, gs.extractionX >= 0 else { continue } + gated += 1 + + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + + XCTAssertFalse(runResolved, + "\(id): extraction must stay locked until the data objective is met") + + // And once the objective IS met, the same step must go through. + _ = gs.requestObjectiveDataAcquired(source: "step-on-test") + refreshMove(runner) + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + XCTAssertTrue(runResolved, + "\(id): with data acquired, stepping on the pad must resolve") + } + XCTAssertGreaterThan(gated, 0, "no shipped mission exercised the data gate") + } + + // MARK: - Move budget + + /// The step-on hook must not become a way to move twice in one turn. + func testSecondMoveInSameTurnIsRejected() throws { + let runner = try walkToExtractionRoom() + guard let floor = firstTile(.floor) else { throw XCTSkip("no floor tile") } + gs.moveCharacter(id: runner.id, toTileX: floor.x, toTileY: floor.y) + let after = (x: runner.positionX, y: runner.positionY) + + // No refreshMove() — the budget is spent. + gs.moveCharacter(id: runner.id, toTileX: floor.x + 1, toTileY: floor.y) + + XCTAssertEqual(runner.positionX, after.x, "second move in a turn must be refused") + XCTAssertEqual(runner.positionY, after.y, "second move in a turn must be refused") + } + + // MARK: - Cross-mission sweep + + /// Every shipped mission whose opening room has a pad must extract on a + /// walk-on. Catches a mission-specific data/geometry quirk that a single + /// M1 test would miss. + func testEveryMissionWithAnOpeningPadExtractsOnWalkOn() throws { + var checked = 0 + for id in MissionCertificationTests.allMissionIds { + RoomManager.shared.unloadMission() + gs.extractionAnimationInProgress = false + gs.missionComplete = false + gs.combatEnded = false + + guard let runner = try? walkToExtractionRoom(mission: id) else { continue } + XCTAssertGreaterThanOrEqual(gs.extractionX, 0, "\(id): extraction coords unset") + if gs.missionRequiresData && !gs.dataAcquired { + _ = gs.requestObjectiveDataAcquired(source: "sweep") + } + XCTAssertTrue(RoomManager.shared.isExtractionActive(), + "\(id): a fully cleared mission must arm extraction") + + gs.moveCharacter(id: runner.id, toTileX: gs.extractionX, toTileY: gs.extractionY) + XCTAssertTrue(runResolved, "\(id): walking onto the armed pad must resolve the run") + checked += 1 + } + try XCTSkipIf(checked == 0, "no mission exposed an armed pad in its opening room") + } +} +#endif