// 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; }