18 Commits

Author SHA1 Message Date
swipelab
39740a1d36 migrate to sx cstring era: std env() replaces local getenv/strlen, alloc_string rename
sx 1d17b0a reserves 'cstring' as the C-boundary string type and renames
std's cstring(size) allocator to alloc_string; std getenv is now
(cstring) -> ?cstring, so the local conflicting binding (caught by the
new same-symbol diagnostic) and its strlen/copy loop collapse into a
process.env delegation. iOS-sim build + 22/22 snapshots green.
2026-06-12 14:57:59 +03:00
swipelab
bb728d0ab0 migrate restart to opt-in UFCS (sx a47ea14)
Free function restart(board, seed) is dot-called from main.sx and
board_view.sx; the sx opt-in UFCS change gates plain functions out of
dot-dispatch, so declare it ufcs. ios-sim build green, 23/23 logic
tests.
2026-06-12 09:37:35 +03:00
swipelab
6f7d2f4db2 lang migration: rename signed integer types sN -> iN
Mechanical sweep of all .sx sources, plan docs, and tests/expected
snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64).
Verified: tools/run_tests.sh 23/23.

Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors
from the sx opt-in UFCS change (sx a47ea14) — independent of this
rename (present pre-sweep); migrated in the follow-up commit.
2026-06-12 09:36:51 +03:00
swipelab
a7b41ccbca migrate to the new for-loop syntax
Drop the ':' before captures (for xs (x) / for 0..n (i)); the index
capture becomes the trailing open range (for xs, 0.. (x, i)). 136
headers across 26 files, mechanical.

Five headless tests (banner_layout, hit_test, swipe_commit,
swipe_intent, swipe_reshuffle) also gain a direct
#import "modules/ui/types.sx" — they named Point/Frame through a
transitive import, which bare visibility no longer permits.

Gates: sx build --target ios-sim main.sx links; tools/run_tests.sh
23/23.
2026-06-10 20:39:59 +03:00
swipelab
cd89a5c9c0 FX2: wire no-moves reshuffle into the UI swipe-commit path
The rendered swipe-commit path (`plan_and_commit`) bypassed the turn-loop's
no-moves rule: a deadlocked board (no legal swap) stayed stuck on screen because
only `play_turn` checked `!has_legal_swap` and reshuffled, and the UI never calls
`play_turn`.

Factor the post-settle "no legal swaps -> reshuffle" check into a shared
`reshuffle_if_deadlocked` in board.sx and call it from BOTH `play_turn` and
`plan_and_commit`, so the animated UI commit obeys the identical model rule. The
reshuffle runs after the cascade settles (post-`commit_swap`); the AnimMove's
recorded `final` stays the settled pre-reshuffle board, so the cascade animation,
per-round audio, and input gating are unchanged — the reshuffled layout renders on
the next settled frame. No win/lose/turn-accounting change; a reshuffle spends no
move and no score.

Regression test tests/swipe_reshuffle.sx drives the exact UI path (swipe_intent ->
plan_and_commit) on the deadlocked board from tests/level.sx: before = no legal
swaps / in_progress; after = reshuffled (has_legal_swap true, 9 legal swaps, no
immediate match), score/moves/budget unchanged. It FAILS pre-fix (board stays
stuck, has_legal_swap false) and PASSES post-fix.
2026-06-06 14:55:38 +03:00
swipelab
e77c470546 P7.1: freeze finished level — reject moves after won/lost
play_turn now checks level_status before committing: a won or lost
level rejects the swap (accepted=false) with no move spent and no
score change, until restart returns it to in_progress. Adds an
accepted flag to TurnResult so the renderer can show the move was
ignored. Regression in tests/level.sx asserts post-won and post-lost
play_turn leaves score/moves/status unchanged and that restart
re-enables play.
2026-06-05 08:37:28 +03:00
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
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