Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Game/CombatFlowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand Down
78 changes: 78 additions & 0 deletions Game/MissionSetupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions HexWire.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand Down Expand Up @@ -1066,6 +1068,7 @@
1A2451FC0596410082323471 /* vfx_ritual_pulse_06.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vfx_ritual_pulse_06.png; sourceTree = "<group>"; };
1A37D1650680D2AC23FE3964 /* vargas_heavy_2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vargas_heavy_2.png; sourceTree = "<group>"; };
1A9FDF9A546E08F9FBF762E4 /* tilesheet_1_r5c1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = tilesheet_1_r5c1.png; sourceTree = "<group>"; };
1AA6FD9ECD12AFA6D04CF9E3 /* ReplayModeCertificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayModeCertificationTests.swift; sourceTree = "<group>"; };
1AE8FFCD8C10F387FBD60E53 /* tilesheet_1_r2c1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = tilesheet_1_r2c1.png; sourceTree = "<group>"; };
1B89713440C5A5D0535E950E /* decker_walk_0.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = decker_walk_0.png; sourceTree = "<group>"; };
1BEC410CFF78B80EB524F072 /* departing_2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = departing_2.png; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1718,6 +1721,7 @@
CCCB08CF43E011D12E1CEEB3 /* vfx_code_red_01.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vfx_code_red_01.png; sourceTree = "<group>"; };
CD2E778EAE3945C1E65A0AEB /* juggernaut_hit_0.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = juggernaut_hit_0.png; sourceTree = "<group>"; };
CD95561ECD30506FB889B33A /* arena_19.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = arena_19.png; sourceTree = "<group>"; };
CDC673880A90BB6982F91E0A /* StepOnSemanticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepOnSemanticsTests.swift; sourceTree = "<group>"; };
CE05B115F9A16646DAE129B4 /* vfx_floor_arrow_06.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = vfx_floor_arrow_06.png; sourceTree = "<group>"; };
CE63FF758948FE468578DE9E /* mirror_sable_p1_cast.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = mirror_sable_p1_cast.png; sourceTree = "<group>"; };
CE78CFCF357074E3D59AA6D3 /* shop_a.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = shop_a.mp3; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
);
Expand Down Expand Up @@ -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 */,
);
Expand Down
40 changes: 27 additions & 13 deletions HexwireApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 —
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading