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