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.
This commit is contained in:
swipelab
2026-06-05 00:32:40 +03:00
parent ea0ba59a23
commit e5df37523f
7 changed files with 188 additions and 5 deletions

View File

@@ -17,6 +17,7 @@
#import "modules/ui/font.sx"; #import "modules/ui/font.sx";
#import "board.sx"; #import "board.sx";
#import "board_layout.sx"; #import "board_layout.sx";
#import "swipe.sx";
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits // Fraction of a cell each gem occupies; the remainder is margin so a gem sits
// inside its cell tile rather than touching the tile's edges. // inside its cell tile rather than touching the tile's edges.
@@ -112,7 +113,8 @@ load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
// Which board cell the player has currently selected, if any. Lives behind a // Which board cell the player has currently selected, if any. Lives behind a
// pointer (heap-allocated in main) because BoardView is a value rebuilt every // pointer (heap-allocated in main) because BoardView is a value rebuilt every
// frame from `build_ui`, so the view itself cannot carry state across frames. // frame from `build_ui`, so the view itself cannot carry state across frames.
// Selection only — P5 turns a selected→adjacent tap into a swap. // A tap toggles this highlight; a swipe commits a swap (see DragInput) and
// clears it.
BoardSelection :: struct { BoardSelection :: struct {
active: bool; active: bool;
cell: Cell; cell: Cell;
@@ -138,10 +140,35 @@ BoardSelection :: struct {
} }
} }
// Tracks an in-progress touch drag between its press and release so a swipe can
// be resolved on lift: the press records the start point, and release maps
// start→end through `swipe_intent` to an adjacent-swap intent. Heap-allocated
// (like BoardSelection) so it survives BoardView's per-frame rebuild between the
// down (touchesBegan → mouse_down) and up (touchesEnded → mouse_up) events.
DragInput :: struct {
active: bool;
start: Point;
init :: (self: *DragInput) {
self.active = false;
self.start = Point.{ x = 0.0, y = 0.0 };
}
begin :: (self: *DragInput, p: Point) {
self.active = true;
self.start = p;
}
clear :: (self: *DragInput) {
self.active = false;
}
}
BoardView :: struct { BoardView :: struct {
board: *Board; board: *Board;
assets: *BoardAssets; assets: *BoardAssets;
sel: *BoardSelection; sel: *BoardSelection;
drag: *DragInput;
safe: EdgeInsets; safe: EdgeInsets;
// Where the grid sits + the touch↔cell mapping. Recomputed each render / // Where the grid sits + the touch↔cell mapping. Recomputed each render /
@@ -209,14 +236,33 @@ impl View for BoardView {
render_hud(ctx, self.board, avail); render_hud(ctx, self.board, avail);
} }
// Touch input. A press records the drag start; the release resolves the
// gesture against the SAME layout it was drawn with. A swipe (start→end maps
// to an adjacent-swap intent) is fed straight into `commit_swap`: a legal
// swap applies, cascades, scores and spends a move, an illegal one reverts —
// either way the next frame re-renders the board + HUD from the model. A
// sub-threshold / off-board drag carries no intent and falls back to the tap
// behaviour: toggle the selection on the pressed cell, or clear it off-board.
handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool { handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool {
self.compute_layout(frame); self.compute_layout(frame);
if event.* == { if event.* == {
case .mouse_down: (d) { case .mouse_down: (d) {
if hit := self.layout.point_to_cell(d.position) { self.drag.begin(d.position);
self.sel.toggle(hit); return true;
} else { }
case .mouse_up: (d) {
if !self.drag.active { return false; }
start := self.drag.start;
self.drag.clear();
if intent := swipe_intent(@self.layout, start, d.position) {
commit_swap(self.board, intent.a, intent.b);
self.sel.clear(); self.sel.clear();
} else {
if hit := self.layout.point_to_cell(start) {
self.sel.toggle(hit);
} else {
self.sel.clear();
}
} }
return true; return true;
} }

BIN
goldens/p5_swap_after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

BIN
goldens/p5_swap_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

10
main.sx
View File

@@ -44,10 +44,15 @@ g_assets : *BoardAssets = null;
// per-frame rebuild; a tap hit-tests a cell and toggles this. // per-frame rebuild; a tap hit-tests a cell and toggles this.
g_sel : *BoardSelection = null; g_sel : *BoardSelection = null;
// In-progress touch drag (P5.2). Heap-allocated for the same reason: the press
// and release that bracket a swipe land on different per-frame BoardView values,
// so the drag start must persist between them.
g_drag : *DragInput = null;
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area // Rebuilt each frame inside the pipeline's arena; carries the current safe-area
// insets so the grid stays inside the notch / home-indicator region. // insets so the grid stays inside the notch / home-indicator region.
build_ui :: () -> View { build_ui :: () -> View {
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, safe = g_safe_insets } BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, safe = g_safe_insets }
} }
frame :: () { frame :: () {
@@ -144,6 +149,9 @@ main :: () -> void {
g_sel = xx context.allocator.alloc(size_of(BoardSelection)); g_sel = xx context.allocator.alloc(size_of(BoardSelection));
g_sel.init(); g_sel.init();
g_drag = xx context.allocator.alloc(size_of(DragInput));
g_drag.init();
g_pipeline.set_body(closure(build_ui)); g_pipeline.set_body(closure(build_ui));
g_plat.run_frame_loop(closure(frame)); g_plat.run_frame_loop(closure(frame));

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,24 @@
== illegal swipe reverts ==
RRPPOGRG
PGPOPRRO
YYBBYRYB
GBYYRGGP
OGBRRORY
BYRRPRBG
YOYYROBB
OROBPPRB
intent (0,0)->(1,0)
legal false awarded 0 score 0 moves_made 0 moves_remaining 30
== legal swipe commits ==
intent (5,4)->(6,4)
legal true depth 1 awarded 30 score 30 moves_made 1 moves_remaining 29
after:
RRPBORRG
PGPPOGRO
YYBOPRYB
GBYBYRGP
OGBYRGOY
BYRRPRBG
YOYYROBB
OROBPPRB
ok: swipe reverts illegal, commits legal

104
tests/swipe_commit.sx Normal file
View File

@@ -0,0 +1,104 @@
// Swipe→commit wiring golden (P5.2): prove the full input-to-model path P5.2
// adds — a touch drag resolved by `swipe_intent` and fed straight into
// `commit_swap` — on the SAME seeded board the iOS app renders (SEED 1337).
// Feeds SYNTHETIC down/up screen positions built from a BoardLayout, resolves
// the swap intent, then commits it, asserting:
// - an ILLEGAL swipe ((0,0)->(1,0): two reds → no match) reverts: the board is
// byte-for-byte unchanged, score stays 0, and no move is spent;
// - a known LEGAL swipe ((5,4)->(6,4): brings R into (5,4), completing R,R,R
// across cols 3-5 of row 4) commits: the board changes, score accrues, and
// exactly one move is spent.
// No rendering, no model reach-around — it calls exactly what BoardView.handle_event
// calls. Links headless like tests/swipe_intent.sx; avoids tests/test.sx because
// its trace.sx pulls in a second `Frame` that collides with the UI one. Failure
// is signalled via a non-zero exit code (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "board.sx";
#import "board_layout.sx";
#import "swipe.sx";
SEED :: 1337;
cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point {
cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() }
}
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
main :: () -> s32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A
// 60px drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
D : f32 = 60.0;
fails : s64 = 0;
// ── ILLEGAL swipe reverts ──────────────────────────────────────────────
// (0,0) and (1,0) are both red on the seed board, so swapping them forms no
// match. The rightward drag must resolve to exactly this pair, and
// commit_swap must reject it — board untouched, score 0, no move spent.
print("== illegal swipe reverts ==\n");
bi : Board = ---;
bi.init(SEED);
before : Board = bi;
out(board_dump(@bi));
a_il := cell_center(@lay, 0, 0);
if s := swipe_intent(@lay, a_il, Point.{ x = a_il.x + D, y = a_il.y }) {
print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row);
if !(s.a.col == 0 and s.a.row == 0 and s.b.col == 1 and s.b.row == 0) { fails += 1; }
mv := commit_swap(@bi, s.a, s.b);
print("legal {} awarded {} score {} moves_made {} moves_remaining {}\n",
mv.legal, mv.cascade.awarded, bi.score, bi.moves_made, mv.moves_remaining);
if mv.legal { fails += 1; }
if !boards_equal(@before, @bi) { fails += 1; }
if bi.score != 0 { fails += 1; }
if bi.moves_made != 0 { fails += 1; }
if mv.moves_remaining != bi.move_limit { fails += 1; }
} else {
print("intent none\n");
fails += 1;
}
// ── LEGAL swipe commits ────────────────────────────────────────────────
// (5,4)->(6,4): the rightward swipe brings R into (5,4), completing R,R,R
// across cols 3-5 of row 4. commit_swap applies it, resolves the cascade
// (score accrues into Board.score) and spends one move; the board changes.
print("== legal swipe commits ==\n");
bl : Board = ---;
bl.init(SEED);
pre : Board = bl;
a_le := cell_center(@lay, 5, 4);
if s := swipe_intent(@lay, a_le, Point.{ x = a_le.x + D, y = a_le.y }) {
print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row);
if !(s.a.col == 5 and s.a.row == 4 and s.b.col == 6 and s.b.row == 4) { fails += 1; }
mv := commit_swap(@bl, s.a, s.b);
print("legal {} depth {} awarded {} score {} moves_made {} moves_remaining {}\n",
mv.legal, mv.cascade.depth, mv.cascade.awarded, bl.score, bl.moves_made, mv.moves_remaining);
if !mv.legal { fails += 1; }
if boards_equal(@pre, @bl) { fails += 1; }
if !(bl.score > 0) { fails += 1; }
if bl.moves_made != 1 { fails += 1; }
if mv.moves_remaining != bl.move_limit - 1 { fails += 1; }
out("after:\n");
out(board_dump(@bl));
} else {
print("intent none\n");
fails += 1;
}
if fails == 0 {
print("ok: swipe reverts illegal, commits legal\n");
return 0;
}
print("FAIL: {} swipe-commit checks failed\n", fails);
return 1;
}