diff --git a/Bolo 2026/Bolo 2026/GameSession.swift b/Bolo 2026/Bolo 2026/GameSession.swift index 5429962..cd1b858 100644 --- a/Bolo 2026/Bolo 2026/GameSession.swift +++ b/Bolo 2026/Bolo 2026/GameSession.swift @@ -106,6 +106,15 @@ public final class GameSession { private let ticksSinceLastUpdate: [UInt64] private var timer: DispatchSourceTimer? private let hostEngine: HostGameEngine? + /// #139: the host path's own last-rendered `GameState` snapshot, kept in step with + /// `onTickRendered` below instead of `adminState`/`liveState` reading `hostEngine.state` + /// directly. `HostGameEngine.state` is a plain stored property mutated off-main by the + /// engine's tick-loop `Task` (see its own header on `onTickRendered`: "a value-type + /// snapshot, never the live `state` itself, which only the consumer `Task` may ever + /// touch") -- reading it synchronously from this `@MainActor` class raced that mutation + /// and corrupted `Array` refcounts on `GameState.local`, crashing with `SIGABRT` after a + /// long-running host session. This snapshot is `@MainActor`-only, so it's race-free. + private var hostLiveState: GameState? private let tcpSession: TCPSession? private let udpSession: UDPSession? private var joinContinuation: AsyncStream.Continuation? @@ -196,6 +205,7 @@ public final class GameSession { /// driving a second, competing tick loop against a second copy of it. public init(hostEngine: HostGameEngine, tilesImage: CGImage, spritesImage: CGImage) { self.state = hostEngine.state + self.hostLiveState = hostEngine.state self.ticksSinceLastUpdate = [] self.hostEngine = hostEngine self.tcpSession = nil @@ -224,10 +234,14 @@ public final class GameSession { view?.render(renderedState, fogState: hostRenderFogState( engineFog: hostEngine?.fogState(for: renderedState.localPlayer), hiddenMines: renderedState.hiddenMines)) self?.hudSnapshot.update(from: renderedState) + self?.hostLiveState = renderedState } hostEngine.onMessageReceived = { [weak self] message in self?.messages.append(message) } + // #149: a real networked host had no sound wiring at all before this -- see + // `HostGameEngine.onShouldPlaySound`'s own doc comment and `SoundPlayer.swift`'s header. + hostEngine.onShouldPlaySound = { name, near in SoundPlayer.shared.play(name, near: near) } controllerInput = GameControllerInputHandler(renderView: view) } @@ -327,13 +341,14 @@ public final class GameSession { /// `submitUnbanPlayer`); this is the app-side surface. public var canHostAdmin: Bool { hostEngine != nil } - /// Live host `GameState` when hosting; otherwise this session's own copy. - private var adminState: GameState { hostEngine?.state ?? state } + /// Live host `GameState` when hosting; otherwise this session's own copy. `hostLiveState`, + /// not `hostEngine?.state` (#139: the latter races the engine's tick-loop `Task`). + private var adminState: GameState { hostLiveState ?? state } /// The state panels should display: the engine's live state on the host path, `state` otherwise. /// On the host path `state` is a frozen one-time snapshot (see this file's header), so a panel /// reading it directly never sees a guest join, leave or ally. - public var liveState: GameState { hostEngine?.state ?? state } + public var liveState: GameState { hostLiveState ?? state } public var isServerPaused: Bool { adminState.serverPauseTicks != 0 || adminState.clientPauseDisplaySeconds != 0 @@ -662,7 +677,20 @@ public final class GameSession { // teleport), so its own dead-tank branch (respawn counter, `spawn`) must not run. let thinning = JoinTickThinning(hostSimulatesMe: hostSimulatesMe) if thinning.runsOwnMovementWhileDead || !state.players[localPlayer].dead { - tankMoveTick(player: localPlayer, state: &state) + // #149: this client had zero SoundPlayer wiring at all before now (confirmed by + // grep, not merely out of scope) -- these three closures mirror the solo path's + // own wiring (`tick()` below) for this join client's own locally-predicted death/ + // explosion sequence. No fog data exists on this path (no FogState tracked here), + // so always "near" -- same disclosed limitation as the solo path. + tankMoveTick( + player: localPlayer, state: &state, + onExplosion: { _ in SoundPlayer.shared.play("explosion") }, + onSuperboom: { SoundPlayer.shared.play("superboom") }, + onSmallboom: { SoundPlayer.shared.play("explosion") }, + onMineExplosion: { _ in SoundPlayer.shared.play("explosion") }, + onBuilderDeath: { SoundPlayer.shared.play("builderdeath") }, + onSuperboomTerrain: { _ in SoundPlayer.shared.play("superboom") } + ) } // B.10 (D127): read-only detect-and-send analogue of `enter()`'s pill/base/ @@ -701,6 +729,9 @@ public final class GameSession { ? state.players[localPlayer].builderStatus : nil let builderOutbound = builderTick( player: localPlayer, state: &state, + onMineExplosion: { _ in SoundPlayer.shared.play("explosion") }, + onTreeHarvest: { _ in SoundPlayer.shared.play("tree") }, + onBuild: { _ in SoundPlayer.shared.play("build") }, joinArrive: { player, state in detectJoinBuilderArrival(player: player, state: state) }, onPrintMessage: { builderNeed.append($0) } ) @@ -746,6 +777,12 @@ public final class GameSession { if thinning.runsOwnShellTick { shellTick( player: localPlayer, state: &state, + onMineExplosion: { _ in SoundPlayer.shared.play("explosion") }, + onBuilderDeath: { SoundPlayer.shared.play("builderdeath") }, + onSuperboomTerrain: { _ in SoundPlayer.shared.play("superboom") }, + onHitTank: { SoundPlayer.shared.play("hittank") }, + onHitTerrain: { _ in SoundPlayer.shared.play("hitterrain") }, + onHitTree: { _ in SoundPlayer.shared.play("hittree") }, onSelfReportDamage: { x, y, boat in shellDamageOutbound.append((x, y, boat)) } ) } diff --git a/Bolo 2026/Bolo 2026/SoundPlayer.swift b/Bolo 2026/Bolo 2026/SoundPlayer.swift index 5d5471c..5b015c6 100644 --- a/Bolo 2026/Bolo 2026/SoundPlayer.swift +++ b/Bolo 2026/Bolo 2026/SoundPlayer.swift @@ -32,24 +32,44 @@ // branch), and `onPillShot` (`PillTick.swift`'s `emitPillShell`). Fog-dependent `far*` variants // remain out of scope (see below) -- these are all "near" names. // -// **`far*` variants are explicitly NOT wired.** The reference's own near/far choice is a -// `client.fog[y][x] > 0` check (`client.c:1368` and elsewhere) -- fog-of-war/seen-tiles is out -// of v1 scope entirely (D65: "treat every tile as fully visible"), so there is no real signal to -// compute near-vs-far from yet. +// **#149/#150 (v1.6.5 Sound Parity): `far*` variants are now wired, host path only, and the** +// **real networked host/guest silence bug is fixed.** Two real, confirmed findings superseded +// the paragraph this replaces: // -// **Not every sound wired here is the local player's own action.** `onTankShot`/`onTreeHarvest`/ -// `onMine` are local-player-only (`TankLocalTick.swift` only ever runs for `state.localPlayer`). -// But `onHitTank`/`onBuild`/`onBuilderDeath`/`onSink`/`onPillShot` fire for *any* connected -// player's shells/builder/tank/pillbox -- `shellTick`'s tank-hit loop, `builderTick`'s per-player -// call, and the mine-detonation/splash-damage chain (`explosionAt`/`superboomAt`) all run once -// per player, not just the local one. With fog out of scope (D65: every tile fully visible), -// there's no near/far signal to gate these on, so the local player now hears every player's hit/ -// build/death/sink/pill-shot map-wide, not just their own -- a real behavior change from the -// four sounds wired before this pass, all of which happened to be local-player-only. Remote- -// player-triggered sounds are still not wired for the *join* client specifically: this port's -// join-mode client doesn't run the callback-bearing `runTick` at all (D116 -- see -// `GameSession.swift`'s own B.8 header), so a join client only ever hears its own locally- -// predicted actions regardless of this pass. +// 1. `GameSession.tick()` (this file's only caller before #149) has exactly one caller of its +// own: the single-process/solo `DispatchSource` timer. A REAL networked host +// (`HostGameEngine`) never called `SoundPlayer` at all -- `hostEngine.start()` runs its own +// internal tick loop, entirely separate from this file's `tick()`, and had no sound callback +// of any kind. Fixed: `HostGameEngine.onShouldPlaySound` (`Sources/BoloNet/HostGameEngine.swift`) +// now fires the same 15 names this file already knew about, wired at the host `init` below. +// A joined guest was *also* completely silent (zero `SoundPlayer` calls existed on the join +// path before this pass, confirmed by grep, not merely under-scoped) -- fixed for the guest's +// own locally-predicted actions (`tankMoveTick`/`builderTick`/`shellTick`, all three already +// called in `handleJoinEvent`'s `.tick` case). `onTankShot`/`onMine`/`onBubbles`/`onSink`/ +// `onPillShot` remain unreachable on the join path -- those need `tankLocalTick`/`pillTick` +// itself, which D116 deliberately keeps off this path (see `GameSession.swift`'s own B.8 +// header); not expanded here. +// +// 2. `far*` near/far selection: the reference's own choice is a `client.fog[y][x] > 0` check +// (`client.c:1368` and elsewhere) -- a real, always-active vision-radius system (tank/pillbox/ +// alliance vision boxes), unrelated to the separate `hiddenmines` flag. This port's own +// equivalent (`FogState`/`isFog`, `CalcVis.swift`) is currently maintained ONLY when +// `state.hiddenMines` is on, and ONLY on the host path (`HostGameEngine.fogStates`) -- so +// near/far selection here is real and oracle-faithful whenever Hidden Mines is on, for the 7 +// location-bearing hooks (`onMineExplosion`/`onSuperboomTerrain`/`onExplosion`/ +// `onTreeHarvest`/`onHitTerrain`/`onHitTree`/`onBuild`); the other 8 hooks have no location +// in their `runTick` signature at all and stay "near" always (disclosed, narrower remaining +// gap, see #150). With Hidden Mines off (this port's default, matching D65's "every tile +// fully visible" v1 decision) or on the solo/join paths (no `FogState` tracked there at all), +// everything still plays "near" regardless of distance -- this is the real, larger, +// already-known vision-system gap D65 disclosed, not something #149/#150 silently claims to +// have closed. +// +// `play(_:near:)` picks the far name via a local reverse of `SoundSetBuilder.swift`'s +// `farNameSources` (duplicated here, not imported, since the app target doesn't currently link +// `BoloSoundsCore` as a runtime dependency -- keep the two lists in sync by hand); a near name +// with no far entry (`mine`/`pillshot`/`bubbles`/`msgreceived` -- matches the oracle, which has +// no `kFarMineSound` etc. either) always plays its near clip regardless of `near`. // import AppKit @@ -68,24 +88,44 @@ final class SoundPlayer { "tree": 2, "bubbles": 2, "sink": 1, "msgreceived": 1, ] + /// #150: near-name -> far-name, a local copy of `SoundSetBuilder.swift`'s own + /// `farNameSources` (reversed) -- see this file's header for why it's duplicated, not + /// imported. `fshot` covers both `tankshot`'s and `pillshot`'s far variant (D122, no + /// separate `fpillshot`); a near name absent here (`mine`/`pillshot`/`bubbles`/ + /// `msgreceived`) has no far variant at all, matching the oracle. + private static let farNames: [String: String] = [ + "explosion": "fexplosion", "superboom": "fsuperboom", "hittank": "fhittank", + "hitterrain": "fhitterrain", "hittree": "fhittree", "build": "fbuild", + "builderdeath": "fbuilderdeath", "tree": "ftree", "sink": "fsink", "tankshot": "fshot", + ] + private var pools: [String: [NSSound]] = [:] private init() { for (name, count) in Self.poolSizes { - guard let url = Bundle.main.url(forResource: name, withExtension: "aiff") else { continue } - pools[name] = (0..