// Per-gem animation determinism guard (P6.3): prove the idle/select/land/clear // poses are PURELY VISUAL pure functions of the animation clock, and — most // importantly — that at clock t==0 EVERY gem's idle pose is EXACTLY the resting // sprite. That t==0-rest invariant is what lets the pre-P6.3 goldens (p4_board, // p4_hud, …) reproduce under the deterministic capture (M3TE_ANIM_TIME=0): if the // idle ramp were dropped, gems would already be deformed at t=0 and every prior // golden would churn. It also pins the reactions to rest at their window ends and // bounds the idle amplitude so the polish stays tasteful. // No rendering — pure math over gem_anim.sx. Failure is a non-zero exit code. #import "modules/std.sx"; #import "modules/math"; #import "board.sx"; #import "gem_anim.sx"; // Local f32 abs with explicit 0.0 literals: the stdlib generic `abs` mis-types // its untyped `0` literals under f32 and returns 0.0 for every f32 input. The // shipped game never calls abs; only this test needs it, so it rolls its own. fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x } approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 } main :: () -> s32 { fails : s64 = 0; // 1. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant). print("== idle t=0 is rest for all cells ==\n"); rest_ok := true; for 0..BOARD_ROWS: (row) { for 0..BOARD_COLS: (col) { p := idle_pose(0.0, col, row); if !(p.scale_x == 1.0 and p.scale_y == 1.0 and p.dx == 0.0 and p.dy == 0.0) { rest_ok = false; } } } print("idle_t0_rest {}\n", rest_ok); if !rest_ok { fails += 1; } // 2. Past the ramp the idle actually moves, but stays tasteful (bounded). print("== idle mid-phase deforms, bounded ==\n"); moved := false; bounded := true; for 0..BOARD_ROWS: (row) { for 0..BOARD_COLS: (col) { p := idle_pose(0.6, col, row); if fabs(p.scale_x - 1.0) > 0.0005 { moved = true; } if fabs(p.scale_x - 1.0) > 0.05 { bounded = false; } if fabs(p.dy) > 0.05 { bounded = false; } } } print("idle_mid_moves {} idle_bounded {}\n", moved, bounded); if !moved { fails += 1; } if !bounded { fails += 1; } // 3. Select pop: rest at both window ends, a pop in the middle. print("== select pop envelope ==\n"); s_start := approx(select_pop_scale(0.0), 1.0); s_end := approx(select_pop_scale(SELECT_DUR), 1.0); s_mid := select_pop_scale(SELECT_DUR * 0.5) > 1.05; print("select_start_rest {} select_end_rest {} select_mid_pops {}\n", s_start, s_end, s_mid); if !s_start { fails += 1; } if !s_end { fails += 1; } if !s_mid { fails += 1; } // 4. Land squash: rest at both window ends, a wobble just after impact. print("== land squash envelope ==\n"); l_start := approx(land_squash(0.0), 0.0); l_end := approx(land_squash(LAND_DUR), 0.0); l_mid := fabs(land_squash(LAND_DUR * 0.12)) > 0.01; print("land_start_rest {} land_end_rest {} land_mid_wobbles {}\n", l_start, l_end, l_mid); if !l_start { fails += 1; } if !l_end { fails += 1; } if !l_mid { fails += 1; } // 5. Clear pop: full at t=0, gone at t=1, overshoots above 1 in between. print("== clear pop envelope ==\n"); c_start := approx(clear_pop_scale(0.0), 1.0); c_end := approx(clear_pop_scale(1.0), 0.0); c_peak := clear_pop_scale(0.30) > 1.1; print("clear_start_full {} clear_end_gone {} clear_overshoots {}\n", c_start, c_end, c_peak); if !c_start { fails += 1; } if !c_end { fails += 1; } if !c_peak { fails += 1; } // 6. GemMotion land bookkeeping: fresh state is unpinned at t=0, a never- // landed cell rests, and a freshly-stamped land reads age 0. print("== gem motion land bookkeeping ==\n"); m : GemMotion = ---; m.init(); init_ok := m.clock == 0.0 and !m.pinned; no_land := land_squash(m.land_local(0)) == 0.0; m.clock = 2.0; m.stamp_land(10); fresh_land := approx(m.land_local(10), 0.0); print("motion_init {} motion_no_land {} motion_fresh_land {}\n", init_ok, no_land, fresh_land); if !init_ok { fails += 1; } if !no_land { fails += 1; } if !fresh_land { fails += 1; } // 7. Restart resets the landing bounce. The restart button (BoardView. // do_restart) reseeds the model AND must clear the per-gem landing state, // or a restart fired right after a terminal cascade carries that move's // squash onto the freshly seeded board. reset_landings is the factored // reset do_restart calls: a cell stamped a moment ago is mid-squash, and // after reset_landings every cell is back at rest — while the idle clock // is deliberately left running (idle resumes from its normal phase). print("== gem motion restart resets landings ==\n"); r : GemMotion = ---; r.init(); r.clock = 5.0; r.stamp_land(3); r.stamp_land(42); r.clock = 5.08; // 0.08s past impact: well inside LAND_DUR, so mid-squash. pre_squashing := fabs(land_squash(r.land_local(3))) > 0.01 and fabs(land_squash(r.land_local(42))) > 0.01; r.reset_landings(); post_rest := land_squash(r.land_local(3)) == 0.0 and land_squash(r.land_local(42)) == 0.0 and land_squash(r.land_local(0)) == 0.0; clock_kept := r.clock == 5.08; print("restart_pre_squashing {} restart_post_rest {} restart_clock_kept {}\n", pre_squashing, post_rest, clock_kept); if !pre_squashing { fails += 1; } if !post_rest { fails += 1; } if !clock_kept { fails += 1; } if fails == 0 { print("ok: per-gem animation rests at t=0 and stays bounded\n"); return 0; } print("FAIL: {} gem-anim checks failed\n", fails); return 1; }