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:
130
board.sx
130
board.sx
@@ -77,6 +77,13 @@ BOARD_COLS :: 8;
|
|||||||
BOARD_ROWS :: 8;
|
BOARD_ROWS :: 8;
|
||||||
BOARD_CELLS :: BOARD_COLS * BOARD_ROWS;
|
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 {
|
Board :: struct {
|
||||||
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
||||||
cells: [BOARD_CELLS]Gem;
|
cells: [BOARD_CELLS]Gem;
|
||||||
@@ -94,10 +101,28 @@ Board :: struct {
|
|||||||
// field. A hand-built board must zero this before accumulating.
|
// field. A hand-built board must zero this before accumulating.
|
||||||
score: s64;
|
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 {
|
idx :: (col: s64, row: s64) -> s64 {
|
||||||
row * BOARD_COLS + col
|
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 {
|
at :: (self: *Board, col: s64, row: s64) -> Gem {
|
||||||
self.cells[Board.idx(col, row)]
|
self.cells[Board.idx(col, row)]
|
||||||
}
|
}
|
||||||
@@ -115,6 +140,8 @@ Board :: struct {
|
|||||||
init :: (self: *Board, seed: s64) {
|
init :: (self: *Board, seed: s64) {
|
||||||
self.rng = rng_seeded(seed);
|
self.rng = rng_seeded(seed);
|
||||||
self.score = 0;
|
self.score = 0;
|
||||||
|
self.moves_made = 0;
|
||||||
|
self.move_limit = DEFAULT_MOVE_LIMIT;
|
||||||
for 0..BOARD_ROWS: (row) {
|
for 0..BOARD_ROWS: (row) {
|
||||||
for 0..BOARD_COLS: (col) {
|
for 0..BOARD_COLS: (col) {
|
||||||
self.set(col, row, pick_gem(self, @self.rng, col, row));
|
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)`
|
// `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
|
// (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.
|
// 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 {
|
Cascade :: struct {
|
||||||
depth: s64;
|
depth: s64;
|
||||||
cleared: List(s64);
|
cleared: List(s64);
|
||||||
awarded: 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
|
// 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
|
// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to
|
||||||
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
|
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
|
||||||
resolve :: (board: *Board) -> Cascade {
|
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 {
|
while true {
|
||||||
// Read the round's base points while its runs are still on the board:
|
// Read the round's base points AND its special-match tally while the runs
|
||||||
// `resolve_step` clears them, so the score has to be taken first.
|
// 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);
|
base := score_round(board);
|
||||||
|
sp := count_specials(board);
|
||||||
n := resolve_step(board);
|
n := resolve_step(board);
|
||||||
if n == 0 { break; }
|
if n == 0 { break; }
|
||||||
result.depth += 1;
|
result.depth += 1;
|
||||||
@@ -515,6 +561,8 @@ resolve :: (board: *Board) -> Cascade {
|
|||||||
board.score += points;
|
board.score += points;
|
||||||
result.awarded += points;
|
result.awarded += points;
|
||||||
result.cleared.append(n);
|
result.cleared.append(n);
|
||||||
|
result.len4 += sp.len4;
|
||||||
|
result.len5_plus += sp.len5_plus;
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -637,6 +685,43 @@ combo_multiplier :: (round: s64) -> s64 {
|
|||||||
round
|
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
|
// 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>`
|
// 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
|
// 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
|
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() }
|
||||||
|
}
|
||||||
|
|||||||
1
tests/expected/turn.exit
Normal file
1
tests/expected/turn.exit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
98
tests/expected/turn.stdout
Normal file
98
tests/expected/turn.stdout
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
== turn (accounting + special-match flagging) ==
|
||||||
|
== flag-len3-none ==
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GORRROGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
len4 0 len5_plus 0
|
||||||
|
had_len4 false had_len5_plus false
|
||||||
|
== flag-len4 ==
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GORRRRGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
len4 1 len5_plus 0
|
||||||
|
had_len4 true had_len5_plus false
|
||||||
|
== flag-len5 ==
|
||||||
|
OGOGOGOG
|
||||||
|
GRRRRRGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
len4 0 len5_plus 1
|
||||||
|
had_len4 false had_len5_plus true
|
||||||
|
== flag-len4-and-len5 ==
|
||||||
|
RRRGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
GOBBBBOG
|
||||||
|
OGOGOGOG
|
||||||
|
GPPPPPGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
len4 1 len5_plus 1
|
||||||
|
had_len4 true had_len5_plus true
|
||||||
|
== commit-legal-len3 ==
|
||||||
|
RROGOGOG
|
||||||
|
GGROGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
before: score 0 moves_made 0 moves_remaining 5
|
||||||
|
after: legal true depth 1 awarded 30 len4 0 len5_plus 0
|
||||||
|
after: score 30 moves_made 1 moves_remaining 4
|
||||||
|
RBRGOGOG
|
||||||
|
GGOOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
== commit-illegal ==
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
before: score 0 moves_made 0 moves_remaining 5
|
||||||
|
after: legal false depth 0 awarded 0 len4 0 len5_plus 0
|
||||||
|
after: score 0 moves_made 0 moves_remaining 5
|
||||||
|
== commit-legal-len4 ==
|
||||||
|
RROROGOG
|
||||||
|
GGROGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
after: legal true depth 1 awarded 60 len4 1 len5_plus 0
|
||||||
|
after: had_len4 true had_len5_plus false
|
||||||
|
after: score 60 moves_made 1 moves_remaining 4
|
||||||
|
RBRROGOG
|
||||||
|
GGOOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
ok: turn accounting + special-match flagging
|
||||||
216
tests/turn.sx
Normal file
216
tests/turn.sx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// Turn accounting + special-match flagging golden (P3.3).
|
||||||
|
//
|
||||||
|
// Two things are proven over crafted, deterministic boards:
|
||||||
|
//
|
||||||
|
// 1. Special-match FLAGGING (pure, no RNG): `count_specials` tallies a single
|
||||||
|
// detection round's maximal runs by special length — runs of length exactly
|
||||||
|
// 4 (`len4`) and of length 5 or more (`len5_plus`). A round with only
|
||||||
|
// length-3 runs tallies neither; the tiers are independent and length-3 runs
|
||||||
|
// never count. This is a HOOK for future special gems — detection only.
|
||||||
|
//
|
||||||
|
// 2. Turn ACCOUNTING via `commit_swap`, the single player-move entry point P5
|
||||||
|
// (input) and P7 (turn loop) will call. A legal swap (one that forms a
|
||||||
|
// match) is committed — applied, resolved (scoring accrues), and it spends
|
||||||
|
// exactly one move; the returned `PlayerMove` reports the settle's payout
|
||||||
|
// and special flags. An illegal swap is rejected — board reverted, no move
|
||||||
|
// spent, no score change.
|
||||||
|
//
|
||||||
|
// Every counter/flag is asserted independently of the dump AND printed so the
|
||||||
|
// golden is self-explanatory and deterministic.
|
||||||
|
#import "modules/std.sx";
|
||||||
|
#import "board.sx";
|
||||||
|
t :: #import "test.sx";
|
||||||
|
|
||||||
|
SEED :: 7;
|
||||||
|
LIMIT :: 5;
|
||||||
|
|
||||||
|
// 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, and the turn counters reset to a fresh game
|
||||||
|
// (no moves made, the given move budget).
|
||||||
|
load_board :: (rows: []string, seed: s64, move_limit: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
boards_equal :: (a: *Board, b: *Board) -> bool {
|
||||||
|
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// One flag scene: snapshot the board, then count its single round's special
|
||||||
|
// runs and assert the tallies (and the boolean flags derived from them) are
|
||||||
|
// exactly the documented values. No RNG, no clear — pure detection.
|
||||||
|
flag_scene :: (name: string, rows: []string, want_len4: s64, want_len5_plus: s64) {
|
||||||
|
print("== {} ==\n", name);
|
||||||
|
b := load_board(rows, 0, LIMIT);
|
||||||
|
out(board_dump(@b));
|
||||||
|
sp := count_specials(@b);
|
||||||
|
print("len4 {} len5_plus {}\n", sp.len4, sp.len5_plus);
|
||||||
|
print("had_len4 {} had_len5_plus {}\n", sp.len4 > 0, sp.len5_plus > 0);
|
||||||
|
t.expect(sp.len4 == want_len4, concat(name, ": len4 count exact"));
|
||||||
|
t.expect(sp.len5_plus == want_len5_plus, concat(name, ": len5_plus count exact"));
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: () -> s32 {
|
||||||
|
print("== turn (accounting + special-match flagging) ==\n");
|
||||||
|
|
||||||
|
// ── Special-match flagging (single round, no RNG) ──────────────────────
|
||||||
|
// Only length-3 runs: a lone horizontal RRR -> neither tier flags.
|
||||||
|
flag_scene("flag-len3-none", .[
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GORRROGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], 0, 0);
|
||||||
|
|
||||||
|
// A length-4 run -> len4 flags, len5_plus does not.
|
||||||
|
flag_scene("flag-len4", .[
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GORRRRGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], 1, 0);
|
||||||
|
|
||||||
|
// A length-5 run -> len5_plus flags, len4 does not.
|
||||||
|
flag_scene("flag-len5", .[
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GRRRRRGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], 0, 1);
|
||||||
|
|
||||||
|
// A length-3, a length-4 and a length-5 run together: the length-3 is ignored
|
||||||
|
// and the two special tiers each count their own run -> len4 1, len5_plus 1.
|
||||||
|
flag_scene("flag-len4-and-len5", .[
|
||||||
|
"RRRGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"GOBBBBOG",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GPPPPPGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
], 1, 1);
|
||||||
|
|
||||||
|
// ── Turn accounting via commit_swap ────────────────────────────────────
|
||||||
|
// A legal swap that forms a length-3 run. Swapping (2,0)<->(2,1) drops a red
|
||||||
|
// into row 0 to complete RRR at cols 0-2 (the donor row 1 is shaped so the
|
||||||
|
// displaced gem makes no second run). The swap is committed: applied,
|
||||||
|
// resolved (score accrues), and it spends exactly one move.
|
||||||
|
print("== commit-legal-len3 ==\n");
|
||||||
|
bl := load_board(.[
|
||||||
|
"RROGOGOG",
|
||||||
|
"GGROGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], SEED, LIMIT);
|
||||||
|
out(board_dump(@bl));
|
||||||
|
score_before := bl.score;
|
||||||
|
made_before := bl.moves_made;
|
||||||
|
rem_before := bl.moves_remaining();
|
||||||
|
print("before: score {} moves_made {} moves_remaining {}\n", score_before, made_before, rem_before);
|
||||||
|
mv := commit_swap(@bl, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 });
|
||||||
|
print("after: legal {} depth {} awarded {} len4 {} len5_plus {}\n",
|
||||||
|
mv.legal, mv.cascade.depth, mv.cascade.awarded, mv.cascade.len4, mv.cascade.len5_plus);
|
||||||
|
print("after: score {} moves_made {} moves_remaining {}\n", bl.score, bl.moves_made, mv.moves_remaining);
|
||||||
|
out(board_dump(@bl));
|
||||||
|
t.expect(mv.legal, "legal-len3: swap committed");
|
||||||
|
t.expect(bl.moves_made == made_before + 1, "legal-len3: moves_made +1");
|
||||||
|
t.expect(mv.moves_remaining == rem_before - 1, "legal-len3: reported moves_remaining -1");
|
||||||
|
t.expect(bl.moves_remaining() == rem_before - 1, "legal-len3: board moves_remaining -1");
|
||||||
|
t.expect(bl.score > score_before, "legal-len3: score accrued");
|
||||||
|
t.expect(mv.cascade.awarded == bl.score - score_before, "legal-len3: awarded equals score delta");
|
||||||
|
t.expect(mv.cascade.depth >= 1, "legal-len3: settled at least one round");
|
||||||
|
|
||||||
|
// An illegal swap on a run-free checkerboard: adjacent but forms no match.
|
||||||
|
// It must be rejected outright — board reverted, no move spent, no score.
|
||||||
|
print("== commit-illegal ==\n");
|
||||||
|
bi := load_board(.[
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], SEED, LIMIT);
|
||||||
|
before := bi;
|
||||||
|
out(board_dump(@bi));
|
||||||
|
print("before: score {} moves_made {} moves_remaining {}\n", bi.score, bi.moves_made, bi.moves_remaining());
|
||||||
|
miv := commit_swap(@bi, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 });
|
||||||
|
print("after: legal {} depth {} awarded {} len4 {} len5_plus {}\n",
|
||||||
|
miv.legal, miv.cascade.depth, miv.cascade.awarded, miv.cascade.len4, miv.cascade.len5_plus);
|
||||||
|
print("after: score {} moves_made {} moves_remaining {}\n", bi.score, bi.moves_made, miv.moves_remaining);
|
||||||
|
t.expect(!miv.legal, "illegal: swap rejected");
|
||||||
|
t.expect(miv.cascade.depth == 0, "illegal: no cascade");
|
||||||
|
t.expect(miv.cascade.awarded == 0, "illegal: no points");
|
||||||
|
t.expect(bi.moves_made == 0, "illegal: no move spent");
|
||||||
|
t.expect(miv.moves_remaining == LIMIT, "illegal: full budget remains");
|
||||||
|
t.expect(bi.score == 0, "illegal: score unchanged");
|
||||||
|
t.expect(boards_equal(@before, @bi), "illegal: board reverted");
|
||||||
|
|
||||||
|
// A legal swap that forms a length-4 run (RRRR at cols 0-3): proves the
|
||||||
|
// special-match flag rides through the whole settle onto the PlayerMove.
|
||||||
|
print("== commit-legal-len4 ==\n");
|
||||||
|
b4 := load_board(.[
|
||||||
|
"RROROGOG",
|
||||||
|
"GGROGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], SEED, LIMIT);
|
||||||
|
out(board_dump(@b4));
|
||||||
|
m4 := commit_swap(@b4, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 });
|
||||||
|
print("after: legal {} depth {} awarded {} len4 {} len5_plus {}\n",
|
||||||
|
m4.legal, m4.cascade.depth, m4.cascade.awarded, m4.cascade.len4, m4.cascade.len5_plus);
|
||||||
|
print("after: had_len4 {} had_len5_plus {}\n", m4.cascade.had_len4(), m4.cascade.had_len5_plus());
|
||||||
|
print("after: score {} moves_made {} moves_remaining {}\n", b4.score, b4.moves_made, m4.moves_remaining);
|
||||||
|
out(board_dump(@b4));
|
||||||
|
t.expect(m4.legal, "legal-len4: swap committed");
|
||||||
|
t.expect(m4.cascade.had_len4(), "legal-len4: settle flags a length-4 run");
|
||||||
|
t.expect(b4.moves_made == 1, "legal-len4: one move spent");
|
||||||
|
t.expect(b4.score > 0, "legal-len4: score accrued");
|
||||||
|
|
||||||
|
print("ok: turn accounting + special-match flagging\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user