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
45 changes: 41 additions & 4 deletions Bolo 2026/Bolo 2026/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<JoinEvent>.Continuation?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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) }
)
Expand Down Expand Up @@ -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)) }
)
}
Expand Down
92 changes: 66 additions & 26 deletions Bolo 2026/Bolo 2026/SoundPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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..<count).compactMap { _ in NSSound(contentsOf: url, byReference: true) }
loadPool(name, count: count)
if let farName = Self.farNames[name] {
loadPool(farName, count: count)
}
}
}

/// Plays `name` on the first non-playing pool slot, silently doing nothing if every slot is
/// busy (matches `playsound()`'s own `for` loop exactly -- it never queues or interrupts,
/// just skips the sound if the whole pool is already in use) or if the name isn't in this
/// v1 slice's wired set. Checks `"GSMuteBool"` on every call, not once at init, so toggling
/// the preference mid-session takes effect immediately -- matching `playsound()`'s own
private func loadPool(_ name: String, count: Int) {
guard let url = Bundle.main.url(forResource: name, withExtension: "aiff") else { return }
pools[name] = (0..<count).compactMap { _ in NSSound(contentsOf: url, byReference: true) }
}

/// Plays `name` (or its far variant, if `near` is false and one exists) on the first
/// non-playing pool slot, silently doing nothing if every slot is busy (matches
/// `playsound()`'s own `for` loop exactly -- it never queues or interrupts, just skips the
/// sound if the whole pool is already in use) or if the resolved name isn't in this v1
/// slice's wired set. Checks `"GSMuteBool"` on every call, not once at init, so toggling the
/// preference mid-session takes effect immediately -- matching `playsound()`'s own
/// `if (!muteBool)` guard, evaluated fresh every call.
func play(_ name: String) {
func play(_ name: String, near: Bool = true) {
guard !UserDefaults.standard.bool(forKey: "GSMuteBool") else { return }
guard let pool = pools[name] else { return }
let resolvedName = (!near ? Self.farNames[name] : nil) ?? name
guard let pool = pools[resolvedName] else { return }
for sound in pool where !sound.isPlaying {
sound.play()
return
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ differential tests. See `LICENSE` for the full attribution chain and license ter

## Status

**Current tagged drop:** [`v1.5.0`](https://github.com/CosmicCEO/BoloKit/releases/tag/v1.5.0) — Hidden-mines fog-of-war. Playable host-and-join two-player LAN, with a live-play fix pass (guest fire/range/drown/mines, alliances, name sync — [#62](https://github.com/CosmicCEO/BoloKit/issues/62)/[#92](https://github.com/CosmicCEO/BoloKit/issues/92)/[#85](https://github.com/CosmicCEO/BoloKit/issues/85)) already merged to `main` and pending its `v1.5.1` tag once the remaining known issues close (see [`docs/STATUS.md`](docs/STATUS.md): [#77](https://github.com/CosmicCEO/BoloKit/issues/77), [#79](https://github.com/CosmicCEO/BoloKit/issues/79), [#107](https://github.com/CosmicCEO/BoloKit/issues/107), [#114](https://github.com/CosmicCEO/BoloKit/issues/114); [#105](https://github.com/CosmicCEO/BoloKit/issues/105) is fixed on `claude/coding-session-a9s35l` and waiting on a two-Mac check). Apple Development–signed, not notarized (right-click → Open). The app's fixed-port `NWListener` EINVAL is fixed; a bare bind can still fail on some machines and fall back to local-only play.
**Current tagged drop:** [`v1.6.0`](https://github.com/CosmicCEO/BoloKit/releases/tag/v1.6.0) — Metal terrain/sprite renderer. Playable host-and-join two-player LAN. `v1.6.1` (visual-polish milestone — [#143](https://github.com/CosmicCEO/BoloKit/pull/143)) is already merged to `main`. Merged but untagged: the full `v1.6.x` themed visual/play parity arc — man, tank, boat, sound (milestones 23–26, all closed) — plus the [#139](https://github.com/CosmicCEO/BoloKit/issues/139) host-engine crash fix. Sound parity (#149) is done; the related #150 distance-sound gap is only partially fixed (host path under Hidden Mines) and the rest deliberately parked (see [`docs/STATUS.md`](docs/STATUS.md)). Apple Development–signed, not notarized (right-click → Open). The app's fixed-port `NWListener` EINVAL is fixed; a bare bind can still fail on some machines and fall back to local-only play.

The C-oracle port is done. Open work lives on GitHub, not in wave numbers. Conventions: issue [#43](https://github.com/CosmicCEO/BoloKit/issues/43). Live board: [`docs/STATUS.md`](docs/STATUS.md).

Expand All @@ -29,7 +29,7 @@ The C-oracle port is done. Open work lives on GitHub, not in wave numbers. Conve
| **Sprint** | Two weeks, Monday start, due Friday of week 2. Skip US federal holidays. |
| **Project** | Grouping only. [1.\*](https://github.com/users/CosmicCEO/projects/1) = path to 2.0.0. [2.\*](https://github.com/users/CosmicCEO/projects/2) = Decide items. |

**Current patch:** [v1.5.1 Live play fixes](https://github.com/CosmicCEO/BoloKit/milestone/19), in flight. **Next release sprint:** [v1.6.0 Metal renderer](https://github.com/CosmicCEO/BoloKit/milestone/4), then v1.7 gameplay packs, v1.8 LAN/WAN discovery. **2.0.0** is the tag when that path is done.
**Next release:** the completed `v1.6.x` parity work above is pending a version-number decision and tag, then v1.7 gameplay packs, v1.8 LAN/WAN discovery. **2.0.0** is the tag when that path is done.

Engineering rules: `docs/CONSTRAINTS.md`. Wave history: git tag `legacy-agent-process`.

Expand Down
2 changes: 1 addition & 1 deletion Sources/BoloGlyphsCore/AppIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public func buildAppIconBase() -> Canvas16 {
// exists so a future change there fails loudly here rather than silently recolouring.
preconditionFailure("GRAS00IMAGE no longer resolves to .grass in tileGlyphRole")
}
let tank = renderGlyph(.tank(heading: 0, ownership: 0, destroyed: false))
let tank = renderGlyph(.tank(heading: 0, ownership: 0, boat: false, destroyed: false))
composite(tank, over: &canvas)
return canvas
}
Expand Down
Loading
Loading