Commit Graph

17 Commits

Author SHA1 Message Date
swipelab
5ec7247001 P6.1: lock input for the full in-flight animation window
A swipe that began while a move animation was playing could still commit:
mouse_down latched the drag unconditionally and the animation-active check
sat at mouse_up, so a press made mid-animation committed once the timeline
finished before release — against a board mid-transition.

Gate input at gesture START instead. Add a pure `accepts_input(anim)`
predicate (false while a timeline is active) and check it at mouse_down: a
press begun mid-animation is dropped and never latches a drag, so it cannot
commit when the animation later settles. The now-dead mouse_up gate is
removed. Animation visuals and the logical model are unchanged.

Extend tests/anim_plan.sx to assert accepts_input rejects for the whole
window (idle accept / busy reject / settled accept) and that press-gating
drops the exact failure gesture a release-gate would let through.
2026-06-05 01:23:12 +03:00
swipelab
0b858f7724 P6.1: swap/clear/fall move tweens (sx, iOS sim)
Add a purely-visual animation timeline so the board no longer snaps on a
move. board_anim.sx records, on a value-copy of the pre-move board, the
swap and each cascade round's matched cells + per-column fall provenance,
then BoardView plays it over delta_time: the two swapped gems SLIDE between
cells (and ping out-and-back on an illegal swap), matched gems SCALE OUT,
and survivors FALL into place while refills drop in from above the grid.

The model stays authoritative: plan_and_commit still calls commit_swap on
the real board exactly as before, and the recording replays the identical
primitives from the identical cells + RNG state, so the timeline ends ON
the model's settled board. tests/anim_plan.sx is the determinism guard —
it asserts the committed board, score, moves, and the timeline's final
state all equal an independent commit_swap of the same move, that the
rounds are contiguous, and that an illegal swap records nothing and leaves
the board untouched. All pre-existing logic/cascade goldens stay green.

Evidence (sx-test-metal, iOS 26.0, time-sampled with temporarily-lengthened
durations; committed durations are the short production values):
goldens/p6_anim_swap.png  gems sliding between (5,4)/(6,4)
goldens/p6_anim_clear.png matched reds scaling out in row 4
goldens/p6_anim_fall.png  gems mid-fall with gaps + refill dropping in
goldens/p6_anim_after.png settled board == model (SCORE 30, MOVES 29/30)
2026-06-05 01:06:02 +03:00
swipelab
e5df37523f P5.2: swipe commits legal swap / reverts illegal (sx, iOS sim)
Wire touch input into the model in BoardView.handle_event. A press records
the drag start (new DragInput, heap-allocated so it survives the per-frame
BoardView rebuild between mouse_down and mouse_up); the release resolves the
gesture against the same layout it was drawn with. A swipe — start→end mapped
by swipe_intent to an adjacent-swap intent — is fed straight into
commit_swap: a legal swap applies, cascades (clear→collapse→refill), accrues
score and spends a move; an illegal one reverts, no move. A sub-threshold /
off-board drag carries no intent and falls back to the tap behaviour
(toggle/clear selection). The next frame re-renders board + HUD from the model.

Reuses swipe.sx + board_layout.sx + commit_swap unchanged — this is wiring,
not new legality/cascade logic.

tests/swipe_commit.sx (new golden) drives the full path on the seeded board
(SEED 1337): a rightward swipe (0,0)->(1,0) is illegal (two reds) and reverts
byte-for-byte with no score/move; (5,4)->(6,4) is legal, completes R,R,R on
row 4, awards 30, spends one move.

Sim evidence (iPhone 17, iOS 26.0): goldens/p5_swap_before.png (SCORE 0,
MOVES 30/30) and goldens/p5_swap_after.png (SCORE 30, MOVES 29/30) bracket a
real idb-injected swipe at (276,475)->(327,475) pt = cell (5,4)->(6,4); the
three reds clear and the board matches the model's resolved state.
2026-06-05 00:32:40 +03:00
swipelab
f290e2614a P5.1: drag → adjacent-swap intent mapping (pure sx)
Add swipe.sx: a pure swipe_intent(layout, start, end) -> ?Swap that turns a
touch drag into an optional adjacent-swap intent (A, B). A is the cell under the
drag start (via BoardLayout.point_to_cell); B is its orthogonal neighbour along
the drag's dominant axis (larger of |dx|,|dy|, ties horizontal). Returns null for
sub-threshold drags (a tap, threshold = cell_size * 0.5), starts off the board,
or neighbours off the board. No rendering, no model mutation.

Lock the logic with tests/swipe_intent.sx (+ expected golden), feeding synthetic
down/up positions for right/left/up/down, sub-threshold tap, diagonal→dominant
(both axes), edge-outward off-board, and start-off-board. bash tools/run_tests.sh
passes (13/13); sx build --target ios-sim main.sx compiles.
2026-06-05 00:14:44 +03:00
swipelab
9ed98c73d2 P4.4: selection highlight + score/moves HUD (sx, iOS sim)
Tap a gem to select it: BoardView hit-tests the touch to a grid cell and
draws a bright rim + translucent fill over it; tapping the same cell clears
the selection, tapping another moves it, tapping off-board clears it.
Selection only — no swap (that's P5). The HUD renders the live score and
remaining moves (out of the move limit) in the Lato font on a translucent
card above the grid.

The touch→cell geometry is factored into a pure BoardLayout (no GL/stb
imports) that BoardView composes and P5 will reuse for swap endpoints.
tests/hit_test.sx locks point_to_cell as the exact inverse of cell_frame
(every cell center round-trips; off-board taps reject) — headless because
BoardLayout pulls no C imports. goldens/p4_hud.png captures the scene after
a real idb tap at (201,437)pt: the HUD plus a yellow selection rim on the
red gem at cell (col 4, row 3).
2026-06-05 00:00:48 +03:00
swipelab
2ff3d6e609 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.
2026-06-04 21:36:19 +03:00
swipelab
c7368a82f1 P3.2: cascade combo multiplier (pure sx)
Scale each cascade round's base points by combo_multiplier(round) = the
1-based round index (round 1 x1, round 2 x2, ...), so deeper chains pay
out more. resolve now reads score_round before each clear, accumulates
score_round * combo_multiplier(round) into Board.score, and reports the
settle's payout as the new Cascade.awarded field. A depth-1 settle scores
exactly its base (x1, no bonus); any multi-round chain strictly exceeds
the same clears scored flat.

resolve_step keeps its signature (no scoring), so cascade.sx and its
golden are unchanged; score_round/add_round_score are untouched, so
score.sx is unchanged. New tests/combo.sx golden locks exact cumulative
scores for a single-round clear (30), the P2.4 cascade board (flat 60 ->
mult 90), and a controlled 3-round chain (flat 90 -> mult 180), printing
per-round base/multiplier/points so the golden self-explains.
2026-06-04 21:17:08 +03:00
swipelab
98752fe8ec P3.1: base match scoring by run length (pure sx)
Add a run-enumeration path and base scoring to the pure-sx model, scoring
one resolution round's clears purely by maximal-run length.

- find_runs(board) enumerates each maximal H/V run (len >= 3) with its
  length, parallel to find_matches/MatchMask (left untouched so every
  clear/cascade caller is unaffected).
- run_score(len): length 3 -> 30, 4 -> 60, 5+ -> 100 (named constants).
- score_round(board): sum of run_score over the current runs (read-only,
  must be called before the round's clear). L/T rule: each maximal run
  scores independently by its own length, so an overlapping L/T scores
  horizontal + vertical (shared corner counts toward both runs).
- Board gains a running `score` field (init zeroes it) and add_round_score
  accumulates a round's base points into it; the cross-round combo
  multiplier off Cascade.depth is left for P3.2.
- tests/score.sx golden over hand-crafted single-round boards asserts exact
  points for len-3/4/5 runs, disjoint runs (sum), an overlapping L/T, and a
  no-match board (0).
2026-06-04 21:01:01 +03:00
swipelab
3e180a121a P2.4: cascade resolution loop (pure sx)
Add the settle loop a swap triggers: resolve(board) runs rounds of
detect -> clear -> collapse -> refill until a round finds no match,
returning a Cascade { depth, cleared } so P3 can read per-round
cleared-cell counts and the combo-driving depth. resolve_step exposes
one round. Termination follows from eventually reaching no-match; no
artificial round cap.

tests/cascade.sx: a fixed-seed hand-crafted board where clearing the
initial BBB lets gravity pack col 0 into a fresh vertical RRR, so the
loop chains two rounds; snapshots each per-round board and the final
depth, asserts the final board is stable and that public resolve
reproduces the manual loop, plus a depth-0 control on an unchanged
checkerboard. Locked in tests/expected/cascade.{stdout,exit}.
2026-06-04 20:48:20 +03:00
swipelab
23d08e44ce P2.3: seeded refill (pure sx)
Add refill to the board model: every .empty hole is filled with a fresh gem
drawn from the board's OWN seeded RNG, so refills are fully reproducible for a
seed and continue the stream rather than reseeding.

- Board now owns its RNG state (rng: Rng); init seeds and draws from it, so
  draws after init/clears thread deterministically. The init draw sequence is
  unchanged, so board_init's golden is byte-identical.
- refill(board) fills all holes in row-major order wherever they sit (does not
  assume collapse ran) and makes no attempt to avoid matches — a refill may
  create new runs, which drives the P2.4 cascade.
- tests/refill.sx (fixed seed) runs clear -> collapse -> refill, locks the
  staged dump as a golden, and asserts: zero empties after refill; each hole
  holds the next seeded-stream gem (replayed from the pre-refill state); drawn
  gems vary (not a constant); same start+seed -> identical board; a second
  refill of the same holes draws new gems (RNG threads, no reseed).
2026-06-04 20:35:50 +03:00
swipelab
b5a3b16651 P2.2: gravity collapse (pure sx)
Add collapse(board): per-column gravity that packs gems contiguously at
the bottom (preserving top-to-bottom order) and bubbles holes to the top,
with no horizontal movement. Returns whether any gem fell, for the P2.4
cascade. Does not refill (that is P2.3).

tests/collapse.sx snapshots gravity over hand-crafted boards exercising
holes in the middle / at the bottom, a full column of holes, a column
with none, a lone gem, an alternating stack, and an already-settled board
(idempotency). Asserts, independently of the dump, that each column's gems
end packed at the bottom in original order with holes above, plus the
exact moved flag. Golden locked in tests/expected/collapse.{stdout,exit}.
2026-06-04 20:21:12 +03:00
swipelab
6e2a57f3a2 P2.1: holes never match in find_matches
find_matches walked maximal same-type spans without excluding `.empty`, so a
line of 3+ holes (left by a prior clear) was reported as a match. After any
vertical 3-clear or L/T clear the board carries such a line, so find_matches /
clear_matches returned non-zero on a board with no real gem match — which would
prevent the P2.4 cascade from ever stabilising.

Fix at the source: a run is only a match if its gem type is not `.empty`. Holes
already break runs of real gems (a hole differs from every gem), so this is the
only change needed and every caller (P1.3 legality, P2.4 cascade) is now correct.

Regression in tests/clear.sx: a holes-only board yields zero matches and
clear_matches 0, and re-clearing a holed board returns 0. Other goldens are
unchanged (no board without holes is affected).
2026-06-04 20:08:26 +03:00
swipelab
2713a67b2b P2.1: clear matched cells (pure sx)
Add the first resolution-pipeline step to the headless board model.

- Introduce an `empty` hole sentinel on the Gem enum (ordinal 6, outside
  GEM_COUNT so the RNG/pick_gem never draw it). board_dump renders holes as
  EMPTY_CHAR ('.') via a single branch in gem_char, leaving boards without
  holes byte-identical to before (existing goldens unchanged).
- clear_cells(board, mask): set every matched cell to `.empty`, leave all
  others untouched, return the count cleared.
- clear_matches(board): detect+clear in one call; returns 0 (board unchanged)
  when there are no matches.

No gravity or refill yet (P2.2 / P2.3).

tests/clear.sx applies detect->clear to hand-crafted boards (single
horizontal/vertical runs, disjoint runs, an overlapping L/T whose shared cell
clears once, and a no-match checkerboard), snapshots before/after, and asserts
matched cells became holes, non-matched cells are unchanged, and the cleared
count is exact. Locked as tests/expected/clear.{stdout,exit}.
2026-06-04 19:57:08 +03:00
swipelab
4264f5f36f P1.3: adjacent-swap legality (pure sx)
Add swap + legality to the board model:
- swap(board, a, b): in-place, self-inverse cell exchange (trial then revert).
- adjacent(a, b): orthogonal-adjacency predicate (diagonal/gap = false).
- swap_legal(board, a, b): legal iff adjacent AND, after the trial swap, either
  swapped cell participates in a 3+ match (reuses find_matches); leaves the
  board unchanged. Non-adjacent/diagonal rejected before any match check.
- Cell/Swap structs + legal_swaps(board): all currently-legal swaps in a stable
  row-major, right-before-down order; dump_swaps for deterministic snapshotting.

tests/swap_legality.sx asserts the predicate over hand-crafted boards (legal
3-run, no-match, non-adjacent, diagonal, only-the-other-gem-matches) and the
non-mutating revert; locks legal_swaps over the seeded board as a golden.
2026-06-04 19:44:54 +03:00
swipelab
b0c081e397 P1.2: match detection (3+ horizontal/vertical runs)
Add a pure-sx match detector to the board model: `find_matches` walks each
row and column once in maximal same-type spans and marks every cell in a run
of length >= 3 into a `MatchMask` (a per-cell membership set mirroring
Board.cells). Overlapping shapes (L / T where a horizontal and vertical run
share a cell) collapse to the union automatically. `dump_matches` renders the
set deterministically: matched cells show their gem char, others '.'.

Detection only — no clear/collapse/refill (that is P2.1).

tests/match_detect.sx exercises hand-crafted boards (built explicitly on a
run-free checkerboard, no seeded init): a horizontal 3-run, a vertical 3-run,
multiple disjoint runs, length-4 and length-5 runs, intersecting L and T
shapes (shared cell counted once), and a no-match board. Output is locked as
tests/expected/match_detect.stdout (+ .exit) and asserts matched-cell counts.
2026-06-04 19:27:15 +03:00
swipelab
45e7eb803e P1.1: pure-sx Gem & Board model with seeded, match-free init
Add board.sx, the headless Phase-1 match-3 core:
- Gem enum (6 types, ordinal 0..5) + single-char dump alphabet.
- Rng: a 32-bit LCG carried in s64, masked to 32 bits each step, so the
  stream is host-width independent and valid for any seed.
- Board (8x8, row-major) with idx/at/set accessors and a seeded init that
  fills row-major, excluding any gem that would complete a 3-in-a-row with
  the two cells to the left or above — so the result has zero pre-existing
  matches. Single RNG draw per cell, always terminates.
- board_dump: deterministic one-row-per-line textual snapshot.

tests/board_init.sx seeds with a fixed seed, dumps the board, and asserts
zero horizontal/vertical 3-in-a-row runs via an independent scan. Output and
exit code are locked as goldens. App ios-sim build is unaffected (main.sx
does not import the model yet).
2026-06-04 19:12:55 +03:00
swipelab
6d9aee67ba P0.4: wire the logic verification gate (sx assert helper + snapshot runner)
Add a real logic-test gate so future pure-sx game logic fails the build on a
bad assertion:

- tests/test.sx: `expect(cond, msg)` assert helper — prints a greppable
  `FAIL <file>:<line>: <msg>` and exits non-zero via process.exit on failure.
- tools/run_tests.sh: snapshot runner mirroring sx/tests/run_examples.sh; runs
  each tests/<name>.sx and diffs stdout + exit code against tests/expected/.
  Exits 0 iff all tests pass.
- tests/arith.sx (+ expected snapshots): seed passing sanity test.
- README.md: document both halves of the gate — logic runner and the
  reproducible ios-sim build/launch sequence (with device discovery).
2026-06-04 18:57:21 +03:00