Files
m3te/tests/level.sx
swipelab a40a994ae1 P7.1: turn / goal state machine (pure sx)
Add a thin, deterministic level loop over the headless model in board.sx:

- target_score: per-level score goal (DEFAULT_TARGET_SCORE=1500), seeded by init.
- Status (in_progress/won/lost) derived purely from score/target/move budget via
  level_status; won is checked before lost so meeting the goal on the final move
  wins. status_name for the HUD/snapshots.
- has_legal_swap: allocation-free deadlock probe (first legal pair short-circuits).
- reshuffle: Fisher-Yates over existing gems via the board's seeded RNG until the
  arrangement has no immediate match and at least one legal move; consumes no
  move, terminates via MAX_RESHUFFLE_TRIES.
- restart: reseed a fresh, reproducible level (resets cells/score/moves/goal).
- play_turn / TurnResult: commit_swap then reshuffle on deadlock while in
  progress, reporting the resulting status.

tests/level.sx (golden tests/expected/level.{stdout,exit}) asserts: start
in_progress; one legal swap crossing a low goal -> won transition; budget
exhausted below an unreachable goal -> lost transition; a provably deadlocked
diagonal Latin-square board ((col-row) mod 6) reshuffles to >=1 legal move with
no immediate match and no move spent; restart resets progress and a fixed seed
reproduces the same starting board.

Gate: sx build --target ios-sim main.sx (exit 0); bash tools/run_tests.sh
(17 passed, 0 failed).
2026-06-05 08:25:08 +03:00

197 lines
8.6 KiB
Plaintext

// Level / turn-goal state-machine golden (P7.1).
//
// Proves the pure level loop layered over the model in board.sx:
//
// * `level_status` derives in_progress / won / lost from `Board.score`,
// `Board.target_score` and the move budget — won the moment the goal is met,
// lost when the budget is spent short of it.
// * the win and loss TRANSITIONS across a committed move (`play_turn`).
// * the deadlock RESHUFFLE rule: a provably stuck board (no legal swap, no
// immediate match) reshuffles — via the seeded RNG — into a board with >=1
// legal move and still no immediate match, consuming no move.
// * `restart` resets score/moves/status and, for a fixed seed, reproduces the
// exact same starting board.
//
// Deterministic: fixed seeds and crafted boards, every transition asserted AND
// printed so the golden is self-explanatory.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
START_SEED :: 1337;
RESHUFFLE_SEED :: 1337;
// Inverse of `gem_char`: map a board character back to its Gem so each board can
// be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed, the turn counters reset to a fresh game, and
// the per-level goal set.
load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b.rng = rng_seeded(seed);
b.score = 0;
b.moves_made = 0;
b.move_limit = move_limit;
b.target_score = target_score;
b
}
boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
true
}
main :: () -> s32 {
print("== level (turn / goal state machine) ==\n");
// ── Start: a fresh seeded board reads in_progress with the default goal ──
print("== start-in-progress ==\n");
b0 : Board = ---;
b0.init(START_SEED);
out(board_dump(@b0));
print("score {} target {} moves_remaining {} status {}\n",
b0.score, b0.target_score, b0.moves_remaining(), status_name(level_status(@b0)));
t.expect(level_status(@b0) == .in_progress, "start: status in_progress");
t.expect(b0.target_score == DEFAULT_TARGET_SCORE, "start: default goal");
// ── WIN transition: one legal swap crosses a low goal ───────────────────
// The commit-legal-len3 board: swapping (2,0)<->(2,1) completes RRR for an
// award of 30. With the goal set to 30, that single move flips in_progress
// -> won, and wins with moves still in the budget (win triggers on the goal,
// not on the budget running out).
print("== win-transition ==\n");
bw := load_board(.[
"RROGOGOG",
"GGROGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 7, 5, 30);
out(board_dump(@bw));
print("before: score {} target {} moves_remaining {} status {}\n",
bw.score, bw.target_score, bw.moves_remaining(), status_name(level_status(@bw)));
t.expect(level_status(@bw) == .in_progress, "win: in_progress before move");
tw := play_turn(@bw, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 });
print("after: legal {} awarded {} reshuffled {}\n", tw.move.legal, tw.move.cascade.awarded, tw.reshuffled);
print("after: score {} moves_remaining {} status {}\n",
bw.score, bw.moves_remaining(), status_name(tw.status));
out(board_dump(@bw));
t.expect(tw.move.legal, "win: swap committed");
t.expect(bw.score >= bw.target_score, "win: goal reached");
t.expect(tw.status == .won, "win: turn reports won");
t.expect(level_status(@bw) == .won, "win: board reads won");
t.expect(bw.moves_remaining() > 0, "win: won with moves to spare");
t.expect(!tw.reshuffled, "win: no reshuffle once won");
// ── LOSS transition: the move budget runs out short of the goal ──────────
// Same legal swap (award 30) but a 1-move budget and an unreachable goal:
// after the move moves_remaining hits 0 with score < goal, flipping
// in_progress -> lost.
print("== lose-transition ==\n");
bl := load_board(.[
"RROGOGOG",
"GGROGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 7, 1, 1000000);
out(board_dump(@bl));
print("before: score {} target {} moves_remaining {} status {}\n",
bl.score, bl.target_score, bl.moves_remaining(), status_name(level_status(@bl)));
t.expect(level_status(@bl) == .in_progress, "lose: in_progress before move");
tl := play_turn(@bl, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 });
print("after: legal {} score {} moves_remaining {} status {}\n",
tl.move.legal, bl.score, bl.moves_remaining(), status_name(tl.status));
t.expect(tl.move.legal, "lose: swap committed");
t.expect(bl.moves_remaining() == 0, "lose: budget spent");
t.expect(bl.score < bl.target_score, "lose: short of goal");
t.expect(tl.status == .lost, "lose: turn reports lost");
t.expect(level_status(@bl) == .lost, "lose: board reads lost");
t.expect(!tl.reshuffled, "lose: no reshuffle once lost");
// ── No-legal-moves rule: RESHUFFLE ──────────────────────────────────────
// A provably deadlocked board: gem(col,row) = (col-row) mod 6, a diagonal
// Latin square. Equal gems lie only on slope-1 diagonals, so no row/column
// holds two of a kind within reach — there is no immediate match, and no
// orthogonal swap can line up three. `reshuffle` re-arranges the SAME gems
// (seeded RNG) into a board with a legal move and no immediate match, and
// spends no move.
print("== deadlock-reshuffle ==\n");
bd := load_board(.[
"ROYGBPRO",
"PROYGBPR",
"BPROYGBP",
"GBPROYGB",
"YGBPROYG",
"OYGBPROY",
"ROYGBPRO",
"PROYGBPR",
], RESHUFFLE_SEED, 10, 1500);
out(board_dump(@bd));
pre_m := find_matches(@bd);
pre_sw := legal_swaps(@bd);
print("before: matches {} legal_swaps {} has_legal_swap {}\n",
pre_m.count(), pre_sw.len, has_legal_swap(@bd));
t.expect(pre_m.count() == 0, "deadlock: no immediate match before");
t.expect(pre_sw.len == 0, "deadlock: no legal swap before");
t.expect(!has_legal_swap(@bd), "deadlock: has_legal_swap false before");
score_before := bd.score;
made_before := bd.moves_made;
ok := reshuffle(@bd);
post_m := find_matches(@bd);
post_sw := legal_swaps(@bd);
print("reshuffled {} matches {} legal_swaps {}\n", ok, post_m.count(), post_sw.len);
print("after: score {} moves_made {}\n", bd.score, bd.moves_made);
out(board_dump(@bd));
t.expect(ok, "deadlock: reshuffle succeeded");
t.expect(post_m.count() == 0, "deadlock: no immediate match after reshuffle");
t.expect(post_sw.len > 0, "deadlock: >=1 legal move after reshuffle");
t.expect(bd.score == score_before, "deadlock: reshuffle spent no score");
t.expect(bd.moves_made == made_before, "deadlock: reshuffle spent no move");
// ── Restart: reset progress, reproduce the seeded starting board ─────────
print("== restart ==\n");
br : Board = ---;
br.init(START_SEED);
// Dirty the state as a partial game would: spend moves, accrue score, and
// mutate a cell so the layout differs from a fresh board.
br.score = 500;
br.moves_made = 7;
br.set(0, 0, .empty);
print("dirty: score {} moves_made {} status {}\n",
br.score, br.moves_made, status_name(level_status(@br)));
restart(@br, START_SEED);
ref : Board = ---;
ref.init(START_SEED);
print("after restart: score {} moves_made {} moves_remaining {} status {}\n",
br.score, br.moves_made, br.moves_remaining(), status_name(level_status(@br)));
out(board_dump(@br));
t.expect(br.score == 0, "restart: score reset");
t.expect(br.moves_made == 0, "restart: moves reset");
t.expect(level_status(@br) == .in_progress, "restart: status in_progress");
t.expect(boards_equal(@br, @ref), "restart: same seed reproduces starting board");
print("ok: level / turn-goal state machine\n");
return 0;
}