P3.3: turn accounting + special-match flagging (pure sx)

Extend the pure-sx board model with the model-level pieces P5 (input) and
P7 (turn/goal loop) will call:

- Turn accounting on Board: `moves_made` + configurable `move_limit`
  (DEFAULT_MOVE_LIMIT), with `moves_remaining()` derived from the two so
  the counters can't drift. `init` resets them.
- Special-match flagging: `count_specials` tallies a detection round's
  maximal runs of length exactly 4 and length 5+, surfaced over a whole
  settle as Cascade.len4 / len5_plus (+ had_len4 / had_len5_plus). A hook
  for future special gems — detection only, no gem behavior.
- `commit_swap`: the single player-move entry point. Legal swap → apply,
  resolve (scoring accrues), spend one move; illegal → revert, no move
  spent. Returns a PlayerMove with the settle's payout + special flags.

Adds tests/turn.sx (+ golden) asserting: legal swap decrements the move
counter by exactly 1 and accrues score; illegal swap leaves counter,
score and board untouched; len4/len5+/len3 rounds set the documented
flags. Existing goldens unchanged; ios-sim build compiles.
This commit is contained in:
swipelab
2026-06-04 21:36:19 +03:00
parent 1e687874e3
commit 2ff3d6e609
4 changed files with 442 additions and 3 deletions

130
board.sx
View File

@@ -77,6 +77,13 @@ BOARD_COLS :: 8;
BOARD_ROWS :: 8;
BOARD_CELLS :: BOARD_COLS * BOARD_ROWS;
// Default per-game move budget (P3.3). `init` seeds `Board.move_limit` with
// this; `moves_remaining` counts down from it as committed swaps spend moves.
// The turn/goal loop (P7) owns enforcing the budget — the model only TRACKS it,
// so `moves_remaining` may legitimately reach 0 (or below, if a caller keeps
// committing) without `commit_swap` refusing.
DEFAULT_MOVE_LIMIT :: 30;
Board :: struct {
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
cells: [BOARD_CELLS]Gem;
@@ -94,10 +101,28 @@ Board :: struct {
// field. A hand-built board must zero this before accumulating.
score: s64;
// Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED —
// only a legal swap (one that resolved into >=1 match) via `commit_swap`
// increments it; an illegal, reverted swap does not. `move_limit` is the
// game's move budget (`init` sets it to DEFAULT_MOVE_LIMIT); `moves_remaining`
// is derived from the two, so there is a single source of truth and the
// counters can never drift apart. A hand-built board must set both before
// committing swaps.
moves_made: s64;
move_limit: s64;
idx :: (col: s64, row: s64) -> s64 {
row * BOARD_COLS + col
}
// Moves still available against the budget: `move_limit - moves_made`. Goes
// to 0 when the budget is spent (and below it only if a caller keeps
// committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop
// (P7) reads this to decide when the game ends.
moves_remaining :: (self: *Board) -> s64 {
self.move_limit - self.moves_made
}
at :: (self: *Board, col: s64, row: s64) -> Gem {
self.cells[Board.idx(col, row)]
}
@@ -115,6 +140,8 @@ Board :: struct {
init :: (self: *Board, seed: s64) {
self.rng = rng_seeded(seed);
self.score = 0;
self.moves_made = 0;
self.move_limit = DEFAULT_MOVE_LIMIT;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
self.set(col, row, pick_gem(self, @self.rng, col, row));
@@ -478,10 +505,27 @@ refill :: (board: *Board) -> s64 {
// `Board.score`: the sum over rounds of `score_round * combo_multiplier(round)`
// (P3.2), so the HUD (P4.4) and turn accounting (P3.3) can read a swap's payout
// without re-deriving it. A depth-0 (already-stable) board awards 0.
//
// `len4` / `len5_plus` tally the special-match runs cleared across the WHOLE
// settle (summed over rounds): the number of maximal runs of length exactly 4,
// and of length 5 or more (P3.3 special-match flagging). They are a HOOK for
// future special gems — nothing here creates or alters a gem; the tallies only
// make "did this settle clear a 4 / 5+ run" observable. `had_len4` /
// `had_len5_plus` are the boolean view of the same counts.
Cascade :: struct {
depth: s64;
cleared: List(s64);
awarded: s64;
len4: s64;
len5_plus: s64;
had_len4 :: (self: *Cascade) -> bool {
self.len4 > 0
}
had_len5_plus :: (self: *Cascade) -> bool {
self.len5_plus > 0
}
}
// One resolution round: detect matches and, if any, clear them, collapse under
@@ -503,11 +547,13 @@ resolve_step :: (board: *Board) -> s64 {
// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
resolve :: (board: *Board) -> Cascade {
result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0 };
result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
while true {
// Read the round's base points while its runs are still on the board:
// `resolve_step` clears them, so the score has to be taken first.
// Read the round's base points AND its special-match tally while the runs
// are still on the board: `resolve_step` clears them, so both have to be
// taken first. A no-match round scores 0 and tallies nothing, then breaks.
base := score_round(board);
sp := count_specials(board);
n := resolve_step(board);
if n == 0 { break; }
result.depth += 1;
@@ -515,6 +561,8 @@ resolve :: (board: *Board) -> Cascade {
board.score += points;
result.awarded += points;
result.cleared.append(n);
result.len4 += sp.len4;
result.len5_plus += sp.len5_plus;
}
result
}
@@ -637,6 +685,43 @@ combo_multiplier :: (round: s64) -> s64 {
round
}
// ── Special-match flagging (P3.3) ──────────────────────────────────────────
// A HOOK for future special gems: surface, per detection round, how many of the
// board's maximal runs are long enough to (later) spawn one — a run of length
// exactly 4, and a run of length 5 or more. This is detection ONLY: nothing here
// creates, marks, or alters a gem; later work reads these counts to decide what
// special gem (if any) a clear produces. Length 3 runs are ordinary and counted
// by neither tier.
// Per-round tally of special-length runs: `len4` is the number of maximal runs
// of length exactly 4, `len5_plus` the number of length 5 or more. Boolean
// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the
// whole settle; a single round reads these counts directly.
SpecialCounts :: struct {
len4: s64;
len5_plus: s64;
}
// Count the board's currently-matched runs that hit a special length, by the
// same `find_runs` enumeration scoring uses (so an L/T's horizontal and vertical
// runs are counted independently by their own lengths). Pure and read-only — it
// inspects the board but changes nothing, so it must be called BEFORE the round's
// clear, while the runs are still present. A board with no run (or only length-3
// runs) tallies zero in both tiers.
count_specials :: (board: *Board) -> SpecialCounts {
runs := find_runs(board);
counts := SpecialCounts.{ len4 = 0, len5_plus = 0 };
for 0..runs.len: (i) {
len := runs.items[i].len;
if len == 4 {
counts.len4 += 1;
} else if len >= 5 {
counts.len5_plus += 1;
}
}
counts
}
// Deterministic textual dump of an enumerated run list, in `find_runs` order: a
// count header, then one run per line as `<axis> len <n> at fixed <f> start <s>`
// where axis is H (horizontal) or V (vertical). An empty list dumps as just
@@ -650,3 +735,42 @@ dump_runs :: (runs: *List(Run)) -> string {
}
result
}
// ── Player move (P3.3) ─────────────────────────────────────────────────────
// The single model-level entry point a player's swap goes through — what the
// swipe input (P5) and turn/goal loop (P7) will call. It ties legality, the
// swap, the cascade settle, and turn accounting together so callers don't
// re-implement the sequence.
// Outcome of attempting one player swap via `commit_swap`. `legal` says whether
// the swap resolved into at least one match and was therefore COMMITTED: when
// false the board is untouched, no move was spent, and `cascade` is the empty
// (depth-0) settle; when true the swap was applied, the board resolved (scoring
// accrued into `Board.score`) and exactly one move was spent. `cascade` carries
// the settle's full outcome — depth, per-round cleared counts, awarded points,
// and the special-match (len4 / len5+) tallies. `moves_remaining` snapshots the
// board's remaining budget AFTER the move, so a caller has it without re-reading.
PlayerMove :: struct {
legal: bool;
cascade: Cascade;
moves_remaining: s64;
}
// Attempt the player's intended swap of two adjacent cells. If the swap is legal
// (`swap_legal`: adjacent AND it forms a match), apply it, `resolve` the cascade
// — which accrues score into `Board.score` and reports the awarded points and
// special-match flags — then spend one move (`moves_made += 1`). If it is illegal
// (non-adjacent, or forms no match) the board is left exactly as it was — no swap,
// no resolve, no move spent — and an empty depth-0 cascade is returned. Move
// accounting only TRACKS the budget; it does not refuse a swap when the budget is
// spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT.
commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
if !swap_legal(board, a, b) {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
}
swap(board, a, b);
cascade := resolve(board);
board.moves_made += 1;
PlayerMove.{ legal = true, cascade = cascade, moves_remaining = board.moves_remaining() }
}