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
30 changes: 29 additions & 1 deletion Game/CombatFlowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1128,13 +1128,19 @@ struct CombatFlowController {
NGPlusStore.shared.scaleForTier(boss) // NG+ durability/stat scaling (matches deployBoss)
boss.positionX = x
boss.positionY = y

// SPLASH FIRST, then the boss lands on the board. Presenting the reveal
// before the append means the card is already rising when the sprite
// materialises behind it, so dismissing the card IS the moment you first
// see him — rather than watching him pop in and getting a card about it
// afterwards.
gameState.presentBossIntro(archetype: "bossmage", name: boss.name)
gameState.enemies.append(boss)

// Narrative beats — these are loud on purpose. The player should
// feel that the fight just got serious.
gameState.addLog("☠️ SATO RISES — his blood magic was only the start.")
gameState.addLog("⚠️ BOSS — \(boss.name) (\(boss.maxHP) HP). Burn him down before he summons.")
gameState.presentBossIntro(archetype: "bossmage", name: boss.name)
HapticsManager.shared.combatStart()

// Bespoke boss-fight music. Falls back to the mission's track if the
Expand Down Expand Up @@ -1552,6 +1558,22 @@ struct CombatFlowController {
// its slot comes up — a corpse takes no turn.
if enemy.isAlive {
gameState.runEnemyAI(enemy: enemy, livingEnemies: livingEnemies)
// BOSS ACTION ECONOMY. A boss is one unit against a squad of
// four: the party takes four actions to its one, so it spent
// the fight walking and was dead before it could threaten
// anyone. Playtest 2026-07-25: "he moves a little, but didn't
// make any attacks" (M5) and "he just kinda sat there" (M3).
// A second activation lets a boss close AND fire in the same
// round, which is what makes it read as a boss rather than a
// slow elite. Still one unit, so the party keeps the edge.
if Self.isBossArchetype(enemy.archetype), enemy.isAlive,
!gameState.combatEnded {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) {
guard gameState.missionAttemptId == attempt,
!gameState.combatEnded, enemy.isAlive else { return }
gameState.runEnemyAI(enemy: enemy, livingEnemies: livingEnemies)
}
}
}
// Leave group only after the enemy's full move+attack animation
// window has played (move ≈0.35s + attack pose/VFX ≈0.4s).
Expand Down Expand Up @@ -1781,6 +1803,12 @@ struct CombatFlowController {
return true
}

/// Boss-class archetypes. These get a second activation per enemy phase —
/// see the action-economy note in the enemy-phase loop.
static func isBossArchetype(_ archetype: String) -> Bool {
archetype.lowercased().hasPrefix("boss")
}

/// Owner path for extraction mission completion resolution.
static func adjudicateExtractionIfEligible(gameState: GameState) {
let isMultiRoomMission = RoomManager.shared.currentMission != nil
Expand Down
8 changes: 7 additions & 1 deletion Game/EnemyAI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,13 @@ extension GameState {
// Design intent: rewards the player for spreading out.
let afterDist = hexDistance(x1: closestPlayer.positionX, y1: closestPlayer.positionY,
x2: enemy.positionX, y2: enemy.positionY)
if afterDist <= 6 {
// Autocannon reach. Was a hard 6, which combined with a 2-tile
// move budget meant a mech that started the fight across the
// arena never fired at all — it walked, ate four runners' worth
// of fire, and died (playtest 2026-07-25). 8 lets a heavy weapon
// threaten from where it actually spawns; the closing turns are
// still the party's window to hurt it for free.
if afterDist <= 8 {
let inBlast = livingPlayers.filter {
hexDistance(x1: $0.positionX, y1: $0.positionY,
x2: closestPlayer.positionX, y2: closestPlayer.positionY) <= 1
Expand Down
13 changes: 10 additions & 3 deletions Game/GameState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1579,12 +1579,14 @@ final class GameState: ObservableObject {
// room_2 wall cluster is now just a static obstacle you walk past, so
// no tile-conversion happens on boss deploy.)

// Cinematic reveal card FIRST, then the unit lands on the board — same
// ordering as the M3 Sato reveal, so the card is already rising as the
// sprite materialises behind it.
presentBossIntro(archetype: enemy.archetype, name: enemy.name)

enemies.append(enemy)
addLog("⚠️ HEAVY UNIT DEPLOYED — \(enemy.name)")

// Cinematic reveal card — slams in over the board, holds, auto-clears.
presentBossIntro(archetype: enemy.archetype, name: enemy.name)

// Visual + audio reveal — fire each notification with userInfo so
// the BattleScene observer can place sprite + run intro sequence.
NotificationCenter.default.post(
Expand Down Expand Up @@ -1945,6 +1947,11 @@ final class GameState: ObservableObject {
/// each enemy movement step. Returns the number of shots fired (0 or 1 per overwatcher).
/// One reaction shot per Overwatch action: the entry is consumed when the shot
/// resolves (fumble/miss/dodge included), not at round end.
///
/// Discardable: every AI call site fires this for its side effects (the shot
/// resolving) and has no use for the count, which was generating 18 identical
/// unused-result warnings and burying the real ones.
@discardableResult
func fireOverwatchShot(atEnemy enemy: Enemy, attackerId: UUID) -> Int {
guard let ovwPool = overwatchers[attackerId] else { return 0 }
// Only fire if enemy is in LOS with no wall blocking
Expand Down
17 changes: 5 additions & 12 deletions Game/MissionSetupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,11 @@ struct MissionSetupService {
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) {
// Uses the SAME lookup the room-entry path uses. A private copy lived
// here briefly; two implementations of "where is the extraction tile"
// is exactly how the backtrack-spawn bug happened (RoomManager and
// BattleScene each had their own answer and disagreed).
} else if let pad = GameState.firstExtractionTile(in: firstRoom.map) {
gameState.extractionX = pad.x
gameState.extractionY = pad.y
gameState.addLog("🚁 Extraction marker active — reach it when all rooms are clear!")
Expand Down Expand Up @@ -393,17 +397,6 @@ 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
41 changes: 29 additions & 12 deletions Game/MissionStatsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,26 @@ final class MissionStatsStore: ObservableObject {
if missionId.hasPrefix("Contract_t3") { return ContractStore.basePay(tier: 3) }
if missionId.hasPrefix("Contract_t2") { return ContractStore.basePay(tier: 2) }
if missionId.hasPrefix(ContractStore.contractIdPrefix) { return ContractStore.basePay(tier: 1) }
// 2026-07 pass: trimmed ~25% on top of the 2026-06 cut. Measured against
// the catalogue — one of every item is ¥111,000, and kitting all four
// runners is ~¥444,000. The old table totalled ¥228,000 base, which with
// the (then multiplicative) bonus stack and kill bounties banked ~¥570k
// across a campaign: enough to buy everything for everyone. These
// numbers plus the additive stack land a strong run near ~¥245,000 —
// about half the full-kit cost, so gear stays a real choice and you
// outfit a couple of runners properly rather than the whole team.
switch missionId {
case "Mission001": return 9_000
case "Mission002": return 16_000
case "Mission002_5": return 8_000 // mirrorline = solo astral bonus
case "Mission003": return 22_000
case "Mission003_5": return 6_000 // chase = clean exfil bonus
case "Mission004": return 28_000
case "Mission004_5": return 7_000 // basement brawl = solo melee bonus
case "Mission005": return 34_000
case "Mission005_5": return 18_000 // cold trace = solo decker run + Neural Imprint
case "Mission006": return 80_000 // finale payout (was a 500k placeholder)
default: return 9_000
case "Mission001": return 7_000
case "Mission002": return 12_000
case "Mission002_5": return 6_000 // mirrorline = solo astral bonus
case "Mission003": return 16_000
case "Mission003_5": return 4_500 // chase = clean exfil bonus
case "Mission004": return 21_000
case "Mission004_5": return 5_500 // basement brawl = solo melee bonus
case "Mission005": return 25_000
case "Mission005_5": return 13_000 // cold trace = solo decker run + Neural Imprint
case "Mission006": return 55_000 // finale payout (was a 500k placeholder)
default: return 7_000
}
}

Expand Down Expand Up @@ -266,7 +274,16 @@ final class MissionStatsStore: ObservableObject {
let firstClear = !paidThisRun.contains(missionId)
let runFactor = firstClear ? 1.0 : 0.25
if firstClear { paidThisRun.insert(missionId) }
let credit = Int((Double(contract) * rankMult * rewardMultiplier * runFactor).rounded())
// Rank and risk stack ADDITIVELY, not multiplicatively. Multiplied, a
// top clear ran 1.6 × 1.5 = 2.40× base — M5 paid ¥94,800 off a ¥34,000
// contract, and a full campaign banked ~¥570k against the ~¥444k it
// costs to buy every item for all four runners, so you could buy out
// the shop and still have change (playtest 2026-07-25: "the pay scale
// is kinda crazy"). Additive keeps both incentives legible — an S rank
// is still worth chasing and heat still pays for the risk — while
// capping the best case at 1.60× instead of 2.40×.
let bonusStack = 1.0 + (rankMult - 1.0) + (rewardMultiplier - 1.0)
let credit = Int((Double(contract) * bonusStack * runFactor).rounded())
playerNuyen += credit
// Expose the factors so the debrief can show WHY the number is what
// it is ("RANK S ×1.6 · HEAT ×1.25 · REPLAY 25%") instead of a bare
Expand Down
35 changes: 23 additions & 12 deletions Game/MusicManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -879,18 +879,29 @@ final class MusicManager {
let startVolume = p.volume
let startTime = Date()
fadeTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self, weak p] timer in
// Identity check: if the player this fade was started for has been
// replaced, the fade is stale — die without consuming the (new
// fade's) pending completion.
guard let self = self, let pp = p, self.player === pp else { timer.invalidate(); return }
let t = min(1.0, Date().timeIntervalSince(startTime) / duration)
pp.volume = startVolume + Float(t) * (target - startVolume)
if t >= 1.0 {
timer.invalidate()
self.fadeTimer = nil
let pending = self.pendingFadeCompletion
self.pendingFadeCompletion = nil
pending?()
// The timer body touches @MainActor state. `scheduledTimer` installs
// on the run loop it was created from, and `fade` is only reachable
// from this @MainActor class, so the callback IS on main — but the
// compiler can't prove that through the @Sendable closure and
// emitted four isolation warnings here. Assert the invariant instead
// of hopping (a hop would let the fade tick land a frame late and
// stutter the ramp); this traps loudly if it is ever false.
MainActor.assumeIsolated {
// Identity check: if the player this fade was started for has
// been replaced, the fade is stale — die without consuming the
// (new fade's) pending completion.
guard let self = self, let pp = p, self.player === pp else {
timer.invalidate(); return
}
let t = min(1.0, Date().timeIntervalSince(startTime) / duration)
pp.volume = startVolume + Float(t) * (target - startVolume)
if t >= 1.0 {
timer.invalidate()
self.fadeTimer = nil
let pending = self.pendingFadeCompletion
self.pendingFadeCompletion = nil
pending?()
}
}
}
}
Expand Down
21 changes: 14 additions & 7 deletions Game/SFXManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,11 @@ final class SFXManager {
let weaponType = note.userInfo?["weaponType"] as? String ?? "pistol"
let enemyArchetype = note.userInfo?["enemyArchetype"] as? String ?? ""
let clip: String
// Per-clip volume so SMG (which came out hotter than the rest)
// sits at parity. Other clips use SFXManager's default.
var clipVolume: Float? = nil
// SMG came out hotter than the rest of the pack, so it plays at a
// scaled fraction of the master volume rather than the default.
// Cumulative −15% × −20% × −15% × −10% ≈ 0.522; Raze's SMG was
// still hot on iPhone at anything above that.
var smgScale: Float? = nil
if enemyArchetype == "bossmech" {
clip = "mech_autocannon" // boss-class autocannon burst
} else if enemyArchetype == "bossagi" {
Expand All @@ -211,14 +213,19 @@ final class SFXManager {
} else {
switch weaponType {
case "rifle": clip = "gunshot_rifle"
case "smg":
clip = "gunshot_smg"
clipVolume = SFXManager.shared.targetVolume * 0.522 // cumulative −15% × −20% × −15% × −10% ≈ 0.522. Raze's SMG was still hot on iPhone.
case "smg": clip = "gunshot_smg"; smgScale = 0.522
case "pistol": clip = "gunshot_pistol"
default: clip = "gunshot_pistol"
}
}
Task { @MainActor in SFXManager.shared.play(clip, volume: clipVolume) }
// Resolve the volume against `targetVolume` INSIDE the main-actor
// hop. Reading the singleton out here tripped actor-isolation
// warnings from this @Sendable notification closure, and the value
// is only meaningful on the actor that owns it anyway.
Task { @MainActor in
let mgr = SFXManager.shared
mgr.play(clip, volume: smgScale.map { mgr.targetVolume * $0 })
}
}

// Melee swing — fires on every melee attempt (hit or miss). Unarmed
Expand Down
5 changes: 4 additions & 1 deletion Game/TurnManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,10 @@ final class Enemy: ObservableObject, Identifiable, Codable {
case "turret": return 0 // bolted down — never repositions
case "spider", "infiltrator": return 3 // fast flankers
case "riot", "mech", "juggernaut": return 1 // heavy/slow walls
case "bossmech": return 2 // boss-class mech advances faster than the regular grunt mech
// Playtest 2026-07-25: at 2 tiles the mech spent the whole fight walking
// and never got a shot off — the party out-ranged it and killed it on the
// approach. 3 lets it actually reach a firing position.
case "bossmech": return 3 // boss-class mech advances faster than the regular grunt mech
case "bossagi": return 3 // AGI phase-shifts — moves 3 tiles per turn
case "bossmage": return 3 // Sato glides fast — closes on the party instead of planting at range (playtest: "doesn't move much")
default: return 2
Expand Down
Loading
Loading