From 707cdc231fd9049e13cdb03b75152f1bfcf10883 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 20:08:06 -0500 Subject: [PATCH 01/14] docs: 1.6.x visual/play parity roadmap (man/tank/boat/sound) Milestone 23 (man) scoped with #145/#146 from a live code-vs-oracle audit. Milestones 24-26 (tank/boat/sound) scaffolded but empty -- checked the full open-issue backlog and nothing fits; each needs its own audit pass before real issues can be filed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index f54c6a6..ea8bf59 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -14,7 +14,46 @@ Wave-by-wave history and the retired four-role process live at git tag `legacy-agent-process`. Do not restore those files. -## Next sprint — milestone 20 `v1.6.x — Rejoin fix`, re-scoped 2026-09-23 +## Roadmap ruling (Jerod, 2026-09-23): 1.6.* is a visual + play parity arc + +Every `v1.6.*` release from here on targets visual/play parity with the original, one theme +per release: **man** (the builder/"little green man"), **tank**, **boat**, **sound** -- +`v1.6.1` (sprite chrome/terrain) already covered general polish; the next four are themed. + +**Active sprint: [milestone 23](https://github.com/CosmicCEO/BoloKit/milestone/23), `v1.6.2 — +Little Green Man Parity`** -- takes priority over milestone 20 below. Investigated parachute-in/spawn behavior against `Reference/c` first -- +state machine (`killBuilder` → `.parachute` → `parachuteTick` → `.return`, +`TankLocalTick.swift:121-152`/`BuilderTick.swift:864-876`) matches `killbuilder()`/ +`kBuilderParachute` line-for-line, including initial spawn defaulting to `.ready` (no +parachute on first spawn) exactly like `client.c:265/395/735` -- no work needed there. Two +real gaps, both filed: +- [#146](https://github.com/CosmicCEO/BoloKit/issues/146) -- the builder sprite itself has no + visual identity (`GlyphSource.swift:74-76`'s `.builder(frame)` is a plain growing square for + all 3 frames, walk-cycle and parachute alike; the frame-selection logic is already a + faithful `GSBoloView.m:296-389` port, it's purely the art that's a placeholder). +- [#145](https://github.com/CosmicCEO/BoloKit/issues/145) -- tank-crush-builder, a disclosed + new mechanic (not in the oracle, see the issue). Scoped: reuses `tankCollision`'s + `testAlliance` check, enemy-tile-overlap triggers `killPointBuilder`/`killSquareBuilder`, + no new sound work (`kBuilderDeathSound` already wired). + +Related but deliberately not pulled forward: [#138](https://github.com/CosmicCEO/BoloKit/issues/138) +(builder movement after death looks unrouted) stays in its own `v1.9.0 — Builder logic` +milestone -- it's a movement-smoothing/gameplay-logic issue, not a sprite/visual one. + +**Scaffolded, not yet scoped:** [milestone 24](https://github.com/CosmicCEO/BoloKit/milestone/24) +`v1.6.3 — Tank Parity`, [25](https://github.com/CosmicCEO/BoloKit/milestone/25) `v1.6.4 — Boat +Parity`, [26](https://github.com/CosmicCEO/BoloKit/milestone/26) `v1.6.5 — Sound Parity`. +Checked the full open-issue backlog (39 issues, 2026-09-23) for anything that fits -- nothing +does. The closest candidates, [#7](https://github.com/CosmicCEO/BoloKit/issues/7) (fire while +standing on a captured base) and [#5](https://github.com/CosmicCEO/BoloKit/issues/5) (explosion +owner attribution), are both explicitly parked `Decide:` rulings ("not a sprint," "do not fix +without a ruling") -- left alone rather than force-fit. #145/#146 above didn't come from the +backlog either; they came from a live code-vs-`Reference/c` audit of the "man" theme. Each of +tank/boat/sound needs that same audit (trace the relevant state machine/collision/sound-hook +code against the oracle, confirm what's already correct, find the real gaps) before real issues +can be filed -- not a sprint on its own, but the prerequisite to scoping each one. + +## Queued next — milestone 20 `v1.6.x — Rejoin fix`, re-scoped 2026-09-23 Triaged the milestone's 9 open issues against what actually shipped in 1.5.1 and the v1.6.0 Metal-renderer switch. User confirmed (live troubleshooting during 1.6.0-1.6.1, not From 54fed1d3a6255471daf8ad1c7090a0db17d49c0c Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 20:20:59 -0500 Subject: [PATCH 02/14] #146: builder sprite has a humanoid silhouette, not a placeholder square BUILD0/BUILD1 (walk cycle) render a head/torso/arms/legs figure with stride alternating between the two frames; BUILD2 (parachute) renders a dome canopy over a smaller hanging figure. Covered by a new test asserting all three frames are non-empty and structurally distinct. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- Sources/BoloGlyphsCore/GlyphSource.swift | 57 +++++++++++++++++++++++- Tests/BoloKitTests/BoloGlyphsTests.swift | 19 ++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/Sources/BoloGlyphsCore/GlyphSource.swift b/Sources/BoloGlyphsCore/GlyphSource.swift index 73d3c44..4269bc9 100644 --- a/Sources/BoloGlyphsCore/GlyphSource.swift +++ b/Sources/BoloGlyphsCore/GlyphSource.swift @@ -72,8 +72,14 @@ public func renderGlyph(_ role: GlyphRole) -> Canvas16 { let radius = 2.0 + Double(frame) * 1.3 c.fillRing(cx: 8, cy: 8, inner: max(0, radius - 2), outer: radius, 255, 140, 30) case .builder(let frame): - let s = 3 + frame - c.fillRect(8 - s, 8 - s, 8 + s, 8 + s, 160, 160, 60) + // #146: frames 0/1 are `GameRenderView.drawBuilder`'s BUILD0/BUILD1 walk-cycle + // alternation, frame 2 is BUILD2 (parachute-in). A humanoid silhouette so the + // "little green man" reads as a person, not a growing placeholder square. + if frame == 2 { + drawBuilderParachute(&c) + } else { + drawBuilderWalking(&c, strideFlip: frame == 1) + } case .crosshair: c.fillRect(7, 0, 9, 16, 255, 255, 255) c.fillRect(0, 7, 16, 9, 255, 255, 255) @@ -460,6 +466,53 @@ private func drawBase(_ c: inout Canvas16, ownership: BaseOwnership) { c.fillRect(7, 10, 9, 14, 20, 20, 20) } +/// #146: an upright walking figure -- round head, torso, arms, and two legs whose stride +/// offsets swap between the two frames `drawBuilder` alternates between (BUILD0/BUILD1), +/// giving the same "legs moving" read the reference's own alternation implies. No heading +/// input (unlike `drawTank`) -- the reference's builder sprite has no directional art either, +/// it always faces the same way regardless of travel direction. +private func drawBuilderWalking(_ c: inout Canvas16, strideFlip: Bool) { + let skin: (UInt8, UInt8, UInt8) = (210, 190, 150) + let suit: (UInt8, UInt8, UInt8) = (60, 150, 70) + c.fillCircle(cx: 8, cy: 3.5, radius: 1.6, skin.0, skin.1, skin.2) + c.fillRect(6, 5, 10, 11, suit.0, suit.1, suit.2) + c.fillRect(4, 6, 6, 9, suit.0, suit.1, suit.2) + c.fillRect(10, 6, 12, 9, suit.0, suit.1, suit.2) + if strideFlip { + c.fillRect(5, 11, 7, 16, suit.0, suit.1, suit.2) + c.fillRect(9, 11, 11, 15, suit.0, suit.1, suit.2) + } else { + c.fillRect(5, 11, 7, 15, suit.0, suit.1, suit.2) + c.fillRect(9, 11, 11, 16, suit.0, suit.1, suit.2) + } +} + +/// #146: a dome canopy over a smaller hanging figure with two suspension-cord pixels per +/// side -- distinct in silhouette from the walking pose, for BUILD2 (`.parachute` status). +private func drawBuilderParachute(_ c: inout Canvas16) { + let canopy: (UInt8, UInt8, UInt8) = (200, 200, 60) + let suit: (UInt8, UInt8, UInt8) = (60, 150, 70) + let skin: (UInt8, UInt8, UInt8) = (210, 190, 150) + let cord: (UInt8, UInt8, UInt8) = (230, 230, 230) + let cx = 8.0, cy = 3.0, r = 7.0 + for y in 0..<5 { + for x in 0..<16 { + let dx = Double(x) + 0.5 - cx + let dy = Double(y) + 0.5 - cy + if dx * dx + dy * dy <= r * r { + c.set(x, y, canopy.0, canopy.1, canopy.2) + } + } + } + c.set(3, 5, cord.0, cord.1, cord.2) + c.set(4, 6, cord.0, cord.1, cord.2) + c.set(12, 5, cord.0, cord.1, cord.2) + c.set(11, 6, cord.0, cord.1, cord.2) + c.fillCircle(cx: 8, cy: 9, radius: 1.3, skin.0, skin.1, skin.2) + c.fillRect(7, 10, 9, 14, suit.0, suit.1, suit.2) + c.fillRect(6, 14, 10, 16, suit.0, suit.1, suit.2) +} + private func tankPalette(_ ownership: Int) -> (UInt8, UInt8, UInt8) { switch ownership { case 0: return (220, 220, 220) diff --git a/Tests/BoloKitTests/BoloGlyphsTests.swift b/Tests/BoloKitTests/BoloGlyphsTests.swift index 68f541e..c86c216 100644 --- a/Tests/BoloKitTests/BoloGlyphsTests.swift +++ b/Tests/BoloKitTests/BoloGlyphsTests.swift @@ -260,6 +260,25 @@ struct BoloGlyphsTests { } } + // MARK: - Builder sprite identity (#146) + + @Test("builder walk frames and the parachute frame each render a non-empty, distinct silhouette") + func builderFramesAreDistinctAndNonEmpty() { + func opaquePixelCount(_ patch: Canvas16) -> Int { + stride(from: 3, to: patch.pixels.count, by: 4).filter { patch.pixels[$0] != 0 }.count + } + let frames = (0...2).map { renderGlyph(.builder(frame: $0)) } + for (frame, patch) in frames.enumerated() { + #expect(opaquePixelCount(patch) > 0, "builder frame \(frame) rendered no pixels") + } + // The two walk frames (BUILD0/BUILD1) must differ from each other -- that's the whole + // point of the alternation `GameRenderView.drawBuilder` drives off tick parity. + #expect(frames[0].pixels != frames[1].pixels) + // The parachute frame (BUILD2) is a structurally different pose, not just a third walk + // frame -- its opaque pixel count should differ from either walk frame's. + #expect(opaquePixelCount(frames[2]) != opaquePixelCount(frames[0])) + } + // MARK: - Shell heading convention (v1.5.1 #114) // // Issue #114: shells vanished for their whole flight when fired toward one of the 10 From cc7967b289a6edf78966a189950c6853df652dc4 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 20:21:06 -0500 Subject: [PATCH 03/14] #145: tank crushes a hostile builder sharing its tile Disclosed departure from Reference/c (tankcollision() has zero builder- position awareness, and no oracle call site kills a builder via tank contact -- only via explosion radius). Added per Jerod's memory of this mechanic in the original Stuart Cheshire game. tankCrushBuilder() checks exact tile overlap, gated by testAlliance so a tank never crushes its own or an allied builder, and reuses killBuilder for the actual kill/respawn. Wired into tankMoveTick's alive branch after terrain collision resolves. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- Sources/BoloKit/TankTick.swift | 50 +++++++++++++++ Tests/BoloKitTests/TankTickTests.swift | 88 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/Sources/BoloKit/TankTick.swift b/Sources/BoloKit/TankTick.swift index d78a2f3..fb332f2 100644 --- a/Sources/BoloKit/TankTick.swift +++ b/Sources/BoloKit/TankTick.swift @@ -54,6 +54,49 @@ public func tankCollision(owner: Int, state: GameState) -> (Pointi) -> Bool { } } +// MARK: - tankCrushBuilder + +/// #145: a tank occupying the same tile as a hostile (non-allied) connected player's active +/// builder crushes it instantly. +/// +/// Not in `Reference/c` — `tankcollision()` above has zero builder-position awareness, and no +/// `Reference/c` call site kills a builder via tank contact (only via explosion radius, see +/// `killPointBuilder`). Filed as a disclosed departure from the oracle (issue #145, v1.6.2 — +/// see [[project_reference_c_is_approximation]]): the user's own memory of the original Stuart +/// Cheshire game placed this mechanic on large maps, and a real memory of original gameplay can +/// justify a disclosed new feature even when the oracle itself doesn't show it. +/// +/// Deliberately narrower than `killPointBuilder`/`killSquareBuilder`'s explosion-radius check: +/// exact tile overlap only, no armour/radius grace, gated by `testAlliance` (mirroring +/// `tankCollision`'s own alliance-gated base check above) so a tank never crushes its own or an +/// allied builder. +public func tankCrushBuilder( + owner: Int, + state: inout GameState, + onShouldBroadcastDropPill: (Int, Int, Int) -> Void = { _, _, _ in }, + onBuilderDeath: () -> Void = {} +) { + let tank = state.players[owner].tank + let tile = Pointi(x: Int32(tank.x), y: Int32(tank.y)) + for victim in state.players.indices where state.players[victim].connected && victim != owner { + guard !testAlliance(victim, owner, players: state.players) else { continue } + switch state.players[victim].builderStatus { + case .goto, .work, .wait, .return: + let builderTile = Pointi( + x: Int32(state.players[victim].builder.x), y: Int32(state.players[victim].builder.y) + ) + if builderTile == tile { + killBuilder( + player: victim, state: &state, onShouldBroadcastDropPill: onShouldBroadcastDropPill, + onBuilderDeath: onBuilderDeath + ) + } + case .ready, .parachute: + break + } + } +} + // MARK: - tankMoveTick /// Per-tick tank physics for one player: dead-tumble/death-event/respawn @@ -365,4 +408,11 @@ public func tankMoveTick( state.players[player].tank = collisionDetect( state.players[player].tank, radius: tankRadius, isSolid: tankCollision(owner: player, state: state) ) + + // 8. #145: crush any hostile builder now sharing this tank's tile (disclosed departure + // from the oracle — see `tankCrushBuilder`'s own doc comment). + tankCrushBuilder( + owner: player, state: &state, onShouldBroadcastDropPill: onShouldBroadcastDropPill, + onBuilderDeath: onBuilderDeath + ) } diff --git a/Tests/BoloKitTests/TankTickTests.swift b/Tests/BoloKitTests/TankTickTests.swift index c92403b..4b3f50e 100644 --- a/Tests/BoloKitTests/TankTickTests.swift +++ b/Tests/BoloKitTests/TankTickTests.swift @@ -98,6 +98,94 @@ private func connectedPlayer(dead: Bool = false) -> PlayerState { #expect(!isSolid(Pointi(x: 6, y: 6))) } +// MARK: - tankCrushBuilder (#145) + +@Test func tankCrushBuilderKillsHostileBuilderSharingTheTankTile() { + var owner = connectedPlayer() + owner.tank = Vec2f(x: 50.5, y: 50.5) + var victim = connectedPlayer() + victim.builderStatus = .work + victim.builder = Vec2f(x: 50.5, y: 50.5) + var state = GameState() + state.players = [owner, victim] + state.localPlayer = 0 + state.starts = [Start(x: 10, y: 20, dir: 0)] + + var deathFired = false + tankCrushBuilder(owner: 0, state: &state, onBuilderDeath: { deathFired = true }) + + #expect(state.players[1].builderStatus == .parachute) + #expect(deathFired) +} + +@Test func tankCrushBuilderSparesAlliedBuilderOnTheSameTile() { + var owner = connectedPlayer() + owner.tank = Vec2f(x: 50.5, y: 50.5) + owner.used = true + owner.alliance = 1 << 1 + var ally = connectedPlayer() + ally.builderStatus = .work + ally.builder = Vec2f(x: 50.5, y: 50.5) + ally.used = true + ally.alliance = 1 << 0 + var state = GameState() + state.players = [owner, ally] + state.localPlayer = 0 + + tankCrushBuilder(owner: 0, state: &state) + + #expect(state.players[1].builderStatus == .work) +} + +@Test func tankCrushBuilderSparesBuilderOnADifferentTile() { + var owner = connectedPlayer() + owner.tank = Vec2f(x: 50.5, y: 50.5) + var victim = connectedPlayer() + victim.builderStatus = .work + victim.builder = Vec2f(x: 80.5, y: 80.5) + var state = GameState() + state.players = [owner, victim] + state.localPlayer = 0 + + tankCrushBuilder(owner: 0, state: &state) + + #expect(state.players[1].builderStatus == .work) +} + +@Test func tankCrushBuilderSparesAReadyOrParachutingBuilder() { + var owner = connectedPlayer() + owner.tank = Vec2f(x: 50.5, y: 50.5) + var victim = connectedPlayer() + victim.builderStatus = .ready + victim.builder = Vec2f(x: 50.5, y: 50.5) + var state = GameState() + state.players = [owner, victim] + state.localPlayer = 0 + + tankCrushBuilder(owner: 0, state: &state) + + #expect(state.players[1].builderStatus == .ready) +} + +@Test func tankMoveTickAliveBranchCrushesAHostileBuilderOnArrival() { + var owner = connectedPlayer() + owner.tank = Vec2f(x: 49.5, y: 50.5) + owner.inputFlags = [] + owner.speed = 0 + var victim = connectedPlayer() + victim.builderStatus = .work + victim.builder = Vec2f(x: 49.5, y: 50.5) + var state = GameState() + state.players = [owner, victim] + state.localPlayer = 0 + state.starts = [Start(x: 10, y: 20, dir: 0)] + for x in 45...55 { state.terrain[x, 50] = .grass0 } + + tankMoveTick(player: 0, state: &state) + + #expect(state.players[1].builderStatus == .parachute) +} + // MARK: - tankMoveTick: dead-tank branch @Test func tankMoveTickIgnoresDisconnectedPlayer() { From 9cfbb0a21a692da082818f0b6ca62c608ee88ad3 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 20:21:30 -0500 Subject: [PATCH 04/14] docs: v1.6.2 (#145/#146) implemented Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/STATUS.md b/docs/STATUS.md index ea8bf59..93913f3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -36,6 +36,15 @@ real gaps, both filed: `testAlliance` check, enemy-tile-overlap triggers `killPointBuilder`/`killSquareBuilder`, no new sound work (`kBuilderDeathSound` already wired). +**Both implemented, 2026-09-23** (commits `54fed1d`/`cc7967b`): #146's `.builder(frame)` now +draws a walking-figure silhouette for BUILD0/BUILD1 (stride alternates between the two) and a +parachute-canopy silhouette for BUILD2, instead of a placeholder growing square. #145 added +`tankCrushBuilder()` (`TankTick.swift`) -- exact tile overlap, `testAlliance`-gated, wired into +`tankMoveTick`'s alive branch right after terrain collision resolves each tick; reuses +`killBuilder` for the actual kill/respawn-as-parachute. Both covered by new tests +(`BoloGlyphsTests`/`TankTickTests`); full `swift test` and `xcodebuild build -scheme "Bolo 2026"` +green (the two pre-existing timing/socket flakes noted above still pass in isolation). + Related but deliberately not pulled forward: [#138](https://github.com/CosmicCEO/BoloKit/issues/138) (builder movement after death looks unrouted) stays in its own `v1.9.0 — Builder logic` milestone -- it's a movement-smoothing/gameplay-logic issue, not a sprite/visual one. From b0ca74848f279770b5768a65ce4f3d205029bda0 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 20:34:37 -0500 Subject: [PATCH 05/14] #147: boated tank renders a distinct hull, not the land triangle+barrel GlyphRole.tank gained a boat: Bool parameter, threaded from ImageIndex.swift's already-correct PTKB/FTKB/ETKB vs PTNK/FTNK/ETNK row selection (which previously dispatched both rows to the identical glyph). drawBoatHull reuses the land tank's bow triangle but adds a wide flat stern deck (negative-length fillRotatedBar) and a thin waterline band instead of the forward gun barrel. Covered by two new tests confirming the hull differs from the land tank and still follows dir2vec. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- Sources/BoloGlyphsCore/AppIcon.swift | 2 +- Sources/BoloGlyphsCore/GlyphSource.swift | 41 +++++++++++++++++------- Sources/BoloGlyphsCore/ImageIndex.swift | 3 +- Tests/BoloKitTests/BoloGlyphsTests.swift | 28 +++++++++++++--- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/Sources/BoloGlyphsCore/AppIcon.swift b/Sources/BoloGlyphsCore/AppIcon.swift index 885f177..b95e9d4 100644 --- a/Sources/BoloGlyphsCore/AppIcon.swift +++ b/Sources/BoloGlyphsCore/AppIcon.swift @@ -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 } diff --git a/Sources/BoloGlyphsCore/GlyphSource.swift b/Sources/BoloGlyphsCore/GlyphSource.swift index 4269bc9..374d20e 100644 --- a/Sources/BoloGlyphsCore/GlyphSource.swift +++ b/Sources/BoloGlyphsCore/GlyphSource.swift @@ -16,8 +16,10 @@ public enum GlyphRole: Sendable { case swamp case mine case pill(armor: Int, ownership: BaseOwnership) - /// `ownership`: 0 = player, 1 = friendly, 2 = enemy. - case tank(heading: Int, ownership: Int, destroyed: Bool) + /// `ownership`: 0 = player, 1 = friendly, 2 = enemy. `boat`: #147 -- the tank is currently + /// boated (`PlayerState.boat`), matching the reference's separate `PTKB`/`FTKB`/`ETKB` + /// sprite row (`ImageIndex.swift`'s `spriteGlyphRole`) with a distinct hull shape. + case tank(heading: Int, ownership: Int, boat: Bool, destroyed: Bool) /// v1.5.1 #114: `heading` is one of the 16 `headingColumn` buckets (`PhysicsOps.swift`), /// matching the C reference's real `Sprites.png` asset -- not an animation frame index /// (the prior `frame` name/semantics only covered 6 of the 16 indices `headingColumn` @@ -57,8 +59,8 @@ public func renderGlyph(_ role: GlyphRole) -> Canvas16 { c.fillCircle(cx: 8, cy: 8, radius: 3, 200, 30, 30) case .pill(let armor, let ownership): drawPill(&c, armor: armor, ownership: ownership) - case .tank(let heading, let ownership, let destroyed): - drawTank(&c, heading: heading, ownership: ownership, destroyed: destroyed) + case .tank(let heading, let ownership, let boat, let destroyed): + drawTank(&c, heading: heading, ownership: ownership, boat: boat, destroyed: destroyed) case .shell(let heading): // v1.5.1 #114: constant-size directional streak, not a growing circle -- size no // longer varies by index (that was mistaking a heading bucket for an animation @@ -521,7 +523,7 @@ private func tankPalette(_ ownership: Int) -> (UInt8, UInt8, UInt8) { } } -private func drawTank(_ c: inout Canvas16, heading: Int, ownership: Int, destroyed: Bool) { +private func drawTank(_ c: inout Canvas16, heading: Int, ownership: Int, boat: Bool, destroyed: Bool) { let (r, g, b) = tankPalette(ownership) if destroyed { for i in 0..<16 { @@ -536,10 +538,27 @@ private func drawTank(_ c: inout Canvas16, heading: Int, ownership: Int, destroy // that could silently drift from the simulation's own convention. let dir = Float(heading) * (kPif / 8.0) let v = dir2vec(dir) - c.fillRotatedTriangle(dx: Double(v.x), dy: Double(v.y), r, g, b) - // D148(C): a solid triangle alone reads as an ambiguous arrow at small - // sizes -- add a dark barrel extending past the hull's tip (6px) toward - // the same heading, using the same rotation convention as the hull, so - // rotation can never drift from `dir2vec`/D70's reference frame. - c.fillRotatedBar(dx: Double(v.x), dy: Double(v.y), length: 5.0, halfWidth: 0.8, 20, 20, 20) + if boat { + drawBoatHull(&c, dx: Double(v.x), dy: Double(v.y), r, g, b) + } else { + c.fillRotatedTriangle(dx: Double(v.x), dy: Double(v.y), r, g, b) + // D148(C): a solid triangle alone reads as an ambiguous arrow at small + // sizes -- add a dark barrel extending past the hull's tip (6px) toward + // the same heading, using the same rotation convention as the hull, so + // rotation can never drift from `dir2vec`/D70's reference frame. + c.fillRotatedBar(dx: Double(v.x), dy: Double(v.y), length: 5.0, halfWidth: 0.8, 20, 20, 20) + } +} + +/// #147: the reference draws a distinct hull sprite for a boated tank (`PTKB`/`FTKB`/`ETKB`), +/// not the same triangle+barrel as a land tank -- this was previously indistinguishable since +/// `GlyphRole.tank` had no `boat` parameter at all. Reuses the same bow (`fillRotatedTriangle`) +/// as the land tank's hull, but adds a wide flat stern deck extending aft (negative-length +/// `fillRotatedBar`, matching drawTank's own rotation convention) instead of the thin forward +/// gun barrel, plus a thin waterline band -- an elongated boat-like silhouette, not an +/// ambiguous arrowhead. +private func drawBoatHull(_ c: inout Canvas16, dx: Double, dy: Double, _ r: UInt8, _ g: UInt8, _ b: UInt8) { + c.fillRotatedTriangle(dx: dx, dy: dy, r, g, b) + c.fillRotatedBar(dx: dx, dy: dy, length: -4.0, halfWidth: 2.2, r, g, b) + c.fillRotatedBar(dx: dx, dy: dy, length: 6.0, halfWidth: 0.5, 20, 40, 70) } diff --git a/Sources/BoloGlyphsCore/ImageIndex.swift b/Sources/BoloGlyphsCore/ImageIndex.swift index 93f5666..73b0add 100644 --- a/Sources/BoloGlyphsCore/ImageIndex.swift +++ b/Sources/BoloGlyphsCore/ImageIndex.swift @@ -66,7 +66,8 @@ public func spriteGlyphRole(for index: Int32) -> GlyphRole? { let row = cellRow(index) let heading = cellCol(index) let ownership = row / 2 - return .tank(heading: heading, ownership: ownership, destroyed: false) + let boat = row % 2 == 0 + return .tank(heading: heading, ownership: ownership, boat: boat, destroyed: false) } if (SHELL0IMAGE...SHELL15IMAGE).contains(index) { return .shell(heading: Int(index - SHELL0IMAGE)) diff --git a/Tests/BoloKitTests/BoloGlyphsTests.swift b/Tests/BoloKitTests/BoloGlyphsTests.swift index c86c216..0006945 100644 --- a/Tests/BoloKitTests/BoloGlyphsTests.swift +++ b/Tests/BoloKitTests/BoloGlyphsTests.swift @@ -218,7 +218,7 @@ struct BoloGlyphsTests { @Test("tank heading 0 points screen-east, matching dir2vec(0), not screen-north") func tankHeadingZeroPointsEast() { - let patch = renderGlyph(.tank(heading: 0, ownership: 0, destroyed: false)) + let patch = renderGlyph(.tank(heading: 0, ownership: 0, boat: false, destroyed: false)) let (offX, offY) = centroidOffsetFromCenter(patch) // Tip points east (+x); pixel mass sits behind it, toward -x. #expect(offX < -0.5) @@ -228,17 +228,17 @@ struct BoloGlyphsTests { @Test("tank headings sweep counterclockwise on screen as the index increases, matching dir2vec") func tankHeadingsSweepCounterclockwise() { // heading 4 -> dir = pi/2 -> dir2vec = (0, -1) = screen-north. - let north = centroidOffsetFromCenter(renderGlyph(.tank(heading: 4, ownership: 0, destroyed: false))) + let north = centroidOffsetFromCenter(renderGlyph(.tank(heading: 4, ownership: 0, boat: false, destroyed: false))) #expect(north.1 > 0.5) // mass sits south (behind a north-pointing tip) #expect(abs(north.0) < 0.5) // heading 8 -> dir = pi -> dir2vec = (-1, 0) = screen-west. - let west = centroidOffsetFromCenter(renderGlyph(.tank(heading: 8, ownership: 0, destroyed: false))) + let west = centroidOffsetFromCenter(renderGlyph(.tank(heading: 8, ownership: 0, boat: false, destroyed: false))) #expect(west.0 > 0.5) // mass sits east (behind a west-pointing tip) #expect(abs(west.1) < 0.5) // heading 12 -> dir = 3pi/2 -> dir2vec = (0, 1) = screen-south. - let south = centroidOffsetFromCenter(renderGlyph(.tank(heading: 12, ownership: 0, destroyed: false))) + let south = centroidOffsetFromCenter(renderGlyph(.tank(heading: 12, ownership: 0, boat: false, destroyed: false))) #expect(south.1 < -0.5) // mass sits north (behind a south-pointing tip) #expect(abs(south.0) < 0.5) } @@ -246,7 +246,7 @@ struct BoloGlyphsTests { @Test("every heading's rendered tip direction matches dir2vec, not an independently-derived angle") func allHeadingsMatchDir2Vec() { for heading in 0..<16 { - let patch = renderGlyph(.tank(heading: heading, ownership: 0, destroyed: false)) + let patch = renderGlyph(.tank(heading: heading, ownership: 0, boat: false, destroyed: false)) let (offX, offY) = centroidOffsetFromCenter(patch) let mag = (offX * offX + offY * offY).squareRoot() guard mag > 0 else { @@ -260,6 +260,24 @@ struct BoloGlyphsTests { } } + // MARK: - Tank boat hull (#147) + + @Test("a boated tank renders a distinct hull, not the same triangle+barrel as a land tank") + func boatedTankRendersADistinctHull() { + let land = renderGlyph(.tank(heading: 0, ownership: 0, boat: false, destroyed: false)) + let boated = renderGlyph(.tank(heading: 0, ownership: 0, boat: true, destroyed: false)) + #expect(land.pixels != boated.pixels) + } + + @Test("the boat hull still points toward dir2vec, same as the land tank") + func boatHullFollowsDir2Vec() { + // heading 4 -> dir = pi/2 -> dir2vec = (0, -1) = screen-north: mass sits south of center. + let patch = renderGlyph(.tank(heading: 4, ownership: 0, boat: true, destroyed: false)) + let (offX, offY) = centroidOffsetFromCenter(patch) + #expect(offY > 0.5) + #expect(abs(offX) < 0.5) + } + // MARK: - Builder sprite identity (#146) @Test("builder walk frames and the parachute frame each render a non-empty, distinct silhouette") From 8c5d9206a91a55eb8f4675711d1e4721e4edc7c1 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 20:34:59 -0500 Subject: [PATCH 06/14] docs: v1.6.3 (#147) implemented, milestone 24 done Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 93913f3..4ffc14b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -49,9 +49,21 @@ Related but deliberately not pulled forward: [#138](https://github.com/CosmicCEO (builder movement after death looks unrouted) stays in its own `v1.9.0 — Builder logic` milestone -- it's a movement-smoothing/gameplay-logic issue, not a sprite/visual one. -**Scaffolded, not yet scoped:** [milestone 24](https://github.com/CosmicCEO/BoloKit/milestone/24) -`v1.6.3 — Tank Parity`, [25](https://github.com/CosmicCEO/BoloKit/milestone/25) `v1.6.4 — Boat -Parity`, [26](https://github.com/CosmicCEO/BoloKit/milestone/26) `v1.6.5 — Sound Parity`. +**Milestone 24, `v1.6.3 — Tank Parity`, done and closed 2026-09-23.** Live audit of +`tankmovelogic`/`tanklocallogic`/`killtank`/`tankcollision`/`tanktest`/`tankonaboattest` and the +shell-vs-tank damage path against `Reference/c`: everything but one item is already a faithful, +complete port (movement, collision, tank-vs-tank push-apart, mine/refuel/pillbox-capture logic, +`killTank`, shell-hit damage). The one real gap: [#147](https://github.com/CosmicCEO/BoloKit/issues/147) +-- a boated tank rendered pixel-identical to a land tank (`ImageIndex.swift` already selected the +right `PTKB`/etc. sprite row, but `GlyphRole.tank` had no `boat` parameter at all, so both rows +drew the same triangle+barrel). Fixed: `boat: Bool` threaded through, `drawBoatHull` gives it a +distinct hull silhouette. Same porting-gap category as #146, not a new mechanic. Full `swift +test` and `xcodebuild build -scheme "Bolo 2026"` green (four DifferentialTests flakes under the +full parallel run, all confirmed pass in isolation -- real-network/timing flakiness, unrelated to +this pure-rendering change). + +**Scaffolded, not yet scoped:** [25](https://github.com/CosmicCEO/BoloKit/milestone/25) `v1.6.4 — +Boat Parity`, [26](https://github.com/CosmicCEO/BoloKit/milestone/26) `v1.6.5 — Sound Parity`. Checked the full open-issue backlog (39 issues, 2026-09-23) for anything that fits -- nothing does. The closest candidates, [#7](https://github.com/CosmicCEO/BoloKit/issues/7) (fire while standing on a captured base) and [#5](https://github.com/CosmicCEO/BoloKit/issues/5) (explosion From 2f368f781a64683ab266101b9962d07b044f9d09 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 21:03:26 -0500 Subject: [PATCH 07/14] #148: boarding a boat now actually sets player.boat = true Fixes the root cause behind "boats don't look like boats" (live user report): no player, under any connection topology, could ever board a boat -- state.players[player].boat was only ever set true by spawning directly onto a boat start-tile. grabTile's .boat case (host's own local player + host-simulated remotes) and recvClGrabTile's .boat case (real remote guests over TCP) both cleared the terrain and, for the network path, broadcast SRGrabBoat, but neither updated the host's own authoritative state.players[player].boat -- so the boat-hull sprite (#147) never had a state to key off of. The requesting guest's own client-side recvSrGrabBoat set it correctly on that one machine, which is why this shipped unnoticed: it looked right locally while the host's copy, and every other player's view of that guest, stayed wrong. Extended the two existing tests that already exercised these code paths (enterTileBoatTerrainPickupWithoutBoat, recvClGrabTileBoatTerrainBecomesRiver) to assert the flag, since their prior absence of that assertion is exactly how this shipped. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- Sources/BoloKit/RecvCL.swift | 6 ++++++ Sources/BoloKit/TankLocalTick.swift | 7 +++++++ Tests/BoloKitTests/RecvCLTests.swift | 8 +++++++- Tests/BoloKitTests/TankLocalTickTests.swift | 3 +++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/BoloKit/RecvCL.swift b/Sources/BoloKit/RecvCL.swift index ad79b2d..d6f8d7b 100644 --- a/Sources/BoloKit/RecvCL.swift +++ b/Sources/BoloKit/RecvCL.swift @@ -173,7 +173,13 @@ public func recvClGrabTile( guard let terrain = state.terrain[x, y] else { return } switch terrain { case .boat: + // #148: same fix as `grabTile`'s `.boat` case (`TankLocalTick.swift`) -- the requesting + // guest's own client sets `boat = true` for itself via `recvSrGrabBoat` once this + // broadcast reaches it, but the HOST's own authoritative copy (which drives the host's + // rendering of this guest and every other connected player's view via the periodic + // per-tick broadcast) never did. state.terrain[x, y] = .river + state.players[player].boat = true onShouldBroadcastGrabBoat(player, x, y) case .minedSea, .minedSwamp, .minedCrater, .minedRoad, .minedForest, .minedRubble, .minedGrass: explosionAt( diff --git a/Sources/BoloKit/TankLocalTick.swift b/Sources/BoloKit/TankLocalTick.swift index d801012..910d397 100644 --- a/Sources/BoloKit/TankLocalTick.swift +++ b/Sources/BoloKit/TankLocalTick.swift @@ -387,7 +387,14 @@ public func grabTile( guard let terrain = state.terrain[x, y] else { return } switch terrain { case .boat: + // #148: the oracle's server never sets this either (each real client self-reports its + // own `boat` state) -- but BoloKit's host-authoritative model needs it set here, at the + // point of mutation, since the host's own physics/rendering/broadcast all read it from + // this copy of `GameState`. Without this, no player (host's own local tank OR a + // host-simulated remote, both of which route through this function) could ever actually + // board a boat. state.terrain[x, y] = .river + state.players[player].boat = true case .minedSea, .minedSwamp, .minedCrater, .minedRoad, .minedForest, .minedRubble, .minedGrass: onMineExplosion(point) diff --git a/Tests/BoloKitTests/RecvCLTests.swift b/Tests/BoloKitTests/RecvCLTests.swift index 88ec6f6..fa39cba 100644 --- a/Tests/BoloKitTests/RecvCLTests.swift +++ b/Tests/BoloKitTests/RecvCLTests.swift @@ -160,12 +160,18 @@ private func makeState(players: [PlayerState] = [PlayerState()]) -> GameState { } @Test func recvClGrabTileBoatTerrainBecomesRiver() { - var state = makeState() + var state = makeState(players: [connectedPlayer(), connectedPlayer()]) state.terrain[50, 50] = .boat var broadcast: (Int, Int, Int)? recvClGrabTile(player: 1, x: 50, y: 50, state: &state, onShouldBroadcastGrabBoat: { broadcast = ($0, $1, $2) }) #expect(state.terrain[50, 50] == .river) #expect(broadcast?.0 == 1) + // #148: the host's own authoritative copy must record the requesting player as boated too, + // not just clear the terrain and broadcast -- previously missing, which is how #148 shipped + // unnoticed (the requesting guest's own client-side ack made it LOOK correct on that one + // machine, while the host's copy -- and every other player's view of this guest -- stayed + // wrong). + #expect(state.players[1].boat) } @Test func recvClGrabTileMinedTerrainDetonates() { diff --git a/Tests/BoloKitTests/TankLocalTickTests.swift b/Tests/BoloKitTests/TankLocalTickTests.swift index 6a86d9e..57e2779 100644 --- a/Tests/BoloKitTests/TankLocalTickTests.swift +++ b/Tests/BoloKitTests/TankLocalTickTests.swift @@ -195,6 +195,9 @@ private func connectedPlayer(dead: Bool = false, boat: Bool = false) -> PlayerSt state.terrain[5, 5] = .boat enterTile(new: Pointi(x: 5, y: 5), old: Pointi(x: 4, y: 5), state: &state) #expect(state.terrain[5, 5] == .river) + // #148: grabbing a boat tile must actually put the player in boat mode -- this assertion + // is the whole point of the test, previously missing, which is how #148 shipped unnoticed. + #expect(state.players[0].boat) } @Test func enterTileMinedSeaDrownsRegardlessOfBoat() { From ebea4d746c0146ef554291961ab0cb27c7b0af12 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 21:03:52 -0500 Subject: [PATCH 08/14] docs: v1.6.4 (#148) implemented, milestone 25 done Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 4ffc14b..951ff41 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -62,8 +62,24 @@ test` and `xcodebuild build -scheme "Bolo 2026"` green (four DifferentialTests f full parallel run, all confirmed pass in isolation -- real-network/timing flakiness, unrelated to this pure-rendering change). -**Scaffolded, not yet scoped:** [25](https://github.com/CosmicCEO/BoloKit/milestone/25) `v1.6.4 — -Boat Parity`, [26](https://github.com/CosmicCEO/BoloKit/milestone/26) `v1.6.5 — Sound Parity`. +**Milestone 25, `v1.6.4 — Boat Parity`, done and closed 2026-09-23.** Live audit of +boarding/disembarking, boat physics, shore-push, boat terrain rendering, and boat-specific +sounds against `Reference/c`: disembarking, ramming, drowning, physics constants, shore-push, +and boat-terrain rendering (`applyBoatRipple`, shipped earlier) are all already faithful. One +real, high-severity gap, confirmed live and directly explaining a live user report ("boats don't +look like boats"): [#148](https://github.com/CosmicCEO/BoloKit/issues/148) -- +`state.players[player].boat` was never set `true` by actually boarding a boat, under ANY +connection topology (host's own tank, host-simulated remotes, or real networked guests) -- only +spawning directly onto a boat start-tile ever set it. Both `grabTile` (`TankLocalTick.swift`) and +`recvClGrabTile` (`RecvCL.swift`) cleared the terrain but never flipped the flag on the host's +own authoritative state, so #147's boat-hull sprite never had a live trigger. Fixed by adding the +missing assignment to both call sites; the two existing tests that already exercised this path +were missing the one assertion that would have caught it, now added. Full `swift test` and +`xcodebuild build -scheme "Bolo 2026"` green (one pre-existing timing flake, confirmed pass in +isolation). + +**Scaffolded, not yet scoped:** [26](https://github.com/CosmicCEO/BoloKit/milestone/26) `v1.6.5 — +Sound Parity`. Checked the full open-issue backlog (39 issues, 2026-09-23) for anything that fits -- nothing does. The closest candidates, [#7](https://github.com/CosmicCEO/BoloKit/issues/7) (fire while standing on a captured base) and [#5](https://github.com/CosmicCEO/BoloKit/issues/5) (explosion From 06e6b8f60e0c8c6ab52aee62a1a42440faa754cf Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 22:40:17 -0500 Subject: [PATCH 09/14] #149/#150: wire sound for real networked host/guest, and near/far by fog #149: a real networked host and a joined guest played zero gameplay sound -- SoundPlayer.shared.play(...) was only ever reachable from GameSession.tick(), which has exactly one caller (the solo/single- process DispatchSource timer). HostGameEngine.onShouldPlaySound is a new property, wired the same way onTickRendered/onMessageReceived already are, threading the same 15 sound-only runTick hooks through HostGameEngine's own tick() (queued during the synchronous runTick call, flushed on the main actor after, same shape pending/ maskedPending already use) plus the separate .localLayMineKeyDown event path that bypasses runTick entirely. Join path wired for its three already-existing local-prediction call sites (tankMoveTick/ builderTick/shellTick). #150: near/far selection was entirely unwired even though the fog machinery (FogState/isFog) already exists and is live on the host path whenever Hidden Mines is on. isNear(at:) reuses that machinery as-is (no new distance proxy) for the 7 location-bearing runTick hooks; SoundPlayer.play(_:near:) resolves the actual far-name asset. Caught a real nested-inout-exclusivity crash during testing (isNear originally read the live state.hiddenMines from inside a closure firing during runTick's own exclusive access) and a real near/far polarity bug (isFog returns true when a tile IS fogged, i.e. far -- inverted in the first pass) via two new HostGameEngineTests that exercise both through a live tick loop, not just unit-test the helper in isolation. Both issues disclose real remaining gaps rather than overclaiming: 8 of the 16 sound hooks have no location parameter to gate on at all, and near/far stays "near always" outside Hidden Mines/host (the solo and join paths track no fog at all) -- the larger always-on vision-system project D65 already flagged, not silently expanded here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- Bolo 2026/Bolo 2026/GameSession.swift | 27 +++++- Bolo 2026/Bolo 2026/SoundPlayer.swift | 92 +++++++++++++------ Sources/BoloNet/HostGameEngine.swift | 79 +++++++++++++++- .../HostGameEngineTests.swift | 83 +++++++++++++++++ 4 files changed, 253 insertions(+), 28 deletions(-) diff --git a/Bolo 2026/Bolo 2026/GameSession.swift b/Bolo 2026/Bolo 2026/GameSession.swift index 5429962..dc6bb50 100644 --- a/Bolo 2026/Bolo 2026/GameSession.swift +++ b/Bolo 2026/Bolo 2026/GameSession.swift @@ -228,6 +228,9 @@ public final class GameSession { 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) } @@ -662,7 +665,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 +717,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 +765,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.. Void)? + /// #149: `runTick`'s sound-only hooks (`onExplosion`/`onHitTank`/`onBuild`/etc.) were never + /// wired here at all -- a real networked host played no gameplay sound whatsoever, since + /// `GameSession`'s own `SoundPlayer` wiring only exists on the single-process/solo `tick()` + /// path, which this class has no relationship to. Fired the same way `onTickRendered` is + /// (hopped to the main actor at this one call site), with the already-resolved sound name + /// and whether it's in fog range (`near`) for this host's own listening player -- the app + /// layer (`SoundPlayer.play(_:near:)`) owns the near/far asset-name mapping, not this file, + /// so this stays a plain `(String, Bool)` pair rather than adding a `BoloSoundsCore` + /// dependency to `BoloNet`. + public var onShouldPlaySound: (@MainActor (String, Bool) -> Void)? + /// **1.1 backlog C.4 / D154 Wave 3:** fired for chat that includes the host's own slot /// *and* for `MSGGAME` system-event lines (roster/capture/alliance/clock/builder). The host /// has no socket to itself, so this is the in-process equivalent of receiving those `SR*` @@ -526,6 +537,14 @@ public final class HostGameEngine: @unchecked Sendable { case .localLayMineKeyDown: var planted: Pointi? layMineOnKeyDown(state: &state, onMine: { planted = $0 }) + if planted != nil { + // #149: this event bypasses `tick()`'s own `runTick` call entirely (a direct + // `layMineOnKeyDown` call in this merged-stream consumer, matching the + // single-process path's own separate `onLayMineKeyDown` wiring in + // `GameSession.swift`) -- `pendingSounds`/`isNear(at:)` in `tick()` never see + // this one, so it needs its own `onShouldPlaySound` fire here. + if let onShouldPlaySound { await MainActor.run { onShouldPlaySound("mine", true) } } + } // A hidden mine is never announced: remote players learn of it by proximity reveal. if let planted, !state.hiddenMines { await table.sendToAll(SRDropMine(player: UInt8(state.localPlayer), x: UInt8(planted.x), y: UInt8(planted.y)).encode()) @@ -669,6 +688,30 @@ public final class HostGameEngine: @unchecked Sendable { let oldBuilderStatus = state.players.map(\.builderStatus) let playerNames = state.players.map(\.name) + // #149/#150: sound-only queue, flushed via `onShouldPlaySound` after `runTick` returns, + // same "queue during the synchronous call, flush after" shape `pending`/`maskedPending` + // already use. `isNear(at:)` mirrors the oracle's own `client.fog[y][x] > 0` check + // (`isFog`, `CalcVis.swift`) using this host's own already-maintained `fogStates` -- + // real near/far parity whenever Hidden Mines is on (the only mode `fogStates` is kept + // live for today, see that property's own doc comment); falls back to always-near + // otherwise, matching this port's existing D65 full-visibility default rather than + // inventing an independent distance proxy that could diverge from the oracle's own + // tank/pillbox/alliance vision-box rules. + var pendingSounds: [(name: String, near: Bool)] = [] + let hostFogStateSnapshot = fogStates[state.localPlayer] + func isNear(at point: Pointi) -> Bool { + // Reads only pre-tick snapshots (`hiddenMinesSnapshot`/`hostFogStateSnapshot`), never + // the live `state`/`fogStates` -- this closure fires from inside `runTick`'s own + // exclusive `state: &state` access below (via `onMineExplosion`/etc.), so touching + // `self.state` here would be a nested-access violation (the same class this file's + // own `onGrow`/`onShouldBroadcastSmallBoom`/`onShouldBroadcastFlood` closures are + // already careful to avoid -- confirmed by a live exclusivity trap while testing this). + guard hiddenMinesSnapshot, let hostFogStateSnapshot else { return true } + // `isFog` returns true when the tile IS fogged (no current vision source covers it, + // `fog == 0`) -- i.e. far, not near. Inverted here, not in `isFog` itself. + return !isFog(x: point.x, y: point.y, fogState: hostFogStateSnapshot) + } + let terrainBeforeTick = state.terrain.storage let tickSignpost = BoloSignposts.tick.beginInterval(BoloSignposts.runTickName) runTick( @@ -690,6 +733,18 @@ public final class HostGameEngine: @unchecked Sendable { let mask = terrainVisibilityMask(x: x, y: y, hiddenMines: hiddenMinesSnapshot, fogStates: self?.fogStates ?? [:]) maskedPending.append((mask, SRGrow(x: UInt8(x), y: UInt8(y)).encode())) }, + // #149/#150: the rest of `SoundPlayer.swift`'s 15 names -- this host had none of + // these wired at all before now (only the single-process/solo path did). The 7 + // location-bearing ones resolve near/far via `isNear(at:)`; the rest (no location in + // their `runTick` signature) stay "near" always, a known, narrower, disclosed + // remaining gap -- see #150's own issue body for why closing it needs a + // signature-changing thread of a location parameter through `TankLocalTick.swift`/ + // `ShellTick.swift`/`PillTick.swift`, out of scope for this pass. + onMineExplosion: { point in pendingSounds.append(("explosion", isNear(at: point))) }, + onSuperboomTerrain: { point in pendingSounds.append(("superboom", isNear(at: point))) }, + onExplosion: { v in pendingSounds.append(("explosion", isNear(at: Pointi(x: Int32(v.x), y: Int32(v.y))))) }, + onSuperboom: { pendingSounds.append(("superboom", true)) }, + onSmallboom: { pendingSounds.append(("explosion", true)) }, onShouldBroadcastDropPill: { pill, x, y in pending.append(SRDropPill(pill: UInt8(pill), x: UInt8(x), y: UInt8(y)).encode()) }, @@ -704,8 +759,16 @@ public final class HostGameEngine: @unchecked Sendable { onShouldBroadcastDamage: { player, x, y, terrain in pending.append(SRDamage(player: UInt8(player), x: UInt8(x), y: UInt8(y), terrain: terrain).encode()) }, + onTankShot: { pendingSounds.append(("tankshot", true)) }, + onTreeHarvest: { point in pendingSounds.append(("tree", isNear(at: point))) }, onPrintMessage: { pendingGameMessages.append($0) }, + onHitTank: { pendingSounds.append(("hittank", true)) }, + onHitTerrain: { point in pendingSounds.append(("hitterrain", isNear(at: point))) }, + onHitTree: { point in pendingSounds.append(("hittree", isNear(at: point))) }, onMine: { point in + // #149: no far variant exists for "mine" (matches the oracle -- no `kFarMineSound` + // either), so this always queues "near" regardless of fog. + pendingSounds.append(("mine", true)) // Same rule as `.localLayMineKeyDown`: a hidden mine is never announced. guard !hiddenMinesSnapshot else { return } pending.append(SRDropMine(player: UInt8(localPlayerSnapshot), x: UInt8(point.x), y: UInt8(point.y)).encode()) @@ -713,12 +776,17 @@ public final class HostGameEngine: @unchecked Sendable { onShouldBroadcastRefuel: { _, base, armour, shells, mines in pending.append(SRRefuel(base: UInt8(base), armour: armour, shells: shells, mines: mines).encode()) }, + onBuild: { point in pendingSounds.append(("build", isNear(at: point))) }, onShouldBroadcastBuildPill: { pill, x, y, armour in pending.append(SRBuildPill(pill: UInt8(pill), x: UInt8(x), y: UInt8(y), armour: armour).encode()) }, onShouldBroadcastRepairPill: { pill, armour in pending.append(SRRepairPill(pill: UInt8(pill), armour: armour).encode()) - } + }, + onBuilderDeath: { pendingSounds.append(("builderdeath", true)) }, + onSink: { pendingSounds.append(("sink", true)) }, + onBubbles: { pendingSounds.append(("bubbles", true)) }, + onPillShot: { pendingSounds.append(("pillshot", true)) } ) BoloSignposts.tick.endInterval(BoloSignposts.runTickName, tickSignpost) @@ -814,6 +882,15 @@ public final class HostGameEngine: @unchecked Sendable { await MainActor.run { onTickRendered(snapshot) } } + // #149: same shape as `onTickRendered` above -- one main-actor hop for every sound + // queued this tick, not one hop per sound. + if let onShouldPlaySound, !pendingSounds.isEmpty { + let sounds = pendingSounds + await MainActor.run { + for (name, near) in sounds { onShouldPlaySound(name, near) } + } + } + // D98 (PARITY finding): `runclient()`'s early return (`client.c:430-434`, // `if (client.timelimitreached || client.basecontrolreached || client.pause)`) skips // `seq++`/`sendclupdate()` entirely while paused or once time-limit is reached -- diff --git a/Tests/DifferentialTests/HostGameEngineTests.swift b/Tests/DifferentialTests/HostGameEngineTests.swift index 264e990..617f3ef 100644 --- a/Tests/DifferentialTests/HostGameEngineTests.swift +++ b/Tests/DifferentialTests/HostGameEngineTests.swift @@ -811,6 +811,15 @@ private actor HostRenderedTicksBox { func record(_ tick: UInt64) { ticks.append(tick) } } +/// #149: `onShouldPlaySound` is `@MainActor`-isolated the same way `onTickRendered` is (see this +/// file's own doc comment above it) -- same actor-box pattern to observe it from an `async` test. +private actor HostPlayedSoundsBox { + private var sounds: [(name: String, near: Bool)] = [] + var isEmpty: Bool { sounds.isEmpty } + var all: [(name: String, near: Bool)] { sounds } + func record(_ sound: (name: String, near: Bool)) { sounds.append(sound) } +} + /// `onPlayerDisconnected`'s own wiring (`HostGameEngine.tick()`) -- distinct from the two tests /// above, which exercise the dynamic per-connection producer's disconnect paths. This one exercises /// `RunTick.swift`'s own step-4 lag-timeout path instead: a player whose `HostSessionTable` @@ -1313,6 +1322,80 @@ private func sendStreamBytes(_ connection: NWConnection, _ bytes: [UInt8]) async #expect(mines.first?.player == 0) } + // #149: a real networked host had no `onShouldPlaySound` wiring at all before this -- see + // `HostGameEngine.swift`'s own doc comment on that property. Reuses the same + // `configureHostWithMines`/`submitLocalLayMineKeyDown` fixture the two tests above already + // establish as a reliable, deterministic sound trigger. "mine" has no far variant (matches + // the oracle), so it must always queue as near regardless of Hidden Mines. + @Test(.timeLimit(.minutes(1))) func hostGameEngineFiresOnShouldPlaySoundForALaidMine() async throws { + let (engine, _, _) = try await makeEngine { configureHostWithMines(&$0, hiddenMines: false) } + defer { engine.stop() } + + let playedSounds = HostPlayedSoundsBox() + engine.onShouldPlaySound = { name, near in + Task { await playedSounds.record((name, near)) } + } + engine.start() + + engine.submitLocalLayMineKeyDown() + + try await waitForCondition(timeout: 3) { await !playedSounds.isEmpty } + let sounds = await playedSounds.all + #expect(sounds.contains { $0.name == "mine" && $0.near }) + } + + // #150: proves the near/far selection itself, not just that `onShouldPlaySound` fires -- + // two host-simulated tanks (no real network connection needed, `hostSimulatesRemotePlayers` + // alone is enough for `RunTick` to simulate player 1), unallied, so player 0 (the listener) + // shares no vision with player 1. Player 1 drives onto a mine far outside player 0's own + // 29x29 vision box (centered on (105,105)); the resulting "explosion" must resolve to + // `near: false`, using the exact same `fogStates`/`isFog` machinery + // `HostGameEngineFogVisionTests`'s other tests already independently verify -- this test's + // job is only to confirm the sound wiring actually reaches that machinery, not to re-derive + // fog correctness. + @Test(.timeLimit(.minutes(1))) func hostGameEngineOnShouldPlaySoundIsFarForAnEventOutsideTheHostsOwnFogVision() async throws { + let (engine, _, _) = try await makeEngine { state in + state.hiddenMines = true + state.hostSimulatesRemotePlayers = true + state.players[0].used = true + state.players[0].connected = true + state.players[0].dead = false + state.players[0].tank = Vec2f(x: 105.5, y: 105.5) + // Not allied with player 1 -- no shared vision (mirrors + // `testAllianceFormingRevealsTheNewAllyImmediately`'s own default-unallied setup). + state.players[1].used = true + state.players[1].connected = true + state.players[1].dead = false + for y in 195..<205 { + for x in 195..<205 { + state.terrain.storage[y * 256 + x] = Terrain.grass0.rawValue + } + } + state.terrain[200, 200] = .minedGrass + // Starts several tiles back (not adjacent) so plenty of ticks pass -- and player 0's + // own fog genuinely initializes (a real, if small, race otherwise: `fogStates` starts + // completely empty, and `updateFogVision()` only runs *after* `runTick` returns each + // tick, so `isNear`'s pre-tick snapshot can still be `nil` on the very first ticks) -- + // before the detonation this test is actually checking fires. + state.players[1].tank = Vec2f(x: 196.5, y: 200.5) + state.players[1].dir = 0 // dir2vec(0) is screen-east (D70) -- drives straight into x=200. + state.players[1].inputFlags = [.accel] + } + defer { engine.stop() } + + let playedSounds = HostPlayedSoundsBox() + engine.onShouldPlaySound = { name, near in + Task { await playedSounds.record((name, near)) } + } + engine.start() + + try await waitForCondition(timeout: 5) { + await playedSounds.all.contains { $0.name == "explosion" } + } + let sounds = await playedSounds.all + #expect(sounds.contains { $0.name == "explosion" && !$0.near }, "an event outside the host's own fog vision must resolve to far, got: \(sounds)") + } + @Test(.timeLimit(.minutes(1))) func hostLaidMineKeyDownIsNotSentToARemotePlayerWhenHiddenMinesIsOn() async throws { let (engine, tcpPort, _) = try await makeEngine { configureHostWithMines(&$0, hiddenMines: true) } defer { engine.stop() } From d337acceacc89c713bd108ecf61d9a454c02f1e9 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Wed, 23 Sep 2026 22:40:51 -0500 Subject: [PATCH 10/14] docs: v1.6.5 (#149 done, #150 partial) -- 1.6.x parity pass complete Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 951ff41..b8bca78 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -78,17 +78,40 @@ were missing the one assertion that would have caught it, now added. Full `swift `xcodebuild build -scheme "Bolo 2026"` green (one pre-existing timing flake, confirmed pass in isolation). -**Scaffolded, not yet scoped:** [26](https://github.com/CosmicCEO/BoloKit/milestone/26) `v1.6.5 — -Sound Parity`. -Checked the full open-issue backlog (39 issues, 2026-09-23) for anything that fits -- nothing -does. The closest candidates, [#7](https://github.com/CosmicCEO/BoloKit/issues/7) (fire while -standing on a captured base) and [#5](https://github.com/CosmicCEO/BoloKit/issues/5) (explosion -owner attribution), are both explicitly parked `Decide:` rulings ("not a sprint," "do not fix -without a ruling") -- left alone rather than force-fit. #145/#146 above didn't come from the -backlog either; they came from a live code-vs-`Reference/c` audit of the "man" theme. Each of -tank/boat/sound needs that same audit (trace the relevant state machine/collision/sound-hook -code against the oracle, confirm what's already correct, find the real gaps) before real issues -can be filed -- not a sprint on its own, but the prerequisite to scoping each one. +**Milestone 26, `v1.6.5 — Sound Parity`, mostly done 2026-09-23 -- one issue intentionally left +open.** Live audit of every oracle `playsound()` trigger against the port's own sound catalog, +prompted directly by a live user report ("hearing trees being harvested beyond view") and an +explicit ask to verify host/client sound wiring. Two real, high-severity findings: +- [#149](https://github.com/CosmicCEO/BoloKit/issues/149) (closed) -- a real networked HOST and a + joined GUEST played **zero** gameplay sound at all. `SoundPlayer` was only ever reachable from + the single-process/solo tick loop; `HostGameEngine`'s own separate internal loop had no sound + callback of any kind. Fixed: `HostGameEngine.onShouldPlaySound`, wired the same way + `onTickRendered`/`onMessageReceived` already are, plus the join path's three local-prediction + call sites. +- [#150](https://github.com/CosmicCEO/BoloKit/issues/150) (**left open, deliberately**) -- sound + played "near" unconditionally regardless of distance -- this was the direct cause of the + reported symptom. Fixed for the host path whenever Hidden Mines is on (reuses the existing, + already-live `FogState`/`isFog` machinery, no new distance proxy invented). **Does not fully + fix the reported symptom**: with Hidden Mines off (this port's current default) or on the solo/ + join paths, every sound still plays "near" -- BoloKit's fog/vision tracking is deliberately + scoped to "always visible" for v1 (D65), narrower than the oracle's own always-active + tank/pillbox vision-box system; making it unconditional and live on every path is a bigger, + separate project, not done here. Caught two real bugs while testing this fix (a nested-`inout` + exclusivity crash, and an inverted `isFog` near/far polarity) via two new + `HostGameEngineTests` that exercise both through a live tick loop. Full `swift test` and + `xcodebuild build -scheme "Bolo 2026"` green (known pre-existing timing/socket flakes, + confirmed pass in isolation). + +Also noted in #150, not implemented, filed for a human call (same category as #145): even a +fixed oracle near/far model is binary, two fixed clips -- true continuous-distance volume/pan +would be richer than the oracle itself, a disclosed-new-feature candidate, not assumed. + +This closes out the four 1.6.x themed parity releases' initial audit-and-fix pass (man #145/#146, +tank #147, boat #148, sound #149 done/#150 partial). #145/#146/#147/#148/#149/#150 all came from +live code-vs-`Reference/c` audits, not the backlog -- the closest backlog candidates, +[#7](https://github.com/CosmicCEO/BoloKit/issues/7) (fire while standing on a captured base) and +[#5](https://github.com/CosmicCEO/BoloKit/issues/5) (explosion owner attribution), remain +explicitly parked `Decide:` rulings, left alone rather than force-fit. ## Queued next — milestone 20 `v1.6.x — Rejoin fix`, re-scoped 2026-09-23 From 033ec7b67b597731b8b3da39b0f45a033925d9f3 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Thu, 24 Sep 2026 06:45:30 -0500 Subject: [PATCH 11/14] docs: #150 parked per Jerod's ruling, milestone 26 closed Solo play has no second listener for far-sound distinction to matter to; the unconditional always-on fog/vision extension is parked in the Decide: Oracle parking lot milestone, not fixed. v1.6.5 (Sound Parity) is done -- closes out all four 1.6.x themed parity releases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 53 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index b8bca78..7d60c68 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -78,40 +78,41 @@ were missing the one assertion that would have caught it, now added. Full `swift `xcodebuild build -scheme "Bolo 2026"` green (one pre-existing timing flake, confirmed pass in isolation). -**Milestone 26, `v1.6.5 — Sound Parity`, mostly done 2026-09-23 -- one issue intentionally left -open.** Live audit of every oracle `playsound()` trigger against the port's own sound catalog, -prompted directly by a live user report ("hearing trees being harvested beyond view") and an -explicit ask to verify host/client sound wiring. Two real, high-severity findings: +**Milestone 26, `v1.6.5 — Sound Parity`, done and closed 2026-09-24.** Live audit of every +oracle `playsound()` trigger against the port's own sound catalog, prompted directly by a live +user report ("hearing trees being harvested beyond view") and an explicit ask to verify +host/client sound wiring. Two real, high-severity findings: - [#149](https://github.com/CosmicCEO/BoloKit/issues/149) (closed) -- a real networked HOST and a joined GUEST played **zero** gameplay sound at all. `SoundPlayer` was only ever reachable from the single-process/solo tick loop; `HostGameEngine`'s own separate internal loop had no sound callback of any kind. Fixed: `HostGameEngine.onShouldPlaySound`, wired the same way `onTickRendered`/`onMessageReceived` already are, plus the join path's three local-prediction call sites. -- [#150](https://github.com/CosmicCEO/BoloKit/issues/150) (**left open, deliberately**) -- sound - played "near" unconditionally regardless of distance -- this was the direct cause of the - reported symptom. Fixed for the host path whenever Hidden Mines is on (reuses the existing, - already-live `FogState`/`isFog` machinery, no new distance proxy invented). **Does not fully - fix the reported symptom**: with Hidden Mines off (this port's current default) or on the solo/ - join paths, every sound still plays "near" -- BoloKit's fog/vision tracking is deliberately - scoped to "always visible" for v1 (D65), narrower than the oracle's own always-active - tank/pillbox vision-box system; making it unconditional and live on every path is a bigger, - separate project, not done here. Caught two real bugs while testing this fix (a nested-`inout` - exclusivity crash, and an inverted `isFog` near/far polarity) via two new - `HostGameEngineTests` that exercise both through a live tick loop. Full `swift test` and - `xcodebuild build -scheme "Bolo 2026"` green (known pre-existing timing/socket flakes, - confirmed pass in isolation). - -Also noted in #150, not implemented, filed for a human call (same category as #145): even a -fixed oracle near/far model is binary, two fixed clips -- true continuous-distance volume/pan -would be richer than the oracle itself, a disclosed-new-feature candidate, not assumed. - -This closes out the four 1.6.x themed parity releases' initial audit-and-fix pass (man #145/#146, -tank #147, boat #148, sound #149 done/#150 partial). #145/#146/#147/#148/#149/#150 all came from -live code-vs-`Reference/c` audits, not the backlog -- the closest backlog candidates, +- [#150](https://github.com/CosmicCEO/BoloKit/issues/150) (closed, ruled 2026-09-24) -- sound + played "near" unconditionally regardless of distance. Fixed for the host path whenever Hidden + Mines is on (reuses the existing, already-live `FogState`/`isFog` machinery, no new distance + proxy invented). The remaining gap -- making fog/vision tracking unconditional and live on + every path (solo/join), matching the oracle's own always-active tank/pillbox vision-box system + -- is **parked, not fixed**: Jerod's ruling (2026-09-24) is that solo play, this port's primary + mode, has no second listener anywhere else on the map for a far-sound distinction to matter to, + so the unconditional extension isn't worth building. Moved to + [milestone 10](https://github.com/CosmicCEO/BoloKit/milestone/10) (`Decide: Oracle parking + lot`) and closed there; reopen only on a real repro (e.g. hosted multiplayer with Hidden Mines + off). Caught two real bugs while landing the host-path fix (a nested-`inout` exclusivity crash, + and an inverted `isFog` near/far polarity) via two new `HostGameEngineTests` that exercise both + through a live tick loop. Full `swift test` and `xcodebuild build -scheme "Bolo 2026"` green + (known pre-existing timing/socket flakes, confirmed pass in isolation). + +Also noted in #150's body, not implemented, no ruling requested yet (same disclosed-new-feature +category as #145): even a fixed oracle near/far model is binary, two fixed clips -- true +continuous-distance volume/pan would be richer than the oracle itself. + +This closes out all four 1.6.x themed parity releases' initial audit-and-fix pass (man #145/#146, +tank #147, boat #148, sound #149/#150). #145/#146/#147/#148/#149/#150 all came from live +code-vs-`Reference/c` audits, not the backlog -- the closest backlog candidates, [#7](https://github.com/CosmicCEO/BoloKit/issues/7) (fire while standing on a captured base) and [#5](https://github.com/CosmicCEO/BoloKit/issues/5) (explosion owner attribution), remain -explicitly parked `Decide:` rulings, left alone rather than force-fit. +their own explicitly parked `Decide:` rulings, left alone rather than force-fit. ## Queued next — milestone 20 `v1.6.x — Rejoin fix`, re-scoped 2026-09-23 From b5c80772c58bc654abac937386ac1f7894fbc08d Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Thu, 24 Sep 2026 06:58:25 -0500 Subject: [PATCH 12/14] #139: fix host engine SIGABRT from cross-thread GameState.local reads GameSession.adminState/liveState read hostEngine.state directly -- a plain stored property mutated off-main by HostGameEngine's tick-loop Task. Reading it synchronously from @MainActor raced that mutation and corrupted Array refcounts on GameState.local, aborting after a long-idle hosted session (crash log ips: SIGABRT in Array._makeMutableAndUnique during tankLocalTick). Fix: cache the already-safe value-type snapshot HostGameEngine hands to onTickRendered (documented as the one sanctioned way to get state off the tick loop) into a new hostLiveState property, and read that instead. 146/146 Bolo 2026Tests and 956/956 SwiftPM tests pass (one pre-existing timing flake confirmed unrelated, passes in isolation). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- Bolo 2026/Bolo 2026/GameSession.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Bolo 2026/Bolo 2026/GameSession.swift b/Bolo 2026/Bolo 2026/GameSession.swift index dc6bb50..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,6 +234,7 @@ 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) @@ -330,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 From 0786a0ecbffd476d168ce3fdb4f16dbcecb38d39 Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Thu, 24 Sep 2026 06:58:46 -0500 Subject: [PATCH 13/14] docs: #139 root-caused and fixed, milestone 20 closed Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- docs/STATUS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/STATUS.md b/docs/STATUS.md index 7d60c68..c49623a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -103,6 +103,23 @@ host/client sound wiring. Two real, high-severity findings: through a live tick loop. Full `swift test` and `xcodebuild build -scheme "Bolo 2026"` green (known pre-existing timing/socket flakes, confirmed pass in isolation). +**Milestone 20, `v1.6.x — Rejoin fix`, done and closed 2026-09-24.** Last remaining issue, +[#139](https://github.com/CosmicCEO/BoloKit/issues/139) (host engine `SIGABRT` after a long-idle +hosted session, crash-log-only, no live repro) -- root-caused via a live audit of +`HostGameEngine`'s concurrency model, not the soak-test repro the issue asked for. Confirmed +root cause: `HostGameEngine` is `final class HostGameEngine: @unchecked Sendable` with `state` a +plain stored property, mutated off-main by the tick-loop `Task` inside `tick()`. `GameSession`'s +`adminState`/`liveState` (`@MainActor`) read `hostEngine.state` directly and synchronously -- +racing that mutation and corrupting `Array` refcounts on `GameState.local` (`tankLocalTick`'s +COW trigger), matching the crash stack's `Array._makeMutableAndUnique` abort exactly. +`HostGameEngine.swift`'s own header already documented the one safe way to get `state` off the +tick loop (`onTickRendered`'s value-type snapshot -- "never the live `state` itself, which only +the consumer `Task` may ever touch"); `adminState`/`liveState` just weren't using it. Fixed by +caching that snapshot into a new `GameSession.hostLiveState` property, updated alongside +`hudSnapshot` in the same `onTickRendered` closure, and reading it instead of `hostEngine.state`. +146/146 `Bolo 2026Tests` and 956/956 SwiftPM tests green (one pre-existing timing flake, +confirmed unrelated and passes in isolation). + Also noted in #150's body, not implemented, no ruling requested yet (same disclosed-new-feature category as #145): even a fixed oracle near/far model is binary, two fixed clips -- true continuous-distance volume/pan would be richer than the oracle itself. From 9cfe074fb8ee24c90b6fa5c1a1a53be30a60c32b Mon Sep 17 00:00:00 2001 From: "Claude (Architectural Reviewer)" Date: Thu, 24 Sep 2026 07:06:24 -0500 Subject: [PATCH 14/14] docs: refresh README status lines for v1.6.0 tag and the untagged v1.6.x parity arc README's version-status lines still said v1.5.0/v1.5.1-in-flight/v1.6.0-next-sprint. v1.6.0 (Metal renderer) has since tagged and released, v1.6.1 merged to main, and this branch's full v1.6.x themed parity arc (man/tank/boat/sound, milestones 23-26) plus the #139 host-engine crash fix are done but not yet tagged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XdDqxCXosBhuFQWVkuCzrz --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c17ba91..005ea8f 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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`.