diff --git a/backlog/decisions/decision-034 - Two-player-core-semantics-shared-status-per-player-alive-collision-rules.md b/backlog/decisions/decision-034 - Two-player-core-semantics-shared-status-per-player-alive-collision-rules.md new file mode 100644 index 0000000..b185985 --- /dev/null +++ b/backlog/decisions/decision-034 - Two-player-core-semantics-shared-status-per-player-alive-collision-rules.md @@ -0,0 +1,108 @@ +--- +id: decision-034 +title: Two-player core semantics -- shared status, per-player alive flag, cross-player collision, mutual kill +date: '2026-09-13 21:57' +status: Accepted +--- +## Context + +TASK-051 reads as "nearly free, Godot-layer only" ("Add local 2-player support to the Godot +presentation and input layers"), but `core/world.zig` had no `player_count` and no per-player +state at all before this task -- `MAX_SUPPORTED_PLAYERS` in `core/abi.zig` was hardcoded to `1`, +and `World` was a single flat struct. Godot's `world.gd` (TASK-027) already forwards `player`/ +`player_count` params through every method to the GDExtension class, and `include/neo_snake.h`'s +`ns_input`/`ns_player_view`/`ns_config.player_count` are already N-player-shaped -- but nothing on +the `core/` side backs any of it for `player_count > 1`. Real two-player local play needs that +support built first. + +`decision-020` (TASK-021) explicitly left cross-player collision "genuinely undecided," scoped its +own two-player primitive to `core/fuzz_seeds.zig` only, and said real PvP rules are "left entirely +to whatever TASK-053/m-8 designs" -- deliberately not constraining this decision. `reference/ +snake.html` is single-player only and has no oracle behavior to match here; every choice below is a +new design, not a parity check. + +## Decision + +**`MAX_PLAYERS: u8 = 2`** (in `core/world.zig`, reused by `core/abi.zig`'s +`MAX_SUPPORTED_PLAYERS`) -- capped at exactly 2, not N, matching this task's actual AC ("two +locally-controlled players") and `decision-020`'s own `MULTI_PLAYERS=2` precedent. YAGNI: nothing +in this repo needs more than 2 yet. + +**Shared world-level `status` (menu/playing/paused/dead) drives the overall game phase.** Pausing +pauses both players; either player's first direction input (from menu or dead) starts or restarts +the whole shared match for both -- `start()`/`reset()` stay whole-world operations, extending +`decision-015`'s existing single-player "press any direction to begin/restart" quirk to a +same-screen match's natural convention. + +**Per-player `alive: bool`, not a full independent `Status` enum.** The per-player status exposed +through the ABI (`ns_player_view_get`, HUD-facing) is a single projection: `if (alive) w.status +else .dead` (`playerStatus` in `core/abi.zig`). A dead player's board keeps rendering as "dead" +even while the match continues for a survivor, without needing two parallel state machines. + +**Starting position: `cy(i) = rows * (i+1) / (player_count+1)`** (integer division), spacing +`player_count` players evenly down the board. This is a strict generalization of the oracle's own +`rows/2` formula: at `player_count == 1` it reduces to `rows*1/2 == rows/2` exactly, so +single-player starting position is unchanged byte-for-byte. + +**Cross-player collision, two-phase (extending `decision-020`'s phase split):** +- Phase 1 (read-only, order-independent): for each player, compute the candidate new head cell + and check it against (a) walls/self-body, exactly as single-player already does, and (b) every + *other* player's full pre-tick body -- with **no tail-vacate exception** granted to opponents. + An opponent's tail cell is still occupied as far as this tick's move is concerned, since that + opponent's own tick hasn't resolved yet. This is the simplest, most conservative reading + available and avoids having to define a cross-player analog of the single-player tail-vacate + rule that decision-020 never touched. +- Phase 2 (fixed ascending player-index order): mutate bodies, apply deaths, resolve eating/food + respawn -- same ascending-index rule `decision-020` and `docs/abi-decisions.md` already require + for the shared RNG stream and food placement. + +**Head-to-head mutual kill:** if two surviving players' computed new-head cells coincide, both +die. Symmetric outcome, no arbitrary tie-break by player index -- a same-screen match where two +snakes collide head-on is expected to be a draw for both, not a coin-flip favoring one player. + +**Win uses the same "win == dead" sentinel convention as single-player**, per player -- no separate +"won" state was invented. + +**A dead player's body is never cleared -- a permanent corpse/obstacle**, exactly matching +single-player's existing behavior of never clearing the body on death. This is a deliberate +divergence from `fuzz_seeds.zig`'s non-canonical `MultiPlayer`/`placeFoodShared` primitive (which +skips dead players when checking occupancy) -- legitimate since `decision-020` explicitly said that +primitive's scope does not anticipate or constrain this design. Corpses remain solid obstacles for +the survivor. + +**World-level `tick` increments only if at least one player is alive after the tick resolves** -- +an exact generalization of single-player's existing "die()/win() return before `w.tick += 1`" +quirk; at `player_count == 1` it reduces to the original behavior exactly. + +**World-level `status` becomes `.dead` only when every player is simultaneously eliminated.** + +**Tick period/speed is read from player 0's score only**, not per-player -- a shared-screen match +shares one clock either way, so there is no meaningful per-player speed to derive. + +**Wire format:** `canon.decode()`'s cell packing is contiguous/bump-allocator-style (every player's +cells packed back-to-back from offset 0, sized by each player's actual `body_len`), which does not +match `World`'s new fixed-per-player-offset `cells_buf` layout once `player_count > 1`. +`ns_deserialize` reconstructs a flat scratch region (reusing `players[0]`'s own backing buffer, +sized `per_player * player_count`), decodes into it, then relocates each player's decoded cells +into its real per-player buffer via `@memmove` -- safe regardless of processing order, since each +player's real destination offset can never overlap an unread portion of the source range when every +`body_len <= per_player`. + +**`ns_world_init` rejects `player_count > 1` when `rows <= 8`** (`NS_ERR_INVALID_ARGUMENT`) -- +guards against a board too short for two players' 3-cell starting snakes to avoid immediately +overlapping under the `cy(i)` spacing formula at small row counts. + +## Consequences + +`core/world.zig`, `core/abi.zig`, and `core/fuzz_seeds.zig` all changed to carry a `player_count` +dimension end to end. Every existing single-player test (Tier-A `core/build.zig test`, Tier-B +`difftest`, Tier-C `abitest`) passes unchanged -- confirming `player_count == 1` behavior is +byte-for-byte preserved under every one of the generalizations above. A new two-player test suite +in `core/world.zig` (starting-row spacing, wall-death-doesn't-stop-survivor, all-players-eliminated +stops the tick counter, opponent-body-collision-is-fatal, head-to-head mutual kill) and a new +`core/abitest.zig` conformance test (two independently-controlled players driven purely through the +C ABI) exercise the new behavior directly. `fuzz_seeds.zig`'s own private `MultiPlayer` primitive +(`decision-020`) is left untouched -- it remains a narrower, non-canonical permutation-invariance +fixture, not a rehearsal of this design, and continues to diverge from it exactly where documented +above (no dead-player occupancy skip). Any future lockstep/netcode work (TASK-053, m-8) inherits +this collision/status model as the real one, rather than re-deciding it from scratch. diff --git a/backlog/decisions/decision-035 - Godot-layer-local-2-player-keyboard-split-shared-status-derivation-scoped-best-score.md b/backlog/decisions/decision-035 - Godot-layer-local-2-player-keyboard-split-shared-status-derivation-scoped-best-score.md new file mode 100644 index 0000000..2b12d1f --- /dev/null +++ b/backlog/decisions/decision-035 - Godot-layer-local-2-player-keyboard-split-shared-status-derivation-scoped-best-score.md @@ -0,0 +1,77 @@ +--- +id: decision-035 +title: Godot-layer local 2-player -- keyboard split, ABI-free shared-status derivation, player-0-scoped best score +date: '2026-09-13 22:13' +status: Accepted +--- +## Context + +`decision-034` settled `core/world.zig`/`core/abi.zig`'s 2-player semantics. TASK-051's remaining +scope is the Godot presentation/input layers, and its AC#1 is a hard constraint: this task must not +modify `include/neo_snake.h`. Three judgment calls came up wiring `game/` up to the now-real +2-player ABI, none of them dictated by the task text or by `decision-034`. + +## Decision + +**GameScreen always initializes `player_count = 2`**, not a togglable 1p/2p mode. Extending the +existing menu's mode selector (`game/content/modes.json`) to also pick a player count would require +cross-validating against `tuning.json`'s `scoring.best_score_defaults` (`game/content/loader.gd`'s +`MODES_SCHEMA`) for no benefit this task's AC actually asks for -- YAGNI. + +**Keyboard scheme split: player 0 keeps arrow keys only, player 1 gets WASD only** +(`game/platform/input_defaults.gd`'s new `ACTION_P2_MOVE_*` actions, `game/project.godot`'s +`p2_move_*` bindings). Player 0's `move_up`/`move_down`/`move_left`/`move_right` action *names* are +unchanged (save-data/keybind-codec compatibility), but their physical keycodes drop WASD -- keeping +WASD on both player 0's and player 1's actions would fire both players' movement from one keypress, +a real bug, not a style preference. Touch swipe and joypad analog stick stay routed to player 0 only +(`input_router.gd`'s `_handle_drag`/`_handle_joypad_motion` hardcode player 0) -- no natural +per-player mapping exists for either input kind on one shared keyboard/no second controller, so this +is a deliberate scope decision, not an oversight. + +**`GameScreen._shared_status()` reconstructs the true shared world status from only per-player ABI +calls**, since AC#1 forbids adding any new header/ABI function to expose the shared status directly: + + func _shared_status() -> int: + var v0: int = world.player_view_get(0).status + if v0 != BoardGeometry.STATUS_DEAD: + return v0 + return world.player_view_get(1).status + +Correct because `core/abi.zig`'s `playerStatus` projects `w.status` onto a still-alive player +unchanged, and (per `decision-034`) the shared world only becomes `.dead` once every player is +simultaneously eliminated. So checking player 0 first and falling back to player 1 only when player +0's own projection already reads DEAD always yields the true shared phase, for every alive/dead +combination. Used for both shared decisions (pause/restart/overlay gating, the overlay's own +screen) and, separately, each player's own raw `player_view_get(i).status` still drives that +player's own HUD status label (`hud.update`/`hud.update_p2`) -- a dead player's board and status +text keep reading "dead" even while the match continues for a survivor, matching `decision-034`'s +per-player `alive` semantics. + +**Best-score persistence stays player-0/mode-scoped only.** Player 1's score renders live in the HUD +(`Hud.update_p2`) but is never compared against or written to `_save_data.best_scores` -- the +existing best-score architecture is inherently single-value-per-mode, and extending it to per-player +bests is out of this task's scope. + +**`BoardView` needed zero code changes.** It was already generic over `player` +(`setup(world, tuning, palette, p_player: int = 0)`, every draw call already parameterized). 2-player +rendering is a second `BoardView` instance (`board_view_p2`) at the same position/size, sharing the +same `SimulationWorld`, independently redrawing player 1's snake on top of the (harmlessly +redundantly redrawn) shared board/food/grid. + +**Per-player death/eat fx cues route via the event dict's existing `"player"` field** +(`core/abi.zig`'s `pushEvent` already tags every event with its player index) -- `GameScreen._process` +picks `board_view` or `board_view_p2` per drained event before calling `.notify_eat()`/ +`.fx.trigger_flash()`, so player 1's death flash doesn't fire on player 0's board and vice versa. +Coalesced sfx cues (`AudioEventCoalescer`) stay global/shared, matching a same-screen match sharing +one speaker. + +## Consequences + +`game/platform/input_defaults.gd`, `game/project.godot`, `game/platform/input_router.gd`, +`game/presentation/screens/hud.gd`, and `game/presentation/screens/game_screen.gd` all changed. +`board_view.gd` did not. Every existing single-player-shaped test in `game/tests/test_game_screen.gd` +passes unchanged, since `_on_direction_queued(dir, player: int = 0)` keeps the old single-arg call +shape and player 0's individually-observable behavior for those call sequences is unaffected. +`game/tests/test_input_defaults.gd` auto-covers the new P2 actions since it iterates +`InputDefaults.ACTION_PHYSICAL_KEYCODES` generically. No new ABI/header symbol was added anywhere in +this diff, satisfying AC#1. diff --git a/backlog/tasks/task-051 - Add-local-2-player-support.md b/backlog/tasks/task-051 - Add-local-2-player-support.md index 5424022..2e358e1 100644 --- a/backlog/tasks/task-051 - Add-local-2-player-support.md +++ b/backlog/tasks/task-051 - Add-local-2-player-support.md @@ -1,7 +1,7 @@ --- id: TASK-051 title: Add local 2-player support -status: To Do +status: Done assignee: [] created_date: '2026-09-09 22:16' labels: [] @@ -21,15 +21,50 @@ Add local 2-player support to the Godot presentation and input layers. This shou ## Acceptance Criteria -- [ ] #1 This task does not modify include/neo_snake.h — verified by git diff on the PR containing this work -- [ ] #2 Two locally-controlled players can play simultaneously on one board with independent input routing -- [ ] #3 Per-player score/status HUD elements both update correctly using ns_player_view_get +- [x] #1 This task does not modify include/neo_snake.h — verified by git diff on the PR containing this work +- [x] #2 Two locally-controlled players can play simultaneously on one board with independent input routing +- [x] #3 Per-player score/status HUD elements both update correctly using ns_player_view_get ## Definition of Done -- [ ] #1 task check is green -- [ ] #2 Any deviation from reference/snake.html behavior is recorded in backlog/decisions/, not left implicit -- [ ] #3 Docs touched by the change are updated in the same commit -- [ ] #4 The task file's AC/notes/status are synced in the same commit as the code +- [x] #1 task check is green +- [x] #2 Any deviation from reference/snake.html behavior is recorded in backlog/decisions/, not left implicit +- [x] #3 Docs touched by the change are updated in the same commit +- [x] #4 The task file's AC/notes/status are synced in the same commit as the code + +## Notes + + +The core-layer 2-player semantics (`core/world.zig`/`core/abi.zig`) were already settled by +[[decision-034]] in a prior session on this branch: shared world-level `status`, per-player `alive` +flag, `playerStatus(w,i)` projection, two-phase collision with head-to-head mutual kill, corpses +never cleared, world only reaches `.dead` once every player is eliminated. + +This session's scope was the remaining Godot presentation/input layers, all judgment calls recorded +in [[decision-035]]: `GameScreen` always initializes `player_count = 2` (no togglable 1p/2p mode — +YAGNI, nothing in this task's AC asks for a mode toggle); player 0 keeps arrow keys only and player 1 +gets a new WASD-only action set (`game/platform/input_defaults.gd`'s `ACTION_P2_MOVE_*`, +`game/project.godot`'s `p2_move_*` bindings) so a single keypress can't drive both players; touch +swipe and joypad analog stick stay routed to player 0 only (no natural per-player mapping exists for +either on one shared keyboard/no second controller); `GameScreen._shared_status()` reconstructs the +true shared world phase from only `ns_player_view_get` calls (no new ABI/header symbol, satisfying +AC#1) by reading player 0's status and falling back to player 1's only when player 0 reads DEAD; +best-score persistence stays player-0/mode-scoped only (per-player bests are out of scope); +`board_view.gd` needed zero changes since it was already generic over `player` — 2-player rendering +is a second `BoardView` instance (`board_view_p2`) sharing the same `SimulationWorld`; per-player +death/eat fx cues route via the event dict's existing `"player"` field so player 1's death flash +doesn't fire on player 0's board and vice versa. + +AC#1 verified via `git diff --stat main -- include/neo_snake.h` producing zero output. AC#2/#3 +covered by four new tests in `game/tests/test_game_screen.gd` +(`test_game_screen_wires_a_second_board_view_for_player_1`, +`test_player_1_direction_input_steers_player_1_without_touching_player_0`, +`test_player_0_direction_input_still_defaults_to_player_0`, +`test_hud_reflects_both_players_score_and_status_after_start`). Full `task check` is green: 123 test +cases / 0 errors / 0 failures / 0 flaky / 0 skipped across 20 suites, plus audio/music checks. +`docs/architecture.md` was checked (grep for `direction_queued`/`player_count`/`PLAYER_ACTIONS`) and +needs no update — it documents `reference/snake.html`'s architecture, which this Godot-layer-only +task doesn't touch. + diff --git a/core/abi.zig b/core/abi.zig index 2797721..a54f906 100644 --- a/core/abi.zig +++ b/core/abi.zig @@ -16,12 +16,12 @@ const std = @import("std"); const world = @import("world"); const canon = @import("canon"); -/// Only value #1 of docs/abi-decisions.md's multiplayer freeze is exercised -/// today (backlog/decisions/decision-020: full multiplayer is milestone -/// m-8, not this task). Every player-index check below compares against the -/// *stored* player_count rather than a literal 0, so relaxing this to > 1 -/// later is additive, not a rewrite — see docs/abi-impl.md. -const MAX_SUPPORTED_PLAYERS: u8 = 1; +/// TASK-051/decision-034 relaxed this from 1 to world.MAX_PLAYERS (2) — the +/// additive change docs/abi-impl.md's original note anticipated: every +/// player-index check below already compared against the *stored* +/// player_count rather than a literal 0, so no call site needed to change, +/// only this bound and core/world.zig's own generalization. +const MAX_SUPPORTED_PLAYERS: u8 = world.MAX_PLAYERS; pub const Result = i32; pub const NS_OK: Result = 0; @@ -151,32 +151,55 @@ fn pushEvent(storage: *WorldStorage, tick: u32, player: u8, kind: u8) void { storage.event_len += 1; } -/// Advances exactly one tick and records the event it produced, if any. -/// docs/abi-impl.md "Telling win from die apart" explains why `ate` is what -/// distinguishes the two: core/world.zig's win() and die() both just set -/// status = .dead, and win() is only ever reached through the eating branch, -/// so "became dead AND scored this tick" is exactly winning. -fn stepOneTick(storage: *WorldStorage, player: u8) void { +/// Advances exactly one tick and records the event(s) it produced, if any, +/// for every player (decision-034 generalized this from a single hardcoded +/// player index to a per-player pre/post diff). docs/abi-impl.md "Telling +/// win from die apart" explains why `ate` is what distinguishes the two: +/// core/world.zig's advance() never marks a player dead except through +/// die()/the board-full win branch, and win only ever happens on the same +/// tick that player ate, so "became dead AND scored this tick" is exactly +/// winning, per player, independently of what any other player did this +/// tick. +fn stepOneTick(storage: *WorldStorage) void { const w = &storage.world; - const pre_score = w.score; - const pre_status = w.status; + var pre_alive: [world.MAX_PLAYERS]bool = undefined; + var pre_score: [world.MAX_PLAYERS]u32 = undefined; + for (0..w.player_count) |i| { + pre_alive[i] = w.players[i].alive; + pre_score[i] = w.players[i].score; + } + world.advance(w); - const ate = w.score != pre_score; - if (ate) pushEvent(storage, w.tick, player, NS_EVENT_EAT); - if (pre_status == .playing and w.status == .dead) { - pushEvent(storage, w.tick, player, if (ate) NS_EVENT_WIN else NS_EVENT_DIE); + for (0..w.player_count) |i| { + const ate = w.players[i].score != pre_score[i]; + if (ate) pushEvent(storage, w.tick, @intCast(i), NS_EVENT_EAT); + if (pre_alive[i] and !w.players[i].alive) { + pushEvent(storage, w.tick, @intCast(i), if (ate) NS_EVENT_WIN else NS_EVENT_DIE); + } } } -fn buildCanonState(w: *const world.World, players_buf: *[1]canon.Player) canon.State { - players_buf[0] = .{ - .status = w.status, - .dir = w.dir, - .next_dir = w.next_dir, - .score = w.score, - .cells = world.cells(w), - }; +/// decision-034: an eliminated player's exposed status is always `.dead`, +/// permanently, until the next reset() — a still-alive player's exposed +/// status always mirrors the one shared world-level phase (menu / playing / +/// paused). `world.PlayerState` deliberately has no status field of its own; +/// this is the single place that projection is computed for the ABI/ +/// canonical-format boundary. +fn playerStatus(w: *const world.World, player: u8) canon.Status { + return if (w.players[player].alive) w.status else .dead; +} + +fn buildCanonState(w: *const world.World, players_buf: *[world.MAX_PLAYERS]canon.Player) canon.State { + for (0..w.player_count) |i| { + players_buf[i] = .{ + .status = playerStatus(w, @intCast(i)), + .dir = w.players[i].dir, + .next_dir = w.players[i].next_dir, + .score = w.players[i].score, + .cells = world.cells(w, @intCast(i)), + }; + } return .{ .cols = w.cols, .rows = w.rows, @@ -184,14 +207,14 @@ fn buildCanonState(w: *const world.World, players_buf: *[1]canon.Player) canon.S .tick = w.tick, .rng_state = w.rng.state(), .food = w.food, - .players = players_buf[0..1], + .players = players_buf[0..w.player_count], }; } // --- World lifecycle -------------------------------------------------- export fn ns_world_size(config: *const Config) callconv(.c) usize { - const cell_bytes = world.requiredCells(config.cols, config.rows) * @sizeOf(world.Cell); + const cell_bytes = world.requiredCells(config.cols, config.rows) * @as(usize, config.player_count) * @sizeOf(world.Cell); return @sizeOf(WorldStorage) + cell_bytes; } @@ -205,8 +228,14 @@ export fn ns_world_init(world_ptr: *anyopaque, config: *const Config) callconv(. if (config.speed_source != NS_SPEED_SOURCE_SCORE_TABLE) return NS_ERR_INVALID_ARGUMENT; // reset()'s starting snake occupies x = 6..8, so cols must leave that // placement on the board; rows just needs to be nonzero for rows/2 to - // land on a real row. + // land on a real row. decision-034's starting-row formula spaces + // `player_count` players between row 0 and `rows`, so a two-player match + // additionally needs enough rows to keep those rows distinct and clear + // of the top/bottom edges — the same generous `<= 8` threshold the + // single-player cols check already uses, reused rather than a new + // constant. if (config.cols <= 8 or config.rows == 0) return NS_ERR_INVALID_ARGUMENT; + if (config.player_count > 1 and config.rows <= 8) return NS_ERR_INVALID_ARGUMENT; if (config.rng_seed[0] == 0 and config.rng_seed[1] == 0 and config.rng_seed[2] == 0 and config.rng_seed[3] == 0) { return NS_ERR_INVALID_ARGUMENT; } @@ -218,11 +247,11 @@ export fn ns_world_init(world_ptr: *anyopaque, config: *const Config) callconv(. const base: [*]u8 = @ptrCast(world_ptr); const cells_ptr: [*]world.Cell = @ptrCast(@alignCast(base + @sizeOf(WorldStorage))); - const cells_buf = cells_ptr[0..world.requiredCells(config.cols, config.rows)]; + const cells_buf = cells_ptr[0 .. world.requiredCells(config.cols, config.rows) * @as(usize, config.player_count)]; // Starts in .menu, matching the reference app's own startup: the first // ns_queue_dir call is what transitions it to playing (decision-015). - world.initWorld(&storage.world, cells_buf, config.cols, config.rows, config.wrap != 0, config.rng_seed, .menu); + world.initWorld(&storage.world, cells_buf, config.cols, config.rows, config.player_count, config.wrap != 0, config.rng_seed, .menu); return NS_OK; } @@ -242,7 +271,7 @@ export fn ns_queue_dir(world_ptr: *anyopaque, player: u8, dir: u8) callconv(.c) const storage = storageOf(world_ptr); if (player >= storage.player_count) return NS_ERR_INVALID_ARGUMENT; if (dir > NS_DIR_RIGHT) return NS_ERR_INVALID_ARGUMENT; - world.queueDir(&storage.world, @enumFromInt(dir)); + world.queueDir(&storage.world, player, @enumFromInt(dir)); return NS_OK; } @@ -265,12 +294,12 @@ export fn ns_step(world_ptr: *anyopaque, inputs: [*]const Input, input_count: us i = 0; while (i < input_count) : (i += 1) { const inp = inputs[i]; - world.queueDir(&storage.world, @enumFromInt(inp.dir)); + world.queueDir(&storage.world, inp.player, @enumFromInt(inp.dir)); } // Only advance if the world was already playing before this call's // inputs landed — the call that starts the game from the menu must not // itself consume a tick (docs/abi-header.md). - if (was_playing) stepOneTick(storage, 0); + if (was_playing) stepOneTick(storage); return NS_OK; } @@ -290,11 +319,14 @@ export fn ns_pump(world_ptr: *anyopaque, dt_us: u32, out_steps: *u32) callconv(. const dt = @min(dt_us, world.MAX_DT_US); w.acc_us += dt; var steps: u32 = 0; - var step_us = world.tickPeriodUs(w.score); + // The tick period is read from player 0's score, matching + // core/world.zig's own pump(): speed is not a per-player notion (a + // shared-screen match shares one clock either way, decision-034). + var step_us = world.tickPeriodUs(w.players[0].score); while (w.acc_us >= step_us and w.status == .playing and steps < world.MAX_STEPS) { w.acc_us -= step_us; - stepOneTick(storage, 0); - step_us = world.tickPeriodUs(w.score); + stepOneTick(storage); + step_us = world.tickPeriodUs(w.players[0].score); steps += 1; } out_steps.* = steps; @@ -307,13 +339,14 @@ export fn ns_player_view_get(world_ptr: *const anyopaque, player: u8, out_view: const storage = storageOfConst(world_ptr); if (player >= storage.player_count) return NS_ERR_INVALID_ARGUMENT; const w = &storage.world; + const p = &w.players[player]; out_view.* = .{ - .status = @intFromEnum(w.status), - .dir = @intFromEnum(w.dir), - .next_dir = @intFromEnum(w.next_dir), + .status = @intFromEnum(playerStatus(w, player)), + .dir = @intFromEnum(p.dir), + .next_dir = @intFromEnum(p.next_dir), ._pad0 = 0, - .score = w.score, - .body_len = w.cells_len, + .score = p.score, + .body_len = p.cells_len, ._pad1 = 0, }; return NS_OK; @@ -323,12 +356,12 @@ export fn ns_body_copy(world_ptr: *const anyopaque, player: u8, out_cells: ?[*]C const storage = storageOfConst(world_ptr); if (player >= storage.player_count) return NS_ERR_INVALID_ARGUMENT; const w = &storage.world; - const required: usize = w.cells_len; + const required: usize = w.players[player].cells_len; out_required.* = required; if (out_cells == null or out_capacity == 0) return NS_OK; if (out_capacity < required) return NS_ERR_BUFFER_TOO_SMALL; - const live = world.cells(w); + const live = world.cells(w, player); var idx: usize = 0; while (idx < required) : (idx += 1) { out_cells.?[idx] = .{ .x = live[idx].x, .y = live[idx].y }; @@ -340,14 +373,14 @@ export fn ns_body_copy(world_ptr: *const anyopaque, player: u8, out_cells: ?[*]C export fn ns_canon_len(world_ptr: *const anyopaque) callconv(.c) usize { const storage = storageOfConst(world_ptr); - var players_buf: [1]canon.Player = undefined; + var players_buf: [world.MAX_PLAYERS]canon.Player = undefined; const state = buildCanonState(&storage.world, &players_buf); return canon.encodedLen(state); } export fn ns_serialize(world_ptr: *const anyopaque, out_buf: [*]u8, out_capacity: usize, out_written: *usize) callconv(.c) Result { const storage = storageOfConst(world_ptr); - var players_buf: [1]canon.Player = undefined; + var players_buf: [world.MAX_PLAYERS]canon.Player = undefined; const state = buildCanonState(&storage.world, &players_buf); const need = canon.encodedLen(state); if (out_capacity < need) { @@ -364,15 +397,30 @@ export fn ns_deserialize(world_ptr: *anyopaque, bytes: [*]const u8, len: usize) const record = bytes[0..len]; if (!canon.verify(record)) return NS_ERR_DECODE_FAILED; - var players_buf: [1]canon.Player = undefined; - const decoded = canon.decode(record, &players_buf, storage.world.cells_buf) catch return NS_ERR_DECODE_FAILED; - // Board size is fixed at ns_world_init time (it sizes the cell buffer); - // a record for a different board can't be rehydrated into this world. - if (decoded.cols != storage.world.cols or decoded.rows != storage.world.rows) return NS_ERR_DECODE_FAILED; - if (decoded.players.len != 1) return NS_ERR_DECODE_FAILED; - - const p = decoded.players[0]; const w = &storage.world; + // canon.decode() packs every player's cells contiguously starting at + // cells_out[0], not at each player's own fixed per-player offset in this + // world's real storage — so decode into the world's own flat cell region + // (reconstructed from player 0's cells_buf, which starts exactly where + // ns_world_init placed it) as scratch, and relocate each player's cells + // into its real per-player cells_buf afterward (decision-034; this + // module's single-player predecessor didn't need this step because with + // exactly one player, contiguous-from-0 and "this player's own buffer" + // were already the same range). + const per_player = w.players[0].cells_buf.len; + const flat_cells = w.players[0].cells_buf.ptr[0 .. per_player * @as(usize, w.player_count)]; + + var players_buf: [world.MAX_PLAYERS]canon.Player = undefined; + const decoded = canon.decode(record, &players_buf, flat_cells) catch return NS_ERR_DECODE_FAILED; + // Board size and player count are fixed at ns_world_init time (they size + // the cell buffer); a record for a different board/player-count can't be + // rehydrated into this world. + if (decoded.cols != w.cols or decoded.rows != w.rows) return NS_ERR_DECODE_FAILED; + if (decoded.players.len != w.player_count) return NS_ERR_DECODE_FAILED; + for (decoded.players) |dp| { + if (dp.cells.len > per_player) return NS_ERR_DECODE_FAILED; + } + w.wrap = decoded.wrap; w.tick = decoded.tick; // Built directly rather than via rng.Rng.init: init() asserts a @@ -385,11 +433,31 @@ export fn ns_deserialize(world_ptr: *anyopaque, bytes: [*]const u8, len: usize) // owes it nothing from the session that produced the record. w.acc_us = 0; w.food = decoded.food; - w.status = p.status; - w.dir = p.dir; - w.next_dir = p.next_dir; - w.score = p.score; - w.cells_len = @intCast(p.cells.len); + + // decision-034: a live player's status always mirrors the one shared + // world-level phase, and an eliminated player's is always `.dead` — so + // the shared phase is recovered from whichever player's decoded status + // isn't `.dead` (ascending index; a validly-encoded record has every + // alive player agreeing), falling back to `.dead` only if every player + // in the record was already eliminated. + w.status = .dead; + for (decoded.players) |dp| { + if (dp.status != .dead) { + w.status = dp.status; + break; + } + } + + for (0..w.player_count) |i| { + const dp = decoded.players[i]; + var p = &w.players[i]; + p.dir = dp.dir; + p.next_dir = dp.next_dir; + p.score = dp.score; + p.alive = dp.status != .dead; + p.cells_len = @intCast(dp.cells.len); + @memmove(p.cells_buf[0..dp.cells.len], dp.cells); + } storage.event_head = 0; storage.event_len = 0; diff --git a/core/abitest.zig b/core/abitest.zig index 2550ceb..9573ac4 100644 --- a/core/abitest.zig +++ b/core/abitest.zig @@ -159,6 +159,70 @@ test "@sizeOf/@offsetOf on the wire structs match docs/canonical-state.md exactl try std.testing.expectEqual(@as(usize, 8), @offsetOf(c.ns_player_view, "body_len")); } +test "two independently-controlled players run concurrently through the C ABI (TASK-051)" { + var storage: StorageBuf = .{}; + const config: c.ns_config = .{ + .abi_version = c.NS_ABI_VERSION, + .cols = 12, + .rows = 12, + .player_count = 2, + .wrap = 0, + .rng_seed = .{ 1, 2, 3, 4 }, + .speed_source = c.NS_SPEED_SOURCE_SCORE_TABLE, + ._pad = .{ 0, 0, 0 }, + }; + try initOk(&storage, &config); + + // Player 0's queued direction starts the shared match for both players + // (decision-015's quirk, whole-world per decision-034); the second + // event in the same call routes player 1 onto a different heading — + // independent per-player input routing (AC#2). The call that starts + // the game from the menu never itself consumes a tick, so nothing has + // moved yet after this one. + const start_inputs = [_]c.ns_input{ + .{ .player = 0, .dir = c.NS_DIR_RIGHT, ._pad = .{ 0, 0 } }, + .{ .player = 1, .dir = c.NS_DIR_UP, ._pad = .{ 0, 0 } }, + }; + try std.testing.expectEqual( + @as(c.ns_result, c.NS_OK), + c.ns_step(storage.ptr(), &start_inputs, start_inputs.len), + ); + + // Four more ticks with no further input: player 0 keeps walking right + // toward the wall (cols=12, no wrap -> dies stepping from x=11 to + // x=12), player 1 keeps walking up, away from any wall. + var i: usize = 0; + while (i < 4) : (i += 1) { + try std.testing.expectEqual( + @as(c.ns_result, c.NS_OK), + c.ns_step(storage.ptr(), null, 0), + ); + } + + // Player 0 has just walked off the right edge; player 1 is untouched + // and the shared match is still running for it -- proof the per-player + // `alive` flag, not the whole world, absorbed the elimination (this is + // exactly the projection AC#3's HUD reads through ns_player_view_get). + var p0: c.ns_player_view = undefined; + var p1: c.ns_player_view = undefined; + try std.testing.expectEqual(@as(c.ns_result, c.NS_OK), c.ns_player_view_get(storage.ptr(), 0, &p0)); + try std.testing.expectEqual(@as(c.ns_result, c.NS_OK), c.ns_player_view_get(storage.ptr(), 1, &p1)); + + try std.testing.expectEqual(@as(c.ns_status, c.NS_STATUS_DEAD), p0.status); + try std.testing.expectEqual(@as(c.ns_status, c.NS_STATUS_PLAYING), p1.status); + try std.testing.expectEqual(@as(c.ns_dir, c.NS_DIR_UP), p1.dir); + + var cells: [8]c.ns_cell = undefined; + var required: usize = 0; + try std.testing.expectEqual( + @as(c.ns_result, c.NS_OK), + c.ns_body_copy(storage.ptr(), 1, &cells, cells.len, &required), + ); + try std.testing.expectEqual(p1.body_len, @as(u32, @intCast(required))); + try std.testing.expectEqual(@as(u16, 4), cells[0].y); // 8 - 4 ticks moving up + try std.testing.expectEqual(@as(u16, 8), cells[0].x); // column unchanged while moving up +} + // --- corpus replay ----------------------------------------------------- /// Trivial hex decoder, deliberately re-implemented here rather than diff --git a/core/difftest.zig b/core/difftest.zig index 37a450b..c743bb0 100644 --- a/core/difftest.zig +++ b/core/difftest.zig @@ -198,10 +198,10 @@ const Trace = struct { // regen_corpus.mjs's initialState() takes status: 'playing' directly // rather than going through queueDir's menu-to-playing transition, so // the tick-0 anchor is a fresh world at tick 0, not one advance in. - world_mod.initWorld(&w, t.cells, t.cols, t.rows, t.wrap, t.seed, .playing); + world_mod.initWorld(&w, t.cells, t.cols, t.rows, 1, t.wrap, t.seed, .playing); for (t.lines, 0..) |line, i| { - for (line.dirs) |d| world_mod.queueDir(&w, d); + for (line.dirs) |d| world_mod.queueDir(&w, 0, d); world_mod.advance(&w); const got = t.encode(&w); @@ -224,10 +224,10 @@ const Trace = struct { fn encode(t: *Trace, w: *const World) []const u8 { t.player = .{ .status = w.status, - .dir = w.dir, - .next_dir = w.next_dir, - .score = w.score, - .cells = world_mod.cells(w), + .dir = w.players[0].dir, + .next_dir = w.players[0].next_dir, + .score = w.players[0].score, + .cells = world_mod.cells(w, 0), }; t.players[0] = t.player; const state = State{ diff --git a/core/fuzz_seeds.zig b/core/fuzz_seeds.zig index 0f502db..48fa9f6 100644 --- a/core/fuzz_seeds.zig +++ b/core/fuzz_seeds.zig @@ -333,12 +333,12 @@ fn nextTurn(r: *rng.Rng) Turn { fn driveSingle(seed: [4]u32, buf: []canon.Cell) world.World { var w: world.World = undefined; - world.initWorld(&w, buf, COLS, ROWS, false, seed, .playing); + world.initWorld(&w, buf, COLS, ROWS, 1, false, seed, .playing); var turns = auxRng(seed, .{ 0x9E3779B1, 0x85EBCA77, 0xC2B2AE3D, 0x27D4EB2F }); var tick: u32 = 0; while (tick < TICKS and w.status == .playing) : (tick += 1) { - world.queueDir(&w, turnedDir(w.dir, nextTurn(&turns))); + world.queueDir(&w, 0, turnedDir(w.players[0].dir, nextTurn(&turns))); world.advance(&w); checkStructuralInvariants(&w); } @@ -350,9 +350,9 @@ fn driveSingle(seed: [4]u32, buf: []canon.Cell) world.World { /// tick coincidentally restoring the property. fn checkStructuralInvariants(w: *world.World) void { // #1: body_len == 3 + score/10 (grows by exactly one cell per 10-point eat). - std.debug.assert(w.cells_len == 3 + w.score / 10); + std.debug.assert(w.players[0].cells_len == 3 + w.players[0].score / 10); - const live = world.cells(w); + const live = world.cells(w, 0); // #2: no duplicate body cells. for (0..live.len) |i| { @@ -375,10 +375,10 @@ fn checkStructuralInvariants(w: *world.World) void { fn stateOf(w: *const world.World, players_buf: *[1]canon.Player) canon.State { players_buf[0] = .{ .status = w.status, - .dir = w.dir, - .next_dir = w.next_dir, - .score = w.score, - .cells = world.cells(w), + .dir = w.players[0].dir, + .next_dir = w.players[0].next_dir, + .score = w.players[0].score, + .cells = world.cells(w, 0), }; return .{ .cols = w.cols, @@ -395,7 +395,7 @@ test "body_len == 3 + score/10 for all 256 seeds" { var buf: [COLS * ROWS]canon.Cell = undefined; for (SEEDS) |seed| { const w = driveSingle(seed, &buf); - try std.testing.expectEqual(3 + w.score / 10, w.cells_len); + try std.testing.expectEqual(3 + w.players[0].score / 10, w.players[0].cells_len); } } @@ -403,7 +403,7 @@ test "no duplicate body cells for all 256 seeds" { var buf: [COLS * ROWS]canon.Cell = undefined; for (SEEDS) |seed| { const w = driveSingle(seed, &buf); - const live = world.cells(&w); + const live = world.cells(&w, 0); for (0..live.len) |i| { for (i + 1..live.len) |j| { try std.testing.expect(!(live[i].x == live[j].x and live[i].y == live[j].y)); @@ -417,7 +417,7 @@ test "food never placed on a body cell for all 256 seeds" { for (SEEDS) |seed| { const w = driveSingle(seed, &buf); if (w.food) |f| { - for (world.cells(&w)) |c| { + for (world.cells(&w, 0)) |c| { try std.testing.expect(!(c.x == f.x and c.y == f.y)); } } diff --git a/core/world.zig b/core/world.zig index 2db3cb7..c5f6a5d 100644 --- a/core/world.zig +++ b/core/world.zig @@ -22,6 +22,15 @@ //! without relinking. World size and layout will be determined by //! ns_world_size/ns_world_init in a later task (TASK-023's C ABI); this is //! the pure-Zig core those exports will wrap. +//! +//! TASK-051 (backlog/decisions/decision-034) generalized this module from a +//! single scalar player to `player_count` (1 or 2) players sharing one +//! board, one food cell, and one RNG stream. `player_count == 1` reproduces +//! every byte of the original single-player behavior — the frozen oracle +//! corpus/fuzz invariants exercise exactly that path, unchanged — the +//! multi-player path is purely additive. See decision-034 for the collision, +//! elimination, and starting-position rules a two-player match uses that a +//! single-player game never exercises. const std = @import("std"); const rng = @import("rng"); @@ -76,25 +85,51 @@ pub const DIR_VEC = [4]struct { dx: i16, dy: i16 }{ .{ .dx = 1, .dy = 0 }, // right }; -/// One snake, one board, one RNG stream — sim.mjs's state object `S`, with -/// the presentation-only fields (flash, particles, the overlay/HUD sync) -/// dropped as they have no canonical byte (docs/canonical-state.md) and the -/// accumulator converted to integer microseconds (see `pump`). +/// Highest player count this module (and core/abi.zig's ABI surface) knows +/// how to simulate — decision-034 scoped real multiplayer to exactly two +/// local players, not an arbitrary N; raising this later is additive. +pub const MAX_PLAYERS: u8 = 2; + +/// One player's own slice of an otherwise-shared World: direction, score, +/// elimination flag, and body. `alive` starts true and only ever latches to +/// false (on death or on winning — decision-034 keeps the single-player +/// win()-is-just-a-flavor-of-die() convention) until the next reset(). +/// `status` is deliberately not tracked per player: a live player's exposed +/// status is always the shared `World.status`, and an eliminated player's is +/// always `.dead` — core/abi.zig projects that directly from `alive` plus +/// `World.status` rather than this struct duplicating the enum. +pub const PlayerState = struct { + dir: Dir, + next_dir: Dir, + score: u32, + alive: bool, + /// Caller-owned; sized `requiredCells(cols, rows)` by ns_world_init + /// (AC#2: all caller-provided memory arrives as explicit parameters). + cells_buf: []Cell, + cells_len: u32, +}; + +/// One snake-or-two, one board, one RNG stream — sim.mjs's state object `S`, +/// with the presentation-only fields (flash, particles, the overlay/HUD +/// sync) dropped as they have no canonical byte (docs/canonical-state.md) +/// and the accumulator converted to integer microseconds (see `pump`). /// -/// The snake body is a window into a caller-supplied cell buffer (AC#2: all -/// caller-provided memory arrives as explicit function parameters, never a -/// global). The caller sizes the buffer via `requiredCells` (cols*rows is -/// always enough); `cells()` returns the live head-first snake, matching -/// canon.Player.cells' head-first convention. +/// `status` is the shared game phase (menu/playing/paused/dead), exactly as +/// in the single-player original: pausing pauses every player at once (a +/// shared-screen local match has one pause key), and the phase only becomes +/// `.dead` once every player is eliminated (`advance`'s tail computes this +/// every tick — decision-034). `food`, `tick`, and `rng` are likewise +/// board-level, not per-player: one shared food cell, one shared clock, one +/// shared RNG stream (docs/abi-decisions.md freeze #1), matching +/// docs/canonical-state.md's wire format, which has exactly one of each at +/// the record header level and only status/dir/next_dir/score/cells inside +/// each per-player record. pub const World = struct { cols: u16, rows: u16, wrap: bool, tick: u32, - score: u32, status: Status, - dir: Dir, - next_dir: Dir, /// `null` is the win / board-full state (the canonical 0xFFFF sentinel). food: ?Cell, @@ -106,76 +141,33 @@ pub const World = struct { /// like sim.mjs's S.acc is not part of the oracle's. acc_us: u32, - /// Caller-owned body storage; `cells()` is the live view into it. - cells_buf: []Cell, - cells_len: u32, + player_count: u8, + players: [MAX_PLAYERS]PlayerState, }; -/// Minimum caller-supplied cell-buffer length for a board: the snake can -/// occupy every cell on a full-board win. +/// Minimum caller-supplied cell-buffer length for one player's board: the +/// snake can occupy every cell on a full-board win. A caller sizing storage +/// for `player_count` players needs `player_count * requiredCells(...)` +/// cells total (core/abi.zig's ns_world_size does exactly this). pub fn requiredCells(cols: u16, rows: u16) usize { return @as(usize, cols) * @as(usize, rows); } -/// sim.mjs's initialState(): a fresh world at the oracle's reset() starting -/// position, food drawn from `seed`. `status` chooses the initial -/// state-machine position — the oracle's own reset() never sets status -/// itself, but initialState takes it as a parameter, so this mirrors that. -/// `cells_buf` must be at least `requiredCells(cols, rows)` long. -pub fn initWorld(w: *World, cells_buf: []Cell, cols: u16, rows: u16, wrap: bool, seed: [4]u32, status: Status) void { - std.debug.assert(cells_buf.len >= requiredCells(cols, rows)); - w.* = .{ - .cols = cols, - .rows = rows, - .wrap = wrap, - .tick = 0, - .score = 0, - .status = status, - .dir = .right, - .next_dir = .right, - .food = null, - .rng = rng.Rng.init(seed), - .acc_us = 0, - .cells_buf = cells_buf, - .cells_len = 0, - }; - reset(w); -} - -/// The live snake, head-first: `cells()[0]` is the head. -pub fn cells(w: *const World) []const Cell { - return w.cells_buf[0..w.cells_len]; +/// The live snake, head-first: `cells(w, player)[0]` is that player's head. +pub fn cells(w: *const World, player: u8) []const Cell { + const p = &w.players[player]; + return p.cells_buf[0..p.cells_len]; } -/// sim.mjs's reset(): 3-cell snake at row (rows/2)|0, head at (8, cy) -/// head-first, dir/next_dir forced to right, score/tick/accumulator cleared, -/// food placed. Like the oracle's, this does not touch `status` — start() -/// sets that after calling it. `wrap` is a preserved setting, not reset. -pub fn reset(w: *World) void { - const cy = w.rows / 2; // integer division, == the oracle's (rows/2)|0 - w.cells_buf[0] = .{ .x = 8, .y = cy }; - w.cells_buf[1] = .{ .x = 7, .y = cy }; - w.cells_buf[2] = .{ .x = 6, .y = cy }; - w.cells_len = 3; - w.dir = .right; - w.next_dir = .right; - w.score = 0; - w.tick = 0; - w.acc_us = 0; - placeFood(w); -} - -/// sim.mjs's start(): reset() first, then status = playing. -pub fn start(w: *World) void { - reset(w); - w.status = .playing; -} - -/// True if any body cell sits on (x, y). O(body_len), like the oracle's +/// True if any body cell of any player (dead or alive — decision-034: an +/// eliminated player's corpse is a permanent obstacle, never cleared) sits +/// on (x, y). O(total body length), like the oracle's single-player /// `occupied()`; board and snake sizes make the linear scan cheap. fn occupied(w: *const World, x: u16, y: u16) bool { - for (cells(w)) |c| { - if (c.x == x and c.y == y) return true; + for (0..w.player_count) |i| { + for (cells(w, @intCast(i))) |c| { + if (c.x == x and c.y == y) return true; + } } return false; } @@ -183,12 +175,14 @@ fn occupied(w: *const World, x: u16, y: u16) bool { /// sim.mjs's placeFood(): the same row-major (outer y, inner x) free-cell /// enumeration — so the same draw indexes the same cell — but found by /// counting free cells and walking to the drawn one instead of building a -/// free-cell list, since this code has no allocator. The snake never -/// self-overlaps and every body cell is on-board, so the free count is -/// exactly cols*rows - body_len. +/// free-cell list, since this code has no allocator. Extended (decision-034) +/// to count every player's body, dead or alive, as occupied: the single +/// shared food cell can never land on anyone's snake, living or eliminated. pub fn placeFood(w: *World) void { const total = requiredCells(w.cols, w.rows); - const free = total - w.cells_len; + var occupied_count: usize = 0; + for (0..w.player_count) |i| occupied_count += w.players[i].cells_len; + const free = total - occupied_count; if (free == 0) { w.food = null; return; @@ -208,20 +202,98 @@ pub fn placeFood(w: *World) void { unreachable; // seen must reach idx < free } +/// sim.mjs's reset(): a 3-cell snake per player, head at (8, cy) head-first, +/// dir/next_dir forced to right, score cleared, every player marked alive, +/// shared tick/accumulator cleared, food placed. Like the oracle's, this +/// does not touch `status` — start() sets that after calling it. `wrap` is a +/// preserved setting, not reset. +/// +/// decision-034's starting-row formula, `cy(i) = rows * (i+1) / (player_count +/// + 1)`, spaces `player_count` players evenly down the board and is an +/// exact generalization of the single-player original: at player_count == 1 +/// it reduces to `rows * 1 / 2 == rows / 2`, the oracle's own formula, +/// byte-for-byte (integer division), so single-player starting position is +/// unchanged. +pub fn reset(w: *World) void { + for (0..w.player_count) |i| { + const cy = @as(u16, @intCast(@as(u32, w.rows) * (i + 1) / (@as(u32, w.player_count) + 1))); + var p = &w.players[i]; + p.cells_buf[0] = .{ .x = 8, .y = cy }; + p.cells_buf[1] = .{ .x = 7, .y = cy }; + p.cells_buf[2] = .{ .x = 6, .y = cy }; + p.cells_len = 3; + p.dir = .right; + p.next_dir = .right; + p.score = 0; + p.alive = true; + } + w.tick = 0; + w.acc_us = 0; + placeFood(w); +} + +/// sim.mjs's initialState(): a fresh world at the oracle's reset() starting +/// position, food drawn from `seed`. `status` chooses the initial +/// state-machine position — the oracle's own reset() never sets status +/// itself, but initialState takes it as a parameter, so this mirrors that. +/// `cells_buf` must be at least `player_count * requiredCells(cols, rows)` +/// long; it is partitioned into `player_count` equal per-player sub-slices, +/// in order (core/abi.zig's ns_world_init owns the actual byte layout this +/// partitions). +pub fn initWorld(w: *World, cells_buf: []Cell, cols: u16, rows: u16, player_count: u8, wrap: bool, seed: [4]u32, status: Status) void { + std.debug.assert(player_count >= 1 and player_count <= MAX_PLAYERS); + const per_player = requiredCells(cols, rows); + std.debug.assert(cells_buf.len >= per_player * player_count); + + w.cols = cols; + w.rows = rows; + w.wrap = wrap; + w.tick = 0; + w.status = status; + w.food = null; + w.rng = rng.Rng.init(seed); + w.acc_us = 0; + w.player_count = player_count; + for (0..player_count) |i| { + w.players[i] = .{ + .dir = .right, + .next_dir = .right, + .score = 0, + .alive = true, + .cells_buf = cells_buf[i * per_player .. (i + 1) * per_player], + .cells_len = 0, + }; + } + reset(w); +} + +/// sim.mjs's start(): reset() first, then status = playing. +pub fn start(w: *World) void { + reset(w); + w.status = .playing; +} + /// sim.mjs's queueDir(): the single choke point for input legality. The /// 180-degree guard reads `dir` while playing and `next_dir` in every other -/// status; a direction queued from menu/dead starts the game, whose reset() -/// then overwrites that direction — decision-015, kept verbatim. -pub fn queueDir(w: *World, d: Dir) void { - const ref = if (w.status == .playing) w.dir else w.next_dir; +/// status; a direction queued from menu/dead starts the game (or restarts a +/// finished match — decision-015's quirk, kept verbatim and, per +/// decision-034, whole-world: any one player's first legal input starts or +/// restarts the shared match for everyone, matching a same-screen local +/// match's "press any direction to begin" convention). An already-eliminated +/// player queuing a direction mid-match is accepted but inert — `advance` +/// never reads a dead player's dir/next_dir again until the next reset(). +pub fn queueDir(w: *World, player: u8, d: Dir) void { + const p = &w.players[player]; + const ref = if (w.status == .playing) p.dir else p.next_dir; const rv = DIR_VEC[@intFromEnum(ref)]; const dv = DIR_VEC[@intFromEnum(d)]; if (dv.dx == -rv.dx and dv.dy == -rv.dy) return; // no instant 180 - w.next_dir = d; + p.next_dir = d; if (w.status == .menu or w.status == .dead) start(w); } -/// sim.mjs's togglePause(): a no-op outside playing/paused. +/// sim.mjs's togglePause(): a no-op outside playing/paused. Whole-world, not +/// per-player — a shared-screen local match pauses for both players at once. pub fn togglePause(w: *World) void { if (w.status == .playing) { w.status = .paused; @@ -230,15 +302,31 @@ pub fn togglePause(w: *World) void { } } -/// sim.mjs's advance(): one simulation step. Statement order is load-bearing -/// — see this module's header. The die()/win() transitions return before the -/// tick increment, matching the oracle's early `return die(S)` / -/// `return win(S)`. -pub fn advance(w: *World) void { - w.dir = w.next_dir; +/// Per-player phase-1 (read-only) result: where advance() would move this +/// player's head, whether that move eats the shared food, or whether it is +/// already fatal — computed against the tick's *starting* snapshot (nobody +/// else has moved yet), so which order players are visited in cannot change +/// the outcome (decision-020's phase-separation pattern, generalized here to +/// include real cross-player collision, which decision-020's own primitive +/// deliberately left unmodeled). +const PlayerMove = struct { + dies: bool, + new_head: Cell, + eats: bool, +}; - const head = cells(w)[0]; - const v = DIR_VEC[@intFromEnum(w.dir)]; +/// Phase 1 for one player: commit dir, compute the candidate head, and check +/// walls/wrap, self-collision (tail-vacate exception preserved verbatim), +/// and — decision-034's new rule — collision against every *other* player's +/// full, pre-tick body (dead or alive: a corpse still blocks). No tail-vacate +/// exception is extended to an opponent's body: their tail's fate is still +/// unresolved at this point in the tick (phase 2 hasn't run), so treating +/// their whole pre-tick body as solid is the conservative, order-independent +/// reading of "occupied right now". +fn planMove(w: *const World, player: u8) PlayerMove { + const p = &w.players[player]; + const head = p.cells_buf[0]; + const v = DIR_VEC[@intFromEnum(p.dir)]; var nx: i32 = @as(i32, head.x) + @as(i32, v.dx); var ny: i32 = @as(i32, head.y) + @as(i32, v.dy); @@ -250,44 +338,120 @@ pub fn advance(w: *World) void { nx = @mod(nx + @as(i32, w.cols), @as(i32, w.cols)); ny = @mod(ny + @as(i32, w.rows), @as(i32, w.rows)); } else if (nx < 0 or ny < 0 or nx >= @as(i32, w.cols) or ny >= @as(i32, w.rows)) { - return die(w); + return .{ .dies = true, .new_head = undefined, .eats = false }; } - const ncell = Cell{ .x = @intCast(nx), .y = @intCast(ny) }; + const nc = Cell{ .x = @intCast(nx), .y = @intCast(ny) }; // Tail vacates this tick unless we eat: self-collision is tested against // the body minus the cell it is about to vacate, which is why moving - // into the space your tail leaves is legal. - const eating = w.food != null and ncell.x == w.food.?.x and ncell.y == w.food.?.y; - const body_len_check: u32 = if (eating) w.cells_len else w.cells_len - 1; - for (0..body_len_check) |i| { - const c = w.cells_buf[i]; - if (c.x == ncell.x and c.y == ncell.y) return die(w); + // into the space your own tail leaves is legal. + const eating = w.food != null and nc.x == w.food.?.x and nc.y == w.food.?.y; + const self_check_len: u32 = if (eating) p.cells_len else p.cells_len - 1; + for (0..self_check_len) |i| { + const c = p.cells_buf[i]; + if (c.x == nc.x and c.y == nc.y) return .{ .dies = true, .new_head = undefined, .eats = false }; } - // unshift: shift the body up one slot (@memmove handles the overlap), - // then write the new head. When not eating the shift drops the tail - // cell — the same "pop" the oracle does, subsumed by the shift. - const buf = w.cells_buf[0 .. w.cells_len + 1]; - @memmove(buf[1..], buf[0..w.cells_len]); - w.cells_buf[0] = ncell; - if (eating) { - w.cells_len += 1; - w.score += 10; - placeFood(w); - if (w.food == null) return win(w); + for (0..w.player_count) |j| { + if (j == player) continue; + for (cells(w, @intCast(j))) |c| { + if (c.x == nc.x and c.y == nc.y) return .{ .dies = true, .new_head = undefined, .eats = false }; + } } - w.tick += 1; -} -/// The oracle's die(): status dead (its overlay is presentation). -fn die(w: *World) void { - w.status = .dead; + return .{ .dies = false, .new_head = nc, .eats = eating }; } -/// The oracle's win(): the same dead status die() sets (its overlay differs; -/// the status does not — docs/canonical-state.md has no separate win code). -fn win(w: *World) void { - w.status = .dead; +/// sim.mjs's advance(), generalized to `player_count` players (decision-034). +/// Statement order is still load-bearing — see this module's header. Phase 1 +/// (`planMove`, above) is read-only and order-independent; phase 2 below +/// always mutates in ascending player-index order, exactly like +/// decision-020's fuzz primitive, so a permuted visitation order can never +/// change the result. At `player_count == 1` every cross-player/head-to-head +/// step below is a no-op (there is no other player), so this reproduces the +/// original single-player advance() byte-for-byte, including its +/// die()/win()-return-before-the-tick-increment quirk: `w.tick` only +/// advances when at least one player is still alive afterward, which for a +/// single player is exactly "didn't just die". +pub fn advance(w: *World) void { + var dies: [MAX_PLAYERS]bool = .{ false, false }; + var new_head: [MAX_PLAYERS]Cell = undefined; + var eats: [MAX_PLAYERS]bool = .{ false, false }; + + // Phase 1: commit dir and plan each still-alive player's move against + // the tick's starting snapshot. + for (0..w.player_count) |i| { + const p = &w.players[i]; + if (!p.alive) continue; + p.dir = p.next_dir; + const move = planMove(w, @intCast(i)); + dies[i] = move.dies; + new_head[i] = move.new_head; + eats[i] = move.eats; + } + + // Head-to-head: two still-surviving players landing on the same cell + // this tick mutually kill each other (decision-034) — symmetric, so + // visiting order doesn't matter; with MAX_PLAYERS == 2 there is only + // ever one pair to check. + for (0..w.player_count) |i| { + if (!w.players[i].alive or dies[i]) continue; + for (i + 1..w.player_count) |j| { + if (!w.players[j].alive or dies[j]) continue; + if (new_head[i].x == new_head[j].x and new_head[i].y == new_head[j].y) { + dies[i] = true; + dies[j] = true; + } + } + } + + // Phase 2 (fixed ascending index, never a visitation order): mutate + // bodies, resolve the shared food, and — only if something was eaten — + // draw the one shared-RNG replacement food (docs/rng.md "Food placement" + // point 3's ascending-index rule). + var ate_this_tick = false; + for (0..w.player_count) |i| { + var p = &w.players[i]; + if (!p.alive) continue; + if (dies[i]) { + // decision-034: a corpse is left exactly where it fell — never + // cleared — so it keeps blocking movement and food placement, + // the same as the single-player original never clearing a dead + // snake's body either. + p.alive = false; + continue; + } + const buf = p.cells_buf[0 .. p.cells_len + 1]; + @memmove(buf[1..], buf[0..p.cells_len]); + p.cells_buf[0] = new_head[i]; + if (eats[i]) { + p.cells_len += 1; + p.score += 10; + ate_this_tick = true; + } + } + if (ate_this_tick) { + w.food = null; + placeFood(w); + if (w.food == null) { + // Board full: whoever just ate won (decision-034 keeps the + // single-player win()-is-a-flavor-of-die() sentinel — no + // separate "won" state exists in `alive`/`status`). + for (0..w.player_count) |i| { + if (w.players[i].alive and eats[i]) w.players[i].alive = false; + } + } + } + + var any_alive = false; + for (0..w.player_count) |i| { + if (w.players[i].alive) any_alive = true; + } + if (any_alive) { + w.tick += 1; + } else { + w.status = .dead; + } } /// sim.mjs's step()/frame() loop — the ns_pump accumulator — minus the @@ -302,6 +466,11 @@ fn win(w: *World) void { /// freeze #5: the period comes from the committed TICK_PERIOD_US table, and /// the first-frame dt sentinel (sim.mjs's `S.last` bookkeeping) lives in the /// caller's clock, not here — this core never reads a clock of its own. +/// +/// The tick period is read from player 0's score, matching core/abi.zig's +/// ns_pump/ns_step contract (docs/abi-header.md): speed is not yet a +/// per-player notion (decision-034 didn't need one — both a solo game and a +/// two-player match share one clock either way). pub fn pump(w: *World, dt_us: u32) u32 { if (w.status != .playing) return 0; @@ -309,11 +478,11 @@ pub fn pump(w: *World, dt_us: u32) u32 { w.acc_us += dt; var steps: u32 = 0; - var step_us = tickPeriodUs(w.score); + var step_us = tickPeriodUs(w.players[0].score); while (w.acc_us >= step_us and w.status == .playing and steps < MAX_STEPS) { w.acc_us -= step_us; advance(w); - step_us = tickPeriodUs(w.score); + step_us = tickPeriodUs(w.players[0].score); steps += 1; } return steps; @@ -325,7 +494,10 @@ pub fn pump(w: *World, dt_us: u32) u32 { // survive/eat split, the negative-wrap arithmetic, the ns_pump clamp and // accumulator carry (docs/abi-decisions.md freeze #5), and the canonical // serialize∘deserialize identity (docs/canonical-state.md). Each maps to a -// single Acceptance Criterion so a regression names itself. +// single Acceptance Criterion so a regression names itself. Every test here +// drives `initWorld` with `player_count = 1`, proving the single-player path +// this task's generalization must not disturb; decision-034's own new tests +// (below) cover the two-player-only behavior. test "refAllDecls" { std.testing.refAllDecls(@This()); @@ -338,15 +510,15 @@ test "advance commits next_dir into dir before the head moves" { // `dir` and the head position, not one tick later. var buf: [COLS * ROWS]Cell = undefined; var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); + initWorld(&w, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); w.food = null; - queueDir(&w, .up); // legal against dir=right + queueDir(&w, 0, .up); // legal against dir=right advance(&w); - try std.testing.expectEqual(Dir.up, w.dir); // committed this tick - try std.testing.expectEqual(@as(u16, 8), cells(&w)[0].x); // head at (8,11) - try std.testing.expectEqual(@as(u16, 11), cells(&w)[0].y); // ...one up from (8,12) + try std.testing.expectEqual(Dir.up, w.players[0].dir); // committed this tick + try std.testing.expectEqual(@as(u16, 8), cells(&w, 0)[0].x); // head at (8,11) + try std.testing.expectEqual(@as(u16, 11), cells(&w, 0)[0].y); // ...one up from (8,12) try std.testing.expectEqual(@as(u32, 1), w.tick); } @@ -360,29 +532,29 @@ test "wrap uses @mod on the (+size) offset, not raw signed %" { // x = 0 moving left: pre-wrap nx is -1. var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, true, .{ 1, 2, 3, 4 }, .playing); - w.cells_buf[0] = .{ .x = 0, .y = 5 }; - w.cells_buf[1] = .{ .x = 1, .y = 5 }; - w.cells_buf[2] = .{ .x = 2, .y = 5 }; - w.dir = .left; - w.next_dir = .left; + initWorld(&w, &buf, COLS, ROWS, 1, true, .{ 1, 2, 3, 4 }, .playing); + w.players[0].cells_buf[0] = .{ .x = 0, .y = 5 }; + w.players[0].cells_buf[1] = .{ .x = 1, .y = 5 }; + w.players[0].cells_buf[2] = .{ .x = 2, .y = 5 }; + w.players[0].dir = .left; + w.players[0].next_dir = .left; w.food = null; advance(&w); try std.testing.expectEqual(Status.playing, w.status); - try std.testing.expectEqual(@as(u16, COLS - 1), cells(&w)[0].x); + try std.testing.expectEqual(@as(u16, COLS - 1), cells(&w, 0)[0].x); // y = 0 moving up: pre-wrap ny is -1. var v: World = undefined; - initWorld(&v, &buf, COLS, ROWS, true, .{ 1, 2, 3, 4 }, .playing); - v.cells_buf[0] = .{ .x = 5, .y = 0 }; - v.cells_buf[1] = .{ .x = 5, .y = 1 }; - v.cells_buf[2] = .{ .x = 5, .y = 2 }; - v.dir = .up; - v.next_dir = .up; + initWorld(&v, &buf, COLS, ROWS, 1, true, .{ 1, 2, 3, 4 }, .playing); + v.players[0].cells_buf[0] = .{ .x = 5, .y = 0 }; + v.players[0].cells_buf[1] = .{ .x = 5, .y = 1 }; + v.players[0].cells_buf[2] = .{ .x = 5, .y = 2 }; + v.players[0].dir = .up; + v.players[0].next_dir = .up; v.food = null; advance(&v); try std.testing.expectEqual(Status.playing, v.status); - try std.testing.expectEqual(@as(u16, ROWS - 1), cells(&v)[0].y); + try std.testing.expectEqual(@as(u16, ROWS - 1), cells(&v, 0)[0].y); } test "tail-chase survives entering the vacating tail cell when not eating" { @@ -391,20 +563,20 @@ test "tail-chase survives entering the vacating tail cell when not eating" { // but only because not eating means the tail actually moves this tick. var buf: [COLS * ROWS]Cell = undefined; var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); - w.cells_buf[0] = .{ .x = 5, .y = 5 }; // head - w.cells_buf[1] = .{ .x = 10, .y = 5 }; // mid (never adjacent to the move) - w.cells_buf[2] = .{ .x = 4, .y = 5 }; // tail, the cell the head enters - w.cells_len = 3; - w.dir = .left; - w.next_dir = .left; + initWorld(&w, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); + w.players[0].cells_buf[0] = .{ .x = 5, .y = 5 }; // head + w.players[0].cells_buf[1] = .{ .x = 10, .y = 5 }; // mid (never adjacent to the move) + w.players[0].cells_buf[2] = .{ .x = 4, .y = 5 }; // tail, the cell the head enters + w.players[0].cells_len = 3; + w.players[0].dir = .left; + w.players[0].next_dir = .left; w.food = null; // not eating -> tail vacates advance(&w); try std.testing.expectEqual(Status.playing, w.status); try std.testing.expectEqualSlices(Cell, &[_]Cell{ .{ .x = 4, .y = 5 }, .{ .x = 5, .y = 5 }, .{ .x = 10, .y = 5 }, - }, cells(&w)); + }, cells(&w, 0)); } test "tail-chase dies entering the tail cell when eating" { @@ -414,17 +586,17 @@ test "tail-chase dies entering the tail cell when eating" { // move that was legal a moment ago is now a fatal self-collision. var buf: [COLS * ROWS]Cell = undefined; var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); - w.cells_buf[0] = .{ .x = 5, .y = 5 }; // head - w.cells_buf[1] = .{ .x = 10, .y = 5 }; // mid - w.cells_buf[2] = .{ .x = 4, .y = 5 }; // tail == next cell == food - w.cells_len = 3; - w.dir = .left; - w.next_dir = .left; + initWorld(&w, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); + w.players[0].cells_buf[0] = .{ .x = 5, .y = 5 }; // head + w.players[0].cells_buf[1] = .{ .x = 10, .y = 5 }; // mid + w.players[0].cells_buf[2] = .{ .x = 4, .y = 5 }; // tail == next cell == food + w.players[0].cells_len = 3; + w.players[0].dir = .left; + w.players[0].next_dir = .left; w.food = .{ .x = 4, .y = 5 }; // the move is a scoring move advance(&w); - // eating -> body_len_check == cells_len (tail included) -> self-hit -> die + // eating -> self_check_len == cells_len (tail included) -> self-hit -> die try std.testing.expectEqual(Status.dead, w.status); } @@ -435,19 +607,19 @@ test "out-of-bounds death happens after the direction commit" { // not skipped when the move turns out to be fatal. var buf: [COLS * ROWS]Cell = undefined; var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); - w.cells_buf[0] = .{ .x = 12, .y = 0 }; // head against the top edge - w.cells_buf[1] = .{ .x = 11, .y = 0 }; - w.cells_buf[2] = .{ .x = 10, .y = 0 }; - w.cells_len = 3; - w.dir = .right; // moving right along the top row - w.next_dir = .right; + initWorld(&w, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); + w.players[0].cells_buf[0] = .{ .x = 12, .y = 0 }; // head against the top edge + w.players[0].cells_buf[1] = .{ .x = 11, .y = 0 }; + w.players[0].cells_buf[2] = .{ .x = 10, .y = 0 }; + w.players[0].cells_len = 3; + w.players[0].dir = .right; // moving right along the top row + w.players[0].next_dir = .right; w.food = null; - queueDir(&w, .up); // legal turn (not a 180 of right) that walks off y=0 + queueDir(&w, 0, .up); // legal turn (not a 180 of right) that walks off y=0 advance(&w); - try std.testing.expectEqual(Dir.up, w.dir); // committed even though fatal + try std.testing.expectEqual(Dir.up, w.players[0].dir); // committed even though fatal try std.testing.expectEqual(Status.dead, w.status); // died moving up, off the top } @@ -458,18 +630,18 @@ test "advance scores 10 before placeFood and wins only after the increment" { // moment the win fires, not skipped or ordered after the win. var buf: [4]Cell = undefined; var w: World = undefined; - initWorld(&w, &buf, 2, 2, false, .{ 1, 2, 3, 4 }, .playing); - w.cells_buf[0] = .{ .x = 1, .y = 0 }; - w.cells_buf[1] = .{ .x = 1, .y = 1 }; - w.cells_buf[2] = .{ .x = 0, .y = 1 }; - w.cells_len = 3; - w.dir = .left; - w.next_dir = .left; + initWorld(&w, &buf, 2, 2, 1, false, .{ 1, 2, 3, 4 }, .playing); + w.players[0].cells_buf[0] = .{ .x = 1, .y = 0 }; + w.players[0].cells_buf[1] = .{ .x = 1, .y = 1 }; + w.players[0].cells_buf[2] = .{ .x = 0, .y = 1 }; + w.players[0].cells_len = 3; + w.players[0].dir = .left; + w.players[0].next_dir = .left; w.food = .{ .x = 0, .y = 0 }; // the only free cell on a 2x2 board advance(&w); - try std.testing.expectEqual(@as(u32, 10), w.score); // incremented first - try std.testing.expectEqual(@as(u32, 4), w.cells_len); // grew to fill the board + try std.testing.expectEqual(@as(u32, 10), w.players[0].score); // incremented first + try std.testing.expectEqual(@as(u32, 4), w.players[0].cells_len); // grew to fill the board try std.testing.expect(w.food == null); // placeFood found no free cell try std.testing.expectEqual(Status.dead, w.status); // win == dead (oracle parity) } @@ -484,21 +656,21 @@ test "queueDir reads dir while playing and next_dir in every other status" { // of dir (reject), but NOT the reverse of next_dir (would wrongly accept // if the guard read next_dir). var p: World = undefined; - initWorld(&p, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); - p.dir = .up; - p.next_dir = .right; - queueDir(&p, .down); - try std.testing.expectEqual(Dir.right, p.next_dir); // rejected: guard read dir=up + initWorld(&p, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); + p.players[0].dir = .up; + p.players[0].next_dir = .right; + queueDir(&p, 0, .down); + try std.testing.expectEqual(Dir.right, p.players[0].next_dir); // rejected: guard read dir=up // Paused (non-playing): guard reads next_dir. dir=up, next_dir=right; // .left is the reverse of next_dir (reject), but NOT of dir (would wrongly // accept if the guard read dir). var q: World = undefined; - initWorld(&q, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .paused); - q.dir = .up; - q.next_dir = .right; - queueDir(&q, .left); - try std.testing.expectEqual(Dir.right, q.next_dir); // rejected: guard read next_dir=right + initWorld(&q, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .paused); + q.players[0].dir = .up; + q.players[0].next_dir = .right; + queueDir(&q, 0, .left); + try std.testing.expectEqual(Dir.right, q.players[0].next_dir); // rejected: guard read next_dir=right try std.testing.expectEqual(Status.paused, q.status); // a rejected input never resumes } @@ -518,9 +690,9 @@ test "pump clamps to MAX_STEPS per call and carries the remainder over" { // acc is preloaded with 10 ticks of time. Only MAX_STEPS fire; the other // four ticks' worth stays in acc_us. var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); + initWorld(&w, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); w.food = null; - w.score = 40; // fastest period, 55000 us + w.players[0].score = 40; // fastest period, 55000 us w.acc_us = 550_000; // 10 ticks of prebuilt accumulated time const stepped = pump(&w, 0); try std.testing.expectEqual(@as(u32, MAX_STEPS), stepped); @@ -532,7 +704,7 @@ test "pump clamps to MAX_STEPS per call and carries the remainder over" { // first two pumps fall short; the third crosses the tick, and the 62000 us // that's left is exactly the unconsumed remainder, carried not discarded. var acc: World = undefined; - initWorld(&acc, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); + initWorld(&acc, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); acc.food = null; try std.testing.expectEqual(@as(u32, 0), pump(&acc, 64_000)); // 64000 < 130000 try std.testing.expectEqual(@as(u32, 0), pump(&acc, 64_000)); // 128000 < 130000 @@ -553,7 +725,7 @@ test "serialize then deserialize round-trips a driven World" { // body all diverge from their initial values before the round-trip. var buf: [COLS * ROWS]Cell = undefined; var w: World = undefined; - initWorld(&w, &buf, COLS, ROWS, false, .{ 1, 2, 3, 4 }, .playing); + initWorld(&w, &buf, COLS, ROWS, 1, false, .{ 1, 2, 3, 4 }, .playing); w.food = .{ .x = 9, .y = 12 }; // directly ahead -> eat on the first advance advance(&w); // score 10, body grows, placeFood advances the rng stream advance(&w); @@ -562,16 +734,16 @@ test "serialize then deserialize round-trips a driven World" { try std.testing.expect(w.food != null); // a real, non-null food to round-trip try std.testing.expect(w.tick > 0); - try std.testing.expect(w.score > 0); + try std.testing.expect(w.players[0].score > 0); // Build canon.State from the world's live fields (head-first cells view). const snapshot = w.rng.state(); const player = canon.Player{ .status = w.status, - .dir = w.dir, - .next_dir = w.next_dir, - .score = w.score, - .cells = cells(&w), + .dir = w.players[0].dir, + .next_dir = w.players[0].next_dir, + .score = w.players[0].score, + .cells = cells(&w, 0), }; const players_arr = [_]canon.Player{player}; const state = canon.State{ @@ -605,8 +777,147 @@ test "serialize then deserialize round-trips a driven World" { try std.testing.expectEqual(@as(usize, 1), got.players.len); const gp = got.players[0]; try std.testing.expectEqual(w.status, gp.status); - try std.testing.expectEqual(w.dir, gp.dir); - try std.testing.expectEqual(w.next_dir, gp.next_dir); - try std.testing.expectEqual(w.score, gp.score); - try std.testing.expectEqualSlices(canon.Cell, cells(&w), gp.cells); + try std.testing.expectEqual(w.players[0].dir, gp.dir); + try std.testing.expectEqual(w.players[0].next_dir, gp.next_dir); + try std.testing.expectEqual(w.players[0].score, gp.score); + try std.testing.expectEqualSlices(canon.Cell, cells(&w, 0), gp.cells); +} + +// --- Two-player suite (TASK-051, decision-034) ---------------------------- +// Covers exactly the behavior single-player play never exercises: the +// starting-row spacing formula, independent per-player elimination while the +// board keeps running for the survivor, cross-player body collision, and the +// symmetric head-to-head mutual kill. + +test "two-player reset spaces starting rows using rows*(i+1)/(player_count+1)" { + var buf: [2 * COLS * ROWS]Cell = undefined; + var w: World = undefined; + initWorld(&w, &buf, COLS, ROWS, 2, false, .{ 1, 2, 3, 4 }, .playing); + + // ROWS = 24: player 0 at 24*1/3 = 8, player 1 at 24*2/3 = 16. + try std.testing.expectEqual(@as(u16, 8), cells(&w, 0)[0].y); + try std.testing.expectEqual(@as(u16, 16), cells(&w, 1)[0].y); + try std.testing.expectEqual(@as(u16, 8), cells(&w, 0)[0].x); // same starting column shape as solo play + try std.testing.expectEqual(@as(u16, 8), cells(&w, 1)[0].x); + try std.testing.expect(w.players[0].alive); + try std.testing.expect(w.players[1].alive); +} + +test "a player dying against a wall does not stop the surviving player" { + var buf: [2 * COLS * ROWS]Cell = undefined; + var w: World = undefined; + initWorld(&w, &buf, COLS, ROWS, 2, false, .{ 1, 2, 3, 4 }, .playing); + w.food = null; + + // Player 0 at the right edge, about to walk off; player 1 far away and + // safe. + w.players[0].cells_buf[0] = .{ .x = COLS - 1, .y = 0 }; + w.players[0].cells_buf[1] = .{ .x = COLS - 2, .y = 0 }; + w.players[0].cells_buf[2] = .{ .x = COLS - 3, .y = 0 }; + w.players[0].cells_len = 3; + w.players[0].dir = .right; + w.players[0].next_dir = .right; + + const pre_tick = w.tick; + advance(&w); + + try std.testing.expect(!w.players[0].alive); // eliminated + try std.testing.expect(w.players[1].alive); // untouched + try std.testing.expectEqual(Status.playing, w.status); // match continues + try std.testing.expectEqual(pre_tick + 1, w.tick); // the shared clock still ticked + + // The corpse stays exactly where it died (never cleared) and the + // survivor keeps moving normally on a later tick. + const corpse_head = cells(&w, 0)[0]; + try std.testing.expectEqual(@as(u16, COLS - 1), corpse_head.x); + advance(&w); + try std.testing.expectEqual(corpse_head, cells(&w, 0)[0]); // untouched by further advances +} + +test "when every player is eliminated the world dies and the tick does not advance" { + var buf: [2 * COLS * ROWS]Cell = undefined; + var w: World = undefined; + initWorld(&w, &buf, COLS, ROWS, 2, false, .{ 1, 2, 3, 4 }, .playing); + w.food = null; + + // Both players walk off opposite edges on the same tick. + w.players[0].cells_buf[0] = .{ .x = COLS - 1, .y = 0 }; + w.players[0].cells_buf[1] = .{ .x = COLS - 2, .y = 0 }; + w.players[0].cells_buf[2] = .{ .x = COLS - 3, .y = 0 }; + w.players[0].cells_len = 3; + w.players[0].dir = .right; + w.players[0].next_dir = .right; + + w.players[1].cells_buf[0] = .{ .x = 0, .y = 5 }; + w.players[1].cells_buf[1] = .{ .x = 1, .y = 5 }; + w.players[1].cells_buf[2] = .{ .x = 2, .y = 5 }; + w.players[1].cells_len = 3; + w.players[1].dir = .left; + w.players[1].next_dir = .left; + + const pre_tick = w.tick; + advance(&w); + + try std.testing.expect(!w.players[0].alive); + try std.testing.expect(!w.players[1].alive); + try std.testing.expectEqual(Status.dead, w.status); // the match is over + try std.testing.expectEqual(pre_tick, w.tick); // no player survived -> tick not incremented +} + +test "entering an opponent's body cell is fatal even though it isn't self-collision" { + var buf: [2 * COLS * ROWS]Cell = undefined; + var w: World = undefined; + initWorld(&w, &buf, COLS, ROWS, 2, false, .{ 1, 2, 3, 4 }, .playing); + w.food = null; + + // Player 0 heads straight into the middle of player 1's stationary body. + w.players[0].cells_buf[0] = .{ .x = 3, .y = 5 }; + w.players[0].cells_buf[1] = .{ .x = 2, .y = 5 }; + w.players[0].cells_buf[2] = .{ .x = 1, .y = 5 }; + w.players[0].cells_len = 3; + w.players[0].dir = .right; + w.players[0].next_dir = .right; + + // Player 1's body occupies (4,5) — directly ahead of player 0 — but is + // parked far from its own head so it can't also collide with itself. + w.players[1].cells_buf[0] = .{ .x = 10, .y = 10 }; + w.players[1].cells_buf[1] = .{ .x = 4, .y = 5 }; + w.players[1].cells_buf[2] = .{ .x = 10, .y = 12 }; + w.players[1].cells_len = 3; + w.players[1].dir = .up; + w.players[1].next_dir = .up; + + advance(&w); + + try std.testing.expect(!w.players[0].alive); // ran into player 1's body + try std.testing.expect(w.players[1].alive); // player 1's own move was unobstructed + try std.testing.expectEqual(Status.playing, w.status); +} + +test "two players moving onto the same cell in the same tick mutually kill each other" { + var buf: [2 * COLS * ROWS]Cell = undefined; + var w: World = undefined; + initWorld(&w, &buf, COLS, ROWS, 2, false, .{ 1, 2, 3, 4 }, .playing); + w.food = null; + + // Player 0 moving right, player 1 moving left, both landing on (6, 5). + w.players[0].cells_buf[0] = .{ .x = 5, .y = 5 }; + w.players[0].cells_buf[1] = .{ .x = 4, .y = 5 }; + w.players[0].cells_buf[2] = .{ .x = 3, .y = 5 }; + w.players[0].cells_len = 3; + w.players[0].dir = .right; + w.players[0].next_dir = .right; + + w.players[1].cells_buf[0] = .{ .x = 7, .y = 5 }; + w.players[1].cells_buf[1] = .{ .x = 8, .y = 5 }; + w.players[1].cells_buf[2] = .{ .x = 9, .y = 5 }; + w.players[1].cells_len = 3; + w.players[1].dir = .left; + w.players[1].next_dir = .left; + + advance(&w); + + try std.testing.expect(!w.players[0].alive); + try std.testing.expect(!w.players[1].alive); + try std.testing.expectEqual(Status.dead, w.status); } diff --git a/docs/abi-impl.md b/docs/abi-impl.md index fdcfd44..b328bed 100644 --- a/docs/abi-impl.md +++ b/docs/abi-impl.md @@ -5,23 +5,31 @@ covers the judgment calls `core/abi.zig` (TASK-024) makes filling that contract header deliberately left open, or that only became concrete once real `core/world.zig`, `core/rng.zig`, and `core/canon.zig` code had to be wired behind it. -## `player_count` is restricted to 1, for now - -`core/world.zig`'s `World` has no per-player dimension — `dir`, `next_dir`, `score`, the cell -buffer, are all scalar, genuinely single-player. `include/neo_snake.h` anticipates multiplayer -(`ns_config.player_count`, every per-player-indexed function), but freeze #1 -(`docs/abi-decisions.md`) only fixes what a *future* multiplayer ABI must look like when it -arrives — it does not require building it now. `backlog/decisions/decision-020` is the direct -precedent: a minimal two-player primitive already exists, but scoped narrowly to one Tier-B fuzz +## `player_count` supports up to 2 (TASK-051) + +`core/world.zig`'s `World` originally had no per-player dimension — `dir`, `next_dir`, `score`, the +cell buffer, were all scalar, genuinely single-player. `include/neo_snake.h` had always anticipated +multiplayer (`ns_config.player_count`, every per-player-indexed function), and freeze #1 +(`docs/abi-decisions.md`) fixed what a multiplayer ABI must look like once real per-player state +existed, but didn't require building it until something needed it. `backlog/decisions/decision-020` +was the interim precedent: a minimal two-player primitive scoped narrowly to one Tier-B fuzz invariant, explicitly not touching `core/world.zig` and explicitly not a preview of the real -multiplayer design. TASK-024 is milestone m-4; real multiplayer is m-8. - -So `core/abi.zig` defines `MAX_SUPPORTED_PLAYERS: u8 = 1` and rejects any other `player_count` in -`ns_world_init` with `NS_ERR_INVALID_ARGUMENT` — the same "additive later, not a rewrite" shape as -the header's own `speed_source` field. Every player-index bounds check (`ns_queue_dir`, `ns_step`, +multiplayer design. + +TASK-051 (m-8, local 2-player) is that "something" — `core/world.zig` now carries a real +per-player dimension (`PlayerState`, `MAX_PLAYERS = 2`), and `core/abi.zig` defines +`MAX_SUPPORTED_PLAYERS: u8 = world.MAX_PLAYERS` (currently 2, not N — YAGNI, matching the only AC +that has ever needed a number). `ns_world_init` rejects any `player_count` outside `1..= +MAX_SUPPORTED_PLAYERS` with `NS_ERR_INVALID_ARGUMENT`, the same "additive later, not a rewrite" +shape as the header's own `speed_source` field, and additionally rejects `player_count > 1` on a +board with `rows <= 8` (too short for two players' starting snakes to avoid overlapping under the +starting-row spacing formula). Every player-index bounds check (`ns_queue_dir`, `ns_step`, `ns_player_view_get`, `ns_body_copy`) compares against `WorldStorage.player_count`, a value stored -at init time, not a literal `0` — loosening this later only changes what `ns_world_init` accepts, -not any call site. +at init time, not a literal `1` — loosening the cap further only changes what `ns_world_init` +accepts, not any call site. The actual collision/status/win semantics for `player_count == 2` are +`backlog/decisions/decision-034`, not this doc — this section only covers the ABI-boundary +plumbing. `fuzz_seeds.zig`'s `decision-020` primitive is untouched and remains a separate, narrower +fixture. ## Telling win from die apart diff --git a/game/platform/input_defaults.gd b/game/platform/input_defaults.gd index 6acfcbd..d518920 100644 --- a/game/platform/input_defaults.gd +++ b/game/platform/input_defaults.gd @@ -18,14 +18,28 @@ const ACTION_MOVE_RIGHT := "move_right" const ACTION_PAUSE := "pause" const ACTION_RESTART := "restart" +## TASK-051: player 1's own action set. Bound to WASD only -- player 0 +## keeps the arrow keys exclusively (see ACTION_PHYSICAL_KEYCODES below). +## Splitting the keys is not stylistic: if both players' actions stayed +## bound to the same physical WASD keys, one keypress would move both +## snakes at once. +const ACTION_P2_MOVE_UP := "p2_move_up" +const ACTION_P2_MOVE_DOWN := "p2_move_down" +const ACTION_P2_MOVE_LEFT := "p2_move_left" +const ACTION_P2_MOVE_RIGHT := "p2_move_right" + ## action name -> the physical keycodes project.godot must bind it to. const ACTION_PHYSICAL_KEYCODES := { - ACTION_MOVE_UP: [KEY_UP, KEY_W], - ACTION_MOVE_DOWN: [KEY_DOWN, KEY_S], - ACTION_MOVE_LEFT: [KEY_LEFT, KEY_A], - ACTION_MOVE_RIGHT: [KEY_RIGHT, KEY_D], + ACTION_MOVE_UP: [KEY_UP], + ACTION_MOVE_DOWN: [KEY_DOWN], + ACTION_MOVE_LEFT: [KEY_LEFT], + ACTION_MOVE_RIGHT: [KEY_RIGHT], ACTION_PAUSE: [KEY_SPACE], ACTION_RESTART: [KEY_R], + ACTION_P2_MOVE_UP: [KEY_W], + ACTION_P2_MOVE_DOWN: [KEY_S], + ACTION_P2_MOVE_LEFT: [KEY_A], + ACTION_P2_MOVE_RIGHT: [KEY_D], } ## action name -> SimulationWorld.DIR_* -- the four movement actions only, @@ -37,3 +51,17 @@ const ACTION_TO_DIR := { ACTION_MOVE_LEFT: SimulationWorld.DIR_LEFT, ACTION_MOVE_RIGHT: SimulationWorld.DIR_RIGHT, } + +## per-player action-to-dir table, indexed by player number (TASK-051). +## input_router.gd iterates this to dispatch keyboard input to the right +## player; touch/joypad stay routed to player 0 only (no natural per-player +## mapping exists for those input kinds on one shared keyboard/no second +## controller). +const ACTION_P2_TO_DIR := { + ACTION_P2_MOVE_UP: SimulationWorld.DIR_UP, + ACTION_P2_MOVE_DOWN: SimulationWorld.DIR_DOWN, + ACTION_P2_MOVE_LEFT: SimulationWorld.DIR_LEFT, + ACTION_P2_MOVE_RIGHT: SimulationWorld.DIR_RIGHT, +} + +const PLAYER_ACTIONS := [ACTION_TO_DIR, ACTION_P2_TO_DIR] diff --git a/game/platform/input_router.gd b/game/platform/input_router.gd index b4383db..4af28da 100644 --- a/game/platform/input_router.gd +++ b/game/platform/input_router.gd @@ -18,7 +18,7 @@ extends Node ## other platform/presentation script landed so far ## (game/simulation/tick_driver.gd, game/presentation/board/board_view.gd). -signal direction_queued(dir: int) +signal direction_queued(player: int, dir: int) signal pause_requested signal restart_requested @@ -41,11 +41,13 @@ func _unhandled_input(event: InputEvent) -> void: restart_requested.emit() get_viewport().set_input_as_handled() return - for action: String in InputDefaults.ACTION_TO_DIR: - if event.is_action_pressed(action): - direction_queued.emit(InputDefaults.ACTION_TO_DIR[action]) - get_viewport().set_input_as_handled() - return + for player: int in InputDefaults.PLAYER_ACTIONS.size(): + var action_to_dir: Dictionary = InputDefaults.PLAYER_ACTIONS[player] + for action: String in action_to_dir: + if event.is_action_pressed(action): + direction_queued.emit(player, action_to_dir[action]) + get_viewport().set_input_as_handled() + return if event is InputEventScreenTouch: _handle_touch(event) elif event is InputEventScreenDrag: @@ -62,7 +64,7 @@ func _handle_touch(event: InputEventScreenTouch) -> void: func _handle_drag(event: InputEventScreenDrag) -> void: var dir := _swipe.update(event.position, cell_px, swipe_threshold_cell_fraction) if dir != -1: - direction_queued.emit(dir) + direction_queued.emit(0, dir) get_viewport().set_input_as_handled() ## Left stick only (JOY_AXIS_LEFT_X/_Y) -- Y+ is down, matching the @@ -75,5 +77,5 @@ func _handle_joypad_motion(event: InputEventJoypadMotion) -> void: elif event.axis == JOY_AXIS_LEFT_Y: dir = _stick_y.feed(event.axis_value, SimulationWorld.DIR_DOWN, SimulationWorld.DIR_UP) if dir != -1: - direction_queued.emit(dir) + direction_queued.emit(0, dir) get_viewport().set_input_as_handled() diff --git a/game/presentation/screens/game_screen.gd b/game/presentation/screens/game_screen.gd index 0642726..bae65ec 100644 --- a/game/presentation/screens/game_screen.gd +++ b/game/presentation/screens/game_screen.gd @@ -48,6 +48,7 @@ var save_dir_override := "" var world: SimulationWorld var board_view: BoardView +var board_view_p2: BoardView var hud: Hud var overlay: OverlayPanel var settings_panel: SettingsPanel @@ -85,14 +86,24 @@ func _ready() -> void: _current_mode_id = _save_data.last_mode if _has_mode(_save_data.last_mode) else content.modes.default_mode world = SimulationWorld.new() - world.init(COLS, ROWS, 1, _wrap_for(_current_mode_id), SeedSource.fresh(), SimulationWorld.SPEED_SOURCE_SCORE_TABLE) + world.init(COLS, ROWS, 2, _wrap_for(_current_mode_id), SeedSource.fresh(), SimulationWorld.SPEED_SOURCE_SCORE_TABLE) board_view = BoardView.new() board_view.custom_minimum_size = Vector2(520, 520) board_view.size = Vector2(520, 520) board_view.position = Vector2(0, 0) - board_view.setup(world, _tuning, _palette) + board_view.setup(world, _tuning, _palette, 0) add_child(board_view) + + ## TASK-051: board_view.gd is already generic over `player` -- a second + ## instance, sharing the same world/board geometry, renders player 1's + ## snake on top of the same shared board/food/grid. + board_view_p2 = BoardView.new() + board_view_p2.custom_minimum_size = Vector2(520, 520) + board_view_p2.size = Vector2(520, 520) + board_view_p2.position = Vector2(0, 0) + board_view_p2.setup(world, _tuning, _palette, 1) + add_child(board_view_p2) _apply_settings(_save_data.settings) hud = Hud.new() @@ -120,7 +131,7 @@ func _ready() -> void: settings_panel.closed.connect(_on_settings_closed) input_router = InputRouter.new() - input_router.direction_queued.connect(_on_direction_queued) + input_router.direction_queued.connect(func(player: int, dir: int) -> void: _on_direction_queued(dir, player)) input_router.pause_requested.connect(_on_pause_requested) input_router.restart_requested.connect(_on_restart_requested) add_child(input_router) @@ -161,7 +172,7 @@ func _maybe_drive_capture_state() -> void: return if state == "dead": var guard := 0 - while world.player_view_get(0).status != BoardGeometry.STATUS_DEAD and guard < 100000: + while _shared_status() != BoardGeometry.STATUS_DEAD and guard < 100000: _process(0.05) guard += 1 @@ -185,13 +196,14 @@ func _process(delta: float) -> void: var drain := world.event_drain(16) if drain.result == SimulationWorld.OK: for event in drain.events: + var event_board_view: BoardView = board_view if event.player == 0 else board_view_p2 match event.kind: SimulationWorld.EVENT_EAT: if pre_food_x != BoardGeometry.NO_CELL_COORD: - board_view.notify_eat(pre_food_x, pre_food_y) + event_board_view.notify_eat(pre_food_x, pre_food_y) SimulationWorld.EVENT_DIE: _is_win = false - board_view.fx.trigger_flash() + event_board_view.fx.trigger_flash() SimulationWorld.EVENT_WIN: _is_win = true # Catch-up coalescing is presentation policy, not simulation policy @@ -208,19 +220,35 @@ func _process(delta: float) -> void: _refresh_screen() +## Recovers the true shared world status from only per-player ABI calls (no +## new header/ABI function is permitted -- TASK-051 AC#1). A player's own +## projected status is DEAD whenever it is not alive (core/abi.zig's +## playerStatus); the shared world only ever becomes DEAD once every player +## is simultaneously eliminated (backlog/decisions/decision-034). So player +## 0's own status already IS the shared status unless player 0 individually +## died first, in which case player 1's status settles it. +func _shared_status() -> int: + var v0: int = world.player_view_get(0).status + if v0 != BoardGeometry.STATUS_DEAD: + return v0 + return world.player_view_get(1).status + func _refresh_screen() -> void: - var view := world.player_view_get(0) - if view.result != SimulationWorld.OK: + var view0 := world.player_view_get(0) + if view0.result != SimulationWorld.OK: return - var screen := GameScreenState.screen_for(view.status, _paused) + var view1 := world.player_view_get(1) + var screen := GameScreenState.screen_for(_shared_status(), _paused) var best: int = _save_data.best_scores.get(_current_mode_id, 0) - if view.score > best: - best = view.score + if view0.score > best: + best = view0.score _save_data.best_scores[_current_mode_id] = best _save_data.last_mode = _current_mode_id save_store.save(_save_data) - hud.update(view.score, best, screen.capitalize()) + hud.update(view0.score, best, GameScreenState.screen_for(view0.status, _paused).capitalize()) + if view1.result == SimulationWorld.OK: + hud.update_p2(view1.score, GameScreenState.screen_for(view1.status, _paused).capitalize()) if _settings_open: return @@ -229,7 +257,7 @@ func _refresh_screen() -> void: return var body := world.body_copy(0) var snake_len: int = body.cells.size() if body.result == SimulationWorld.OK else 0 - overlay.configure(GameScreenState.overlay_content(screen, view.score, best, snake_len, _is_win)) + overlay.configure(GameScreenState.overlay_content(screen, view0.score, best, snake_len, _is_win)) func _has_mode(mode_id: String) -> bool: for mode in _modes: @@ -251,7 +279,8 @@ func _on_pause_requested() -> void: var view := world.player_view_get(0) if view.result != SimulationWorld.OK: return - if view.status == BoardGeometry.STATUS_MENU or view.status == BoardGeometry.STATUS_DEAD: + var status := _shared_status() + if status == BoardGeometry.STATUS_MENU or status == BoardGeometry.STATUS_DEAD: _start_or_restart() else: _paused = not _paused @@ -268,7 +297,8 @@ func _on_restart_requested() -> void: var view := world.player_view_get(0) if view.result != SimulationWorld.OK: return - if view.status == BoardGeometry.STATUS_MENU or view.status == BoardGeometry.STATUS_DEAD: + var status := _shared_status() + if status == BoardGeometry.STATUS_MENU or status == BoardGeometry.STATUS_DEAD: _start_or_restart() else: world.reset() @@ -283,7 +313,8 @@ func _on_overlay_action_pressed() -> void: if view.result != SimulationWorld.OK: return sfx.play("ui_confirm") - if view.status == BoardGeometry.STATUS_PLAYING and _paused: + var status := _shared_status() + if status == BoardGeometry.STATUS_PLAYING and _paused: _paused = false _refresh_screen() else: @@ -291,8 +322,8 @@ func _on_overlay_action_pressed() -> void: func _start_or_restart() -> void: var view := world.player_view_get(0) - if view.result == SimulationWorld.OK and view.status == BoardGeometry.STATUS_MENU: - world.init(COLS, ROWS, 1, _wrap_for(_current_mode_id), SeedSource.fresh(), SimulationWorld.SPEED_SOURCE_SCORE_TABLE) + if view.result == SimulationWorld.OK and _shared_status() == BoardGeometry.STATUS_MENU: + world.init(COLS, ROWS, 2, _wrap_for(_current_mode_id), SeedSource.fresh(), SimulationWorld.SPEED_SOURCE_SCORE_TABLE) world.queue_dir(0, SimulationWorld.DIR_RIGHT) _paused = false _is_win = false @@ -306,13 +337,13 @@ func _start_or_restart() -> void: ## unconditionally sets S.dir = S.nextDir = right (snake.html:312) AFTER ## queueDir() already set nextDir to the pressed key, clobbering it. So the ## keypress that starts a run never steers it; only the run itself. -func _on_direction_queued(dir: int) -> void: - var view := world.player_view_get(0) - if view.result == SimulationWorld.OK and (view.status == BoardGeometry.STATUS_MENU or view.status == BoardGeometry.STATUS_DEAD): +func _on_direction_queued(dir: int, player: int = 0) -> void: + var status := _shared_status() + if status == BoardGeometry.STATUS_MENU or status == BoardGeometry.STATUS_DEAD: _start_or_restart() return sfx.play("turn") - world.queue_dir(0, dir) + world.queue_dir(player, dir) func _on_mode_selected(mode_id: String) -> void: sfx.play("ui_move") @@ -322,7 +353,7 @@ func _on_mode_selected(mode_id: String) -> void: ## only fires while actually playing. func _on_focus_lost() -> void: var view := world.player_view_get(0) - if view.result == SimulationWorld.OK and view.status == BoardGeometry.STATUS_PLAYING and not _paused: + if view.result == SimulationWorld.OK and _shared_status() == BoardGeometry.STATUS_PLAYING and not _paused: _paused = true _refresh_screen() @@ -336,6 +367,7 @@ func _apply_settings(settings: Dictionary) -> void: if bus_idx >= 0: AudioServer.set_bus_volume_db(bus_idx, settings.volume_db[bus]) board_view.fx.reduce_flash = settings.reduce_flash + board_view_p2.fx.reduce_flash = settings.reduce_flash KeybindCodec.apply_to_input_map(settings.keybinds) func _on_settings_requested() -> void: diff --git a/game/presentation/screens/hud.gd b/game/presentation/screens/hud.gd index 3ad91e9..823f9a0 100644 --- a/game/presentation/screens/hud.gd +++ b/game/presentation/screens/hud.gd @@ -12,6 +12,11 @@ var _score_label := Label.new() var _best_label := Label.new() var _status_label := Label.new() +## TASK-051: player 1's own row. No best-score label -- best-score +## persistence stays player-0/mode-scoped only (see backlog/decisions). +var _p2_score_label := Label.new() +var _p2_status_label := Label.new() + func _ready() -> void: var box := HBoxContainer.new() box.add_theme_constant_override("separation", 16) @@ -21,6 +26,13 @@ func _ready() -> void: box.add_child(_status_label) update(0, 0, "") + var p2_box := HBoxContainer.new() + p2_box.add_theme_constant_override("separation", 16) + add_child(p2_box) + p2_box.add_child(_p2_score_label) + p2_box.add_child(_p2_status_label) + update_p2(0, "") + ## status_text mirrors the S.status equivalent transition currently in ## effect (menu/playing/paused/dead) -- not part of the oracle's own HUD, ## but explicit in this task's Description ("status text matching S.status @@ -29,3 +41,7 @@ func update(score: int, best: int, status_text: String) -> void: _score_label.text = "Score %d" % score _best_label.text = "Best %d" % best _status_label.text = status_text + +func update_p2(score: int, status_text: String) -> void: + _p2_score_label.text = "P2 Score %d" % score + _p2_status_label.text = status_text diff --git a/game/project.godot b/game/project.godot index ed3dd19..a1ac76e 100644 --- a/game/project.godot +++ b/game/project.godot @@ -25,25 +25,41 @@ window/size/viewport_height=600 move_up={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } move_down={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } move_left={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } move_right={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) -, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +p2_move_up={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +p2_move_down={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +p2_move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +p2_move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } pause={ diff --git a/game/tests/test_game_screen.gd b/game/tests/test_game_screen.gd index efc8796..eb1d51b 100644 --- a/game/tests/test_game_screen.gd +++ b/game/tests/test_game_screen.gd @@ -125,3 +125,38 @@ func test_overlay_action_pressed_plays_the_ui_confirm_cue() -> void: func test_mode_selected_plays_the_ui_move_cue() -> void: _screen._on_mode_selected("classic") assert_bool(_screen.sfx._players["ui_move"].playing).is_true() + + +## TASK-051 AC#2: two locally-controlled players, independent input routing. + +func test_game_screen_wires_a_second_board_view_for_player_1() -> void: + assert_object(_screen.board_view_p2).is_not_null() + assert_int(_screen.board_view_p2.player).is_equal(1) + + +func test_player_1_direction_input_steers_player_1_without_touching_player_0() -> void: + _screen._start_or_restart() + _screen._on_direction_queued(SimulationWorld.DIR_DOWN, 1) + var p0 := _screen.world.player_view_get(0) + var p1 := _screen.world.player_view_get(1) + assert_int(p0.next_dir).is_equal(SimulationWorld.DIR_RIGHT) + assert_int(p1.next_dir).is_equal(SimulationWorld.DIR_DOWN) + + +func test_player_0_direction_input_still_defaults_to_player_0() -> void: + _screen._start_or_restart() + _screen._on_direction_queued(SimulationWorld.DIR_UP) + var p0 := _screen.world.player_view_get(0) + var p1 := _screen.world.player_view_get(1) + assert_int(p0.next_dir).is_equal(SimulationWorld.DIR_UP) + assert_int(p1.next_dir).is_equal(SimulationWorld.DIR_RIGHT) + + +## TASK-051 AC#3: per-player score/status HUD elements both update. + +func test_hud_reflects_both_players_score_and_status_after_start() -> void: + _screen._start_or_restart() + _screen._refresh_screen() + assert_str(_screen.hud._status_label.text).is_equal(GameScreenState.SCREEN_PLAYING.capitalize()) + assert_str(_screen.hud._p2_status_label.text).is_equal(GameScreenState.SCREEN_PLAYING.capitalize()) + assert_str(_screen.hud._p2_score_label.text).is_equal("P2 Score 0")