Add physics/bounds.clamp_x and physics.settle_all, reject a bounds.y_from_top helper, and fix a real float.clamp/bounce_axis divergence on degenerate bounds
Context
Direct follow-up to decision 0049, same standing instruction: keep growing the Core API with small, research-backed, independently-testable utilities extracted from what glemy/games/tiers.gleam actually duplicates or hand-rolls. A fresh read of tiers.gleam (post-0049) found three candidates. Investigating the first (generalizing the private clamp_preview_x, which used float.clamp(cursor_x, bounds.min.x + radius, bounds.max.x - radius) to keep the drop preview inside the play area) surfaced a real, verified correctness bug in the naive generalization: on a degenerate box (radius larger than half the box's width -- exactly the scenario physics/bounds_test.gleam's own existing degenerate_zero_size_bounds_still_clamps_without_crashing_test already exercises, point_box with radius 1.0, position (10.0, 10.0)), physics/bounds.gleam's own already-shipped bounce/bounce_axis resolves to 4.0 (confirmed: that test already asserts this and passes), while gleam_stdlib's float.clamp resolves the equivalent 1D case to 6.0 instead (verified directly against gleam_stdlib's source, build/packages/gleam_stdlib/src/gleam/float.gleam:80-85: min_bound >=. max_bound triggers a different branch order than bounce_axis's own first-match-wins case). A second candidate (generalizing the shared bounds.max.y -. margin shape behind spawn_y/danger_line_y into a bounds.y_from_top helper) was investigated and rejected.
Options considered
Decision
Added physics/bounds.clamp_x(x, radius, bounds) -> Float (keeps a circle's center inside bounds horizontally, position only, no velocity -- for callers like a drop preview that only have a position to keep in bounds, not a simulated entity that should go through bounce instead) plus a private clamp_axis helper matching bounce_axis's exact edge-resolution order. Added physics.settle_all(model, dt) -> Model, a bulk-apply convenience for the exact map-then-reconstruct shape physics.update already uses internally, mirroring that pattern one level up; deliberately kept separate from update itself (update stays the pure, undamped integrate/bounce/collision composition, per its own existing doc comment -- not reopened here). Rewired games/tiers.gleam: deleted the private clamp_preview_x entirely in favor of calling bounds.clamp_x directly at its one call site (matching how tick already calls cooldown.tick/stopwatch.tick/physics.entity_count with no local wrapper, decision 0049's own style), and replaced the 3-line manual settle map-then-reconstruct with one call to physics.settle_all. No Model field or public signature changed shape.
Verification
gleam test and gleam test --target javascript both pass -- 195 Erlang / 218 JavaScript, exactly the prior baseline (186/209) plus the new tests for clamp_x (6) and settle_all (3), with zero change to any pre-existing test file's count, including test/glemy/games/tiers_test.gleam, test/glemy/game_test.gleam, and test/glemy/games/tiers/rules_test.gleam, none of which needed any edit -- their unmodified passing is the regression proof that the tiers.gleam rewiring was behavior-preserving. deno task check-warnings passes with the baseline completely unchanged (51/51, zero new warnings). deno task browser-check passes for real (183 real requestAnimationFrame frames, a real click-and-hold grew the entity count 4 -> 5, a real entity spawned near the expected world-x, zero page errors).
Consequences
physics/bounds.gleam now offers a second entity-shaped-position utility (clamp_x, alongside bounce) that any future game with a cursor-follow or paddle-style mechanic can reuse directly, with a verified, tested guarantee that it agrees with bounce on degenerate input rather than silently diverging. physics.gleam's settle_all closes the gap between update's own internal composition style and what a caller like games/tiers.tick has to hand-write, continuing the same 'tick reads as composition of named Core primitives' improvement decision 0049 started. The rejected bounds.y_from_top candidate is written down here, not silently dropped, so a future reader with a genuinely different (branching/accumulating) shape to extract from that area doesn't have to rediscover why this specific shape wasn't worth it.