fixstep source

← fixstep · API reference · version 0.1.4

7 file(s), 1711 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
ease_fraction fn58FIX-01275 113 332
approach fn74FIX-012316 318
wrap_deg fn79—113 353
wrap_delta fn97FIX-013113 341
approach_angle fn112FIX-013346
BANK_RATE_SCALE const108—118 145 157
BANK_WHOLE const118—none in this package
Bank struct128—135 136 180 209 221 232 … (8 total)
bank_new fn135—232 245 258
bank_whole fn144—234 260
bank_rate fn155—233 259
bank_gain fn180FIX-009238 248
bank_most fn198FIX-010262 266
bank_progress fn209—267
bank_fraction fn221—none in this package
bank_restore fn232—242
bank_restore_fraction fn239—none in this package
bank_reset fn253—none in this package
CLOCK_UNITS_PER_SECOND const154—118 261 278
TickClock struct162—221 222 309 316 321 332 … (16 total)
clock_new fn221—33 44 56 69 70 82 … (14 total)
clock_units_from_micros fn234—none in this package
clock_seconds_from_units fn260—none in this package
clock_units_from_seconds fn276—none in this package
clock_set_rate fn309FIX-007188 195 199
clock_rate_num fn316—none in this package
clock_rate_den fn321—17
clock_advance fn362FIX-001395 433 576 37 45 62 … (18 total)
clock_advance_capped fn393FIX-004111
clock_pump fn421FIX-00387 89 90 94 95
clock_step fn465FIX-00258 64 72 193
clock_step_units fn470—576
clock_banked fn482FIX-00663 65 66 155 168 170
clock_restore fn492—165
clock_alpha fn537FIX-005135
clock_drive fn574FIX-008218
Timer struct120—132 133 141 143 156 163 … (15 total)
timer_new fn132—142 278
timer_armed fn141—none in this package
timer_arm fn156FIX-011279 295
timer_running fn163—284 298 11
timer_left fn173—none in this package
timer_spent fn184—none in this package
timer_total fn190—none in this package
timer_fraction fn202—none in this package
timer_spend fn224FIX-011286 300
timer_cancel fn241—253 265 266
timer_restore_left fn252—none in this package
timer_restore_spent fn264—none in this package

src/ease.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The ease — a frame-rate-independent approach, and its short-way angle.
//
// ── ⚠⚠ THE ONE THING IN THIS PACKAGE THAT IS OUTSIDE ITS INVARIANT
//
// `fixstep` § THE INVARIANT says every duration is an exact integer and
// floating point appears only where something is DRAWN.  This module is
// the *where something is drawn* half, and it is float by nature: it is
// a PRESENTATION rate over real frame time, not a simulation step.
//
// ⚠ It is in the package anyway, and the reason is the package's own
// admission test — *could a consumer be tempted to hand-roll this?*
// **Both** loft games did, and **one of them got it wrong**:
//
//     // game A
//     fn cam_approach(now, want, k, dt) { now + (want - now) * (1 - exp(-k*dt)) }
//     // game B — same name, same signature, different arithmetic
//     fn cam_approach(now, want, k, dt) { f = k*dt; if f > 1 { f = 1 } … }
//
// ── ⚠⚠ AND `k · dt` IS FRAME-RATE DEPENDENT, WHICH IS THE WHOLE POINT
//
// The linear form settles somewhere different depending on how the
// frames happened to be cut, and that is not a rounding difference —
// measured, easing 0 → 1 over one second:
//
//     linear       one frame of 1 s  1.0           sixty of 1/60 s  0.9982029897
//     exponential  one frame of 1 s  0.9975212478  sixty of 1/60 s  0.9975212478
//
// — **identical to the bit**, because `1 − e^(−k·dt)` COMPOSES: sixty
// factors of `e^(−k/60)` are one factor of `e^(−k)`.  ⚠ That is an exact
// property of the function rather than a tolerance, which is what lets a
// gate photograph a camera after N seconds without asking how the frames
// were cut — the same property `clock_advance` has one family over, and
// the reason both belong in one package.
//
// ⚠ A game at a locked frame rate can use the linear form and never
// notice.  A game that ships to a browser cannot.
//
// ── ⚠ WHAT IS NOT HERE ──────────────────────────────────────────
//
// **No rest tolerance, and no snap.**  An exponential approach is
// asymptotic, so a consumer that needs *arrived* must decide what that
// means and write the value exactly — otherwise where it stopped is a
// function of how the frames fell.  That is a per-valve decision (a
// camera's position, an angle and a zoom do not share an epsilon), so
// this answers the step and the consumer owns the boundary.
//
// **No rate constant.**  `k` is tuning, and tuning belongs to the game.

// What one frame of `dt` closes of a gap, in `[0, 1)`.
//
// ⚠ Zero for a frame with no time in it — which is what a PAUSED
// consumer hands down, and it must not creep.
//
// Example: @FIX-012
pub fn ease_fraction(k: float, dt: float) -> float {
    if dt <= 0.0 { return 0.0; }
    1.0 - exp(-(k * dt))
}

// Move `now` toward `want` by one frame's worth.
//
// ⚠ **`k` is the gap fraction closed per SECOND**, so it is a rate and
// not a step: an approach chasing something moving at `v` settles at a
// steady lag of `v / k`.  ⚠⚠ That derivation assumes a CONTINUOUS
// target — against one that stands still and then jumps a whole step,
// the real excursion is the step, not `v / k`, and a consumer that
// sized `k` on the formula will be out by the ratio of the two
// (measured at **3.7x** in this package's first consumer).
//
// Example: @FIX-012
pub fn approach(now: float, want: float, k: float, dt: float) -> float {
    now + (want - now) * ease_fraction(k, dt)
}

// An angle in degrees, folded into `[0, 360)`.
pub fn wrap_deg(a: float) -> float {
    wd = a % 360.0;
    if wd < 0.0 { wd = wd + 360.0; }
    wd
}

// A DIFFERENCE of angles, folded into `(−180, 180]` — the short way
// round.
//
// ⚠⚠ **This is not defensive tidying, and a real key set reaches the
// wrong-way swing.**  In this package's first consumer, holding one key
// gives a bearing of 360° and adding a second gives 60°.  The true turn
// is 60°; eased as plain numbers it is **−300°**, and the camera spins
// five sixths of a circle the wrong way on one extra keypress — while
// looking like a working camera the whole time, which is why it needs a
// test rather than an eye.
//
// Example: @FIX-013
pub fn wrap_delta(d: float) -> float {
    wl = d % 360.0;
    if wl > 180.0   { wl = wl - 360.0; }
    if wl <= -180.0 { wl = wl + 360.0; }
    wl
}

// Move an ANGLE toward another by one frame's worth, the short way.
//
// ⚠ A separate door from `approach` rather than a flag on it: an angle
// is not a number that happens to wrap, and a consumer that eased a
// bearing with `approach` gets a camera that is correct at every angle
// except across the seam.
//
// Example: @FIX-013
pub fn approach_angle(now: float, want: float, k: float, dt: float) -> float {
    wrap_deg(now + wrap_delta(want - now) * ease_fraction(k, dt))
}

src/fixstep.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// fixstep — a fixed simulation step, in exact integer time.
//
// ── ⚠⚠ WHY THIS PACKAGE EXISTS, COUNTED RATHER THAN ARGUED ──────
//
// Before it, two loft games had **nine** independent implementations of
// *do not lose a fraction* between them, and one of them was wrong in a
// way no test in either repo could see:
//
//   * dryopea had **seven** — a frame accumulator, three movers, three
//     one-shot timers — each with its own epsilon or, in the case of the
//     PLAYER, with none and none correct.  Measured over one simulated
//     minute at seven tick lengths, the player covered
//     **180 / 120 / 180 / 0 / 0 / 0 / 0** hexes against a true 180, and
//     stopped moving entirely under a 250 ms tick.
//   * moros had **two** more (a page backlog cap and a server rate
//     scaler) plus a `TICK_US` of its own, having already learned the
//     same lesson one unit up: it integrated `steps × TICK_US` in a
//     single pass and got machine-dependent trajectories.
//
// ⚠ **Every one of them existed because there was nothing to consume.**
// So the admission test for this package is not a size budget, it is the
// opposite: *could a consumer be tempted to hand-roll this?*  If yes it
// belongs in — built once, tested once, and never argued about again.
//
// ── THE INVARIANT ───────────────────────────────────────────────
//
// > **Every DURATION is an exact integer count of one chosen base unit,
// > and every RATE is consumed by an integer accumulator that carries
// > its remainder.  Floating point appears only where something is
// > DRAWN.**
//
// That is falsifiable: a site that keeps a float duration, or that drops
// a remainder, breaks it.  ⚠ `approach` is the one thing inside the
// package and outside the invariant, and it says so itself.
//
// ── THREE TYPES, AND PICKING THE WRONG ONE IS SILENT ────────────
//
// | you have | you want | why not the others |
// |---|---|---|
// | wall time, and a simulation to step | `TickClock` | — |
// | a rate, and whole units to spend | `Bank` | a bank's remainder is load-bearing FOR EVER |
// | a duration that fires ONCE | `Timer` | a one-shot's remainder dies at its boundary |
//
// ⚠⚠ **`Timer` is not a `Bank` and must not become one**, and the
// refutation is measured rather than asserted (`@FIX-011`): a one-shot
// built on `bank_gain` fires a SECOND time with nobody re-arming it, and
// carries its residue into the next arming — a 5 s cooldown costs 8
// ticks the first time and **7** the second.
//
// ── THE TWO RULES UNDER ALL OF IT ───────────────────────────────
//
// 1. **No callbacks and no inversion of control.**  Nothing here calls
//    your tick; it answers *how many*.  That is what lets one clock
//    serve a frame loop, a server pump, a script runner and a test.
//    ⚠ It is also what makes the surface affordable to grow: a new door
//    is a function a caller may ignore, never a shape every caller must
//    adopt.
// 2. **Defaults mean *the behaviour you already had*.**
//    `clock_new(step)` takes one argument, and a clock nobody configures
//    is uncapped, unscaled and carries its remainder.
//
// ── ⚠ A DOOR PER USE CASE, so flexibility costs nothing at the call
//    site ──────────────────────────────────────────────────────────
//
// The surface grows in DOORS, never in parameters — one function per way
// a game drives a clock, each with the arguments that case actually has
// and no others.  **Every row has a test named for its case, and the
// test IS the example**: a snippet in prose rots because nothing
// compiles it, and a test nobody links to is invisible, which is how a
// consumer ends up rebuilding what already exists.
//
// | the case | the door | the example |
// |---|---|---|
// | a frame loop (GL, browser) | `clock_advance(clk, elapsed)` | `@FIX-001` |
// | a script, a test, a turn commit | `clock_step(clk, n)` | `@FIX-002` |
// | a server pump owning the wall | `clock_pump(clk, now)` | `@FIX-003` |
// | a loop that may be BACKGROUNDED | `clock_advance_capped(clk, dt, max)` | `@FIX-004` |
// | drawing between two steps | `clock_alpha(clk)` | `@FIX-005` |
// | a rollback re-simulation | `clock_restore` + `clock_step` | `@FIX-006` |
// | pause, slow-mo, fast-forward | `clock_set_rate(clk, num, den)` | `@FIX-007` |
// | a slow layer over a fast one | `clock_drive(slow, fast, n)` | `@FIX-008` |
// | a mover spending a rate | `bank_gain(b, rate, elapsed, whole)` | `@FIX-009` |
// | asking without spending | `bank_most(rate, elapsed, whole)` | `@FIX-010` |
// | a cooldown, a countdown, a lull | `timer_arm` + `timer_spend` | `@FIX-011` |
// | an eased camera or value | `approach(now, want, k, dt)` | `@FIX-012` |
//
// ⚠ **Nothing here is a mode flag on another door.**  A count and a
// duration look like one question with a switch, and they are not:
// spelling a count as `n × step` in FLOAT seconds is wrong for **602 of
// the first 1000 `n`**, which is the measurement this package's first
// consumer made one level up before this package existed.
//
// ⚠ **The doors are THIN and share ONE accumulator.**  Five entry points
// over five copies of the arithmetic would be the nine-sites problem
// wearing a library's clothes; five over one is the point.
//
// ── ⚠ WHAT IS DELIBERATELY NOT HERE ─────────────────────────────
//
// **No tick BODY.**  What one step DOES is the game's — one consumer
// walks a continuous person, the other steps a lattice roster.
//
// **No netcode, no rollback, no snapshots.**  Rollback rewinds GAME
// STATE, which this cannot hold without owning the game.  What it owns
// is the timing half exactly: `clock_banked` / `clock_restore` plus an
// exact `clock_step` re-runs N steps identically.  ⚠ The line is *this
// owns everything a game would rebuild about TIME*, not everything a
// game would rebuild.
//
// **No interpolation POLICY.**  `clock_alpha` answers *where between two
// steps are we*; it does not answer *what should be drawn there*.  Its
// first consumer measured three answers and shipped none, because their
// prices sit on three different axes and which one wins is a function of
// how long the step is.  See `clock_alpha`.
//
// **No tick COUNTER.**  A game already counts its own ticks, and a
// second counter beside it is two facts that can disagree.

pub use tick_clock::*;
pub use tick_bank::*;
pub use tick_timer::*;
pub use ease::*;

src/tick_bank.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// A rate consumed in whole units, in exact integer arithmetic —
// plan 26 phase L2.
//
// ── What this replaces, and why there were three of it ───────────
//
// `plans/26` § 2 counted **seven** independent sites in dryopea that
// re-assert *do not lose a fraction*, and found that omitting it is
// SILENT.  Three of them were the same arithmetic written three times:
//
//     progress += speed * tick_seconds;
//     whole     = (progress + EPSILON) as integer;
//     progress -= whole;
//
// in `spawn.loft::enemy_bank`, `helper.loft::helper_bank` — and NOT in
// `vehicle.loft`, which truncated and carried nothing.  That omission
// is `@D003`: measured over one simulated minute at seven tick
// lengths, every banked mover held its rate and the player read
// **180 / 120 / 180 / 0 / 0 / 0 / 0** hexes against a true 180
// (`@M030`).  A fourth site was needed and nobody wrote it, because
// there was nothing to consume.
//
// ⚠ **The epsilon was load-bearing and is now GONE.**  `@M017`
// measured that zeroing `ENEMY_PROGRESS_EPSILON` turned 7 of 1149
// tests red and `scripts/validate.sh` with them — three ticks of
// `2.5 * (1.0/1.5)` sum to 4.999999999999999, so the fifth hex existed
// only because the constant released it.  Integer arithmetic has no
// such hair: five thirds of a tick is `10 000 000 / 3 000 000` exactly
// and the fifth hex is simply there.  **An epsilon is what you need
// when the arithmetic cannot state the answer**; this file states it.
//
// ── ⚠⚠ THE RATE IS SCALED AND THE TIME IS NOT ───────────────────
//
// Two quantities meet here and only one of them can be exact for free.
//
//   * **Time** is already an exact integer count of the clock's base
//     unit (`tick_clock.loft` § THE BASE UNIT IS A CHOICE — 1/3 µs, so
//     that dryopea's 2/3-second tick is a whole number).  It arrives
//     exact and leaves exact.
//   * **A rate** is an authored design number in `numbers.json`
//     (`1.0`, `1.5`, `2.5`, `3.0`, `6.0` hex/s) and reaches the mover
//     as a `float`, because `@X061` puts the lookup at the CALL SITE
//     so that *a damaged robot moves slower* can change it within a
//     life.  So it is QUANTISED, once, at the door: `bank_rate` rounds
//     to millionths of a unit per second.
//
// ⚠ **That quantisation is a tolerance on the RATE and never on the
// CARRY**, which is the whole distinction the epsilon used to blur.
// Every speed dryopea authors is exact in millionths; a rate that is
// not lands on the nearest millionth and is then consumed exactly, for
// ever, with no drift — where the float version drifted against a rate
// it could state perfectly.
//
// ⚠ **Why not carry the reciprocal instead** — "base time units per
// whole unit", which would make this file `clock_advance` with a
// variable step and need no scale at all.  Because it is *inexact for
// the rates this game already sweeps*: 3 000 000 / 2.25 is
// 1 333 333.33, and `@M013`'s speed sweep includes 2.25.  A rate
// scaled UP is exact for every authored number; a rate inverted is not.
// That asymmetry is the reason `Bank` is not `TickClock` wearing a
// different name.
//
// ── ⚠ WHAT THE BANK HOLDS, AND WHAT IT DOES NOT ─────────────────
//
// It holds **the carry and nothing else** — one integer.  In
// particular it does NOT hold:
//
//   * **the rate**, because `@X061` says a rate is a property of the
//     mover's CONDITION rather than of its class, so it arrives per
//     call;
//   * **`whole`** — how much banked progress makes one unit — because
//     that is a property of the CLOCK, there is one clock, and a
//     `Bank` field that carried it would make every partial struct
//     literal a silent trap.  ⚠ loft defaults an omitted field
//     silently ([loft#914]) and dryopea builds `Enemy` from partial
//     literals in a dozen places; a defaulted `whole` of 0 is a mover
//     that never moves.  A defaulted CARRY of 0 is a fresh bank, which
//     is exactly right.
//
// ⚠ So `Bank { }` is correct-neutral, and that is a deliberate choice
// paid for with one extra argument on three call sites inside `src/`.
// No test ever writes `whole`.
//
// ── ⚠ IT IS NOT A TIMER, AND THE TWO MUST NOT SHARE A PRIMITIVE ──
//
// A bank's remainder is load-bearing for ever: a scout that banked
// two-thirds of a hex is two-thirds of a hex closer, permanently.  A
// one-shot timer's remainder dies at its boundary — `helper_recover_
// tick`'s 90 ticks and `tower_repair_tick`'s 20 s fire once and the
// residue means nothing afterwards.  `plans/26` § The invariant keeps
// them apart as ARITHMETIC, and L3 builds `Timer` beside this rather
// than on top of it.  ⚠ One clever type that "handles both" is the
// over-unification this plan was written to avoid.

use tick_clock::*;

// How finely a rate is quantised: millionths of a whole unit per
// second.
//
// ⚠ **A tolerance on the RATE, and the only approximation in this
// file.**  Every speed `numbers.json` authors — 1.0, 1.5, 2.5, 3.0,
// 6.0 hex/s — is an exact multiple of a millionth, so today it is not
// an approximation at all.  It becomes one the day a design number
// needs a seventh decimal, and then it is a rate 1e-6 off consumed
// exactly, rather than the right rate consumed badly.
pub const BANK_RATE_SCALE: integer = 1000000;

// How much banked progress makes ONE whole unit, for a bank driven by
// dryopea's clock.
//
// ⚠ **The CONSUMER's constant, exactly as `CLOCK_UNITS_PER_SECOND`
// is.**  It is `units per second × rate scale` because progress
// accumulates `rate × elapsed`: a rate quoted per second, multiplied
// by a time quoted in base units, has both scales in it.  A game whose
// clock counts something else derives its own with `bank_whole`.
pub const BANK_WHOLE: integer = CLOCK_UNITS_PER_SECOND * BANK_RATE_SCALE;

// Progress banked toward the next whole unit.
//
// ⚠ **One field, and the struct exists so the caller cannot forget to
// write the remainder back.**  loft has no by-reference integer, so a
// free function over an `integer` would have to answer a pair and
// leave the carry to the call site — which is the seven-sites problem
// wearing a library's clothes.  Passed as a parameter it aliases, so
// `bank_gain` updates it in place and answers only the whole units.
pub struct Bank {
    // Always in `[0, whole)` between calls.  Zero is a fresh bank,
    // which is what makes `Bank { }` correct — see the header.
    progress: integer,
}

// A bank with nothing carried.
pub fn bank_new() -> Bank {
    Bank { progress: 0 }
}

// How much banked progress makes one whole unit, for a clock counting
// `units_per_second` base units a second.
//
// ⚠ dryopea's answer is the `BANK_WHOLE` constant above; this is the
// derivation, for a consumer whose clock counts something else.
pub fn bank_whole(units_per_second: integer) -> integer {
    max(units_per_second, 1) * BANK_RATE_SCALE
}

// Quantise a rate quoted in whole units per second.
//
// ⚠ **It ROUNDS rather than truncating**, and the difference is
// visible at every rate that is not a whole millionth: truncating
// would make a mover systematically slower, which is the shape of
// defect this whole plan exists to remove.  ⚠ Truncation is also what
// `@D003` was — the same mistake one unit further out.
pub fn bank_rate(per_second: float) -> integer {
    if per_second <= 0.0 { return 0; }
    br_s = per_second * (BANK_RATE_SCALE as float) + 0.5;
    br_s as integer? ?? 0
}

// Gain ground at `rate` for `elapsed` base time units, and answer the
// whole units it releases.  The remainder stays banked.
//
// ⚠ **This is the one implementation of *do not lose a fraction* in
// dryopea**, and it is exact: `progress` is an integer, `rate ×
// elapsed` is an integer, and a whole unit costs an integer, so there
// is nothing to round and no epsilon to get the direction of wrong
// (`CLAUDE.md` § Timers and epsilons: *neither direction is safe*).
//
// ⚠ **A rate of zero banks nothing and keeps what is banked** — a
// mover that stops has not forgotten how far it had come.  Same for a
// non-positive `elapsed`: `tick_clock.loft` gives the reason time
// going backwards must be dropped rather than owed.
//
// ⚠ **What it does NOT do is decide whether the mover may move.**
// `enemy_tick` asks the pre-walk window first, `helper_tick` asks
// whether the crew member is alive; a bank that knew about standing
// would have to know about every other reason not to move too.
// Example: @FIX-009
pub fn bank_gain(b: Bank, rate: integer, elapsed: integer,
                 whole: integer) -> integer {
    if rate <= 0 || elapsed <= 0 || whole <= 0 { return 0; }
    b.progress = b.progress + rate * elapsed;
    bg_units   = (b.progress / whole) ?? 0;
    b.progress = b.progress - bg_units * whole;
    bg_units
}

// The MOST one step at `rate` over `elapsed` could release — the
// ceiling, carry included, touching no bank.
//
// ⚠ **An upper bound is a different question from a release**, and
// conflating them is what a caller does when it has no bank to spend.
// `play.loft::play_steer_reach` is the case: it asks how far ahead a
// held key should point, which must exceed anything one tick can
// cover, and it must not consume the player's carry to find out.
// Example: @FIX-010
pub fn bank_most(rate: integer, elapsed: integer, whole: integer) -> integer {
    if rate <= 0 || elapsed <= 0 || whole <= 0 { return 0; }
    ((rate * elapsed + whole - 1) / whole) ?? 0
}

// What is banked, in raw progress.
//
// ⚠ **Readable so a run can be COMPARED and RESTORED** —
// `compare.loft::state_diff` reads it as part of a mover's state, and
// it is an integer now, so the comparison is exact without the
// "floats EXACTLY" argument that file had to make.
pub fn bank_progress(b: const Bank) -> integer {
    b.progress
}

// What is banked, as a fraction of a whole unit.
//
// ⚠ **The AUTHORING door, and the one place a float appears** —
// `emit.loft` writes a captured situation as `.keys` text and
// `script.loft` reads it back, and *hexes* is the unit a person
// authors in.  The round trip is exact because a double carries 53
// bits and `BANK_WHOLE` needs 42: `bank_restore_fraction` recovers the
// integer this answered.
pub fn bank_fraction(b: const Bank, whole: integer) -> float {
    if whole <= 0 { return 0.0; }
    ((b.progress as float) / (whole as float)) ?? 0.0
}

// Put a bank back where it was, in raw progress.
//
// ⚠ Clamped into `[0, whole)` rather than trusted, for the reason
// `clock_restore` clamps: a carry at or past a whole unit is a state
// `bank_gain` can never leave behind, and accepting one would release
// a unit nothing paid for.
pub fn bank_restore(b: Bank, progress: integer, whole: integer) {
    if progress <= 0 || whole <= 0 { b.progress = 0; return; }
    b.progress = (progress % whole) ?? 0;
}

// Put a bank back from a fraction of a whole unit — `bank_fraction`'s
// other half.
pub fn bank_restore_fraction(b: Bank, units: float, whole: integer) {
    if units <= 0.0 || whole <= 0 { b.progress = 0; return; }
    brf_p = units * (whole as float) + 0.5;
    bank_restore(b, brf_p as integer? ?? 0, whole);
}

// Throw the carry away.
//
// ⚠ **Deliberately a door of its own rather than `bank_restore(b, 0)`,
// because it is a DECISION.**  A helper that comes back from recovery
// is a repaired vehicle rather than the wreck that was carried in, so
// its banked ground goes with the damage; an enemy that is merely
// blocked keeps everything.  Two behaviours, and the one that discards
// says so by name.
pub fn bank_reset(b: Bank) {
    b.progress = 0;
}

src/tick_clock.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The fixed step, in exact integer time — plan 26 phase L1.
//
// ── What this replaces, and why it is not a tidy-up ──────────────
//
// `play.loft` banked elapsed time as a FLOAT and spent it one tick at
// a time:
//
//     ps.banked += seconds;
//     while ps.banked >= TICK_SECONDS { ps.banked -= TICK_SECONDS; … }
//
// That is correct in the property it was built for — the remainder
// CARRIES, so 1200 frames of 1/60 s and one frame of 20 s reach the
// same state — and wrong in one it was not: **a subtraction chain over
// a repeating fraction loses the last tick**, for 602 of the first
// 1000 counts (`tests/19_p1` § A count asked for in seconds comes back
// short).  Plan 19 P1 worked around it by refusing to spell a count as
// a duration; this file removes the arithmetic that made the
// workaround necessary.
//
// ⚠ **An integer accumulator has no rounding to carry**, so the
// identity is exact by construction rather than by argument:
//
//     clock_advance(clk, n * step) == clock_step(clk, n)   for all n
//
// and `tests/26_l1_the_clock.loft` sweeps it over 1..100000 with the
// float path beside it as the control — because a gate that only
// showed integers agreeing with integers would be measuring nothing
// (`@M022`'s lesson: *can this gate produce a non-trivial reading at
// all?*).
//
// ── ⚠⚠ THE BASE UNIT IS A CHOICE, AND µs IS THE WRONG ONE ────────
//
// The step has to be an exact integer number of base units or the
// whole construction collapses back into rounding.  dryopea's tick is
// **2/3 of a second**, and `1e6 / 1.5` is **666 666.67** — so there is
// no integer number of MICROSECONDS that is today's tick.
//
// ⚠ `plans/26` § Open questions recommended **666 667 µs**, *"today's
// tick to within 1e-6 s"*, on the reasoning that it *"moves nothing"*.
// **Measured, it moves seventeen tests** (`@M031`): the 654 scenario
// measurements do not budge, but `TICK_SECONDS` derived from it is
// `0.666667` rather than `0.6666666666666666`, and a dozen assertions
// are pinned to the exactness of the latter — `23_k2a`'s carry is no
// longer 0.0 to the bit, `3.0 * TICK_SECONDS` is no longer exactly 2,
// and two frame-accumulation tests land a tick out.  *A step chosen to
// look like the tick is not the tick.*
//
// ⚠⚠ **So the base unit is 1/3 of a microsecond — 3 000 000 a second —
// and it is chosen for exactly one reason: it is the coarsest unit in
// which 2/3 of a second is a whole number.**  Today's tick is
// **2 000 000** units, and `TICK_SECONDS` derived from it is
// `2000000.0 / 3000000.0`, which is **bit-identical** to the
// `1.0 / 1.5` it replaces — verified, not assumed
// (`tests/26_l1_the_clock.loft` § The derived second is the old one).
// So there is ONE definition of the tick and no residue to document.
//
// ⚠ The wall clock hands out microseconds (`ticks()`), and
// `clock_units_from_micros` is `* 3` — exact, no division, no rounding.
//
// ⚠ **The unit is not baked into this file's arithmetic**, and that is
// deliberate: `clock_new` takes a step and the accumulator counts
// whatever the caller counts.  A consumer at 128 Hz needs a different
// unit (3 000 000 / 128 is not an integer) and is entitled to pick one.
// What the library promises is the IDENTITY, not a unit.
//
// ── ⚠ A DOOR PER USE CASE, and none is a mode flag on another ───
//
//   * `clock_advance(clk, elapsed)` — a DURATION.  Banks it and answers
//     the whole steps that are due.  A frame loop's door.
//   * `clock_advance_capped(clk, elapsed, max)` — the same, for a
//     driver that may be BACKGROUNDED.  Drops the excess (L4).
//   * `clock_pump(clk, now)` — an ABSOLUTE wall reading.  The clock
//     keeps the previous one, so a server pump owns no subtraction (L4).
//   * `clock_step(clk, n)` — a COUNT.  Answers `n` and does not touch
//     the bank at all.
//   * `clock_drive(slow, fast, n)` — `n` ticks of one clock spent on a
//     slower one (L4).
//
// That is `play.loft`'s two-door split one level down, and it is the
// same rule for the same measured reason: a count and a duration look
// like one question with a switch, and they are not.  ⚠ The doors are
// THIN and share ONE accumulator — five entry points over five copies
// of the arithmetic would be `plans/26` § 2's seven-sites problem
// wearing a library's clothes.
//
// ── ⚠⚠ THE POLICIES ARE THE DRIVER'S, AND THE ARITHMETIC IS THIS
//    FILE'S ────────────────────────────────────────────────────────
//
// L4 adds a cap, a rate and a composition — **none of which dryopea
// uses**, and that is deliberate (`plans/26` § FLEXIBLE: *could a
// consumer be tempted to hand-roll this?*  moros built two of the
// three already).  What keeps them from becoming policy is that every
// one of them is a door a caller may ignore:
//
//   * **The cap DROPS time, it never compresses or defers it.**  A
//     capped frame runs `max` steps and the rest of the backlog is
//     GONE.  ⚠ The tempting wrong version clamps the answer and leaves
//     the excess banked — which is not a cap but a DEFERRAL, and it
//     makes the simulation run behind the wall for ever afterwards.
//     `tests/26_l4` § A cap that defers is the control that says so.
//   * **The rate is a RATIONAL, so it is exact.**  `num/den` with the
//     wall remainder carried in `[0, den)` — pause is `0/1`,
//     half-speed `1/2`, and a rate that does not divide loses nothing.
//     ⚠ *Free-run* is not a rate: it is the COUNT door, driven as fast
//     as the machine allows.
//   * **Composition is `n` ticks of one clock spent on another.**
//     ⚠ Spelling it `n * step` by hand is the exact shape of mistake
//     `tests/19_p1` records — and here it is exact, because the
//     arithmetic is integer.  The door is what says so, and what keeps
//     a consumer from writing the float version of it.
//
// ⚠ **Every DURATION door is scaled by the rate and the COUNT door is
// not.**  A script asking for thirty ticks, a turn being committed and
// a rollback re-simulation have no wall clock to slow down; a rate that
// reached them would make a paused game unable to run its own tests.
//
// ── ⚠ WHAT IS DELIBERATELY NOT HERE ─────────────────────────────
//
// **No callback and no inversion of control.**  This never calls your
// tick; it answers *how many*.  That is what lets one clock serve a GL
// loop, a script runner, a server pump and a test — moros's
// `tick.loft` keeps the same rule and its three drivers are the
// evidence it works.
//
// **No backlog cap on `clock_advance`, and L4 did not add one.**  A
// frame that delivers 20 s really does buy 30 ticks.  A cap is a
// DRIVER's policy (`plans/26` § 3: moros's page has one and its server
// does not), and `play_advance`'s refusal of one is what 654 gate
// measurements rest on.  `clock_advance_capped` is a door BESIDE it,
// never a default inside it.
//
// **An ALPHA, and nothing that uses it** (L5).  `clock_alpha` is how
// far the bank is toward the next step, and it is the one number a
// renderer needs from a fixed-step clock.  ⚠ What a consumer DOES with
// it is a presentation decision the clock must not make — dryopea
// measured three policies for it and ships none of them
// (`tests/26_l5_the_alpha.loft`, `@M035`).
//
// **No tick COUNTER.**  `PlayState.ticks` already counts the run's
// ticks, and a second counter beside it is two facts that can
// disagree.  This owns the STEP and the BANK — the parts a game would
// otherwise rebuild — and the count belongs to the game.


// How many base units make one second.
//
// ⚠ **1/3 of a microsecond, and the number is derived rather than
// picked** — see the header.  It is the coarsest unit in which
// dryopea's 2/3-second tick is a whole number, which is what lets
// `TICK_SECONDS` stay bit-identical to the `1.0 / 1.5` it replaced.
pub const CLOCK_UNITS_PER_SECOND: integer = 3000000;

// A fixed step and the time banked toward the next one.
//
// ⚠ **Both integers, and that is the whole file.**  `banked` is always
// in `[0, step)` between calls, so there is no rounding to accumulate
// and no epsilon to get the direction of wrong (`CLAUDE.md` § Timers
// and epsilons: *neither direction is safe*).
pub struct TickClock {
    // One simulation step, in base units.  Never zero — `clock_new`
    // refuses, because a zero step makes `clock_advance` a division by
    // zero and "pause" is a rate of 0 rather than a step of 0 (L4).
    pub step: integer,

    // Base units not yet spent as whole steps.  ⚠ **The remainder is
    // KEPT**, which is the property plan 19 P0 measured and the one no
    // measurement in this repo would survive losing: a frame that
    // delivers less than a step takes no step and banks all of it.
    pub banked: integer,

    // How fast simulated time runs against the wall, as `num / den`
    // (L4).  Pause is `0 / 1`; half-speed `1 / 2`; triple `3 / 1`.
    //
    // ⚠⚠ **A zero-defaulted `den` reads as UNSCALED, and that is the
    // third answer this plan has given to the same loft rule.**  loft
    // defaults an omitted struct field silently ([loft#914]): a
    // `Bank` therefore may not hold its `whole` (a defaulted 0 is a
    // mover that never moves, `@X080`) and a `Timer` MAY hold its
    // `total` (a defaulted 0 is an unarmed timer, `@X082`).  Here a
    // defaulted `0 / 0` is *the behaviour you already had* —
    // `plans/26` § The two rules under all of it — so the neutral
    // default is not merely safe, it is the rule stated as data.
    pub rate_num: integer,
    pub rate_den: integer,

    // Wall time not yet converted into simulated time, in `den`-ths of
    // a base unit.  Always in `[0, den)`.
    //
    // ⚠ **A second accumulator, and it is a different QUESTION rather
    // than a second copy of one.**  `banked` carries sim time toward
    // the next step; this carries wall time toward the next sim unit.
    // Folding them — scaling `banked` by `den` — would make
    // `clock_banked`'s unit change with the rate, and it is a
    // save/restore value.
    pub rate_carry: integer,

    // The last absolute wall reading `clock_pump` saw, and whether it
    // has seen one at all.
    //
    // ⚠ **`pumped` is a flag rather than a sentinel `pump_at` of 0**,
    // because a wall clock may legitimately read 0 — and a clock whose
    // first pump answered `now` worth of ticks would open a server
    // with the whole uptime as one burst.
    pub pump_at: integer,
    pub pumped:  boolean,
}

// A clock ticking every `step` base units.
//
// ⚠ **One argument, and a clock nobody configures behaves exactly as
// dryopea's did before this file** — uncapped, unscaled, carrying its
// remainder.  `plans/26` § The two rules under all of it: *defaults
// mean the behaviour you already had*.
//
// ⚠ A step below 1 is clamped to 1 rather than refused, because the
// alternative is a nullable clock at every call site for a case no
// caller has: `clock_new(0)` is a mistake, not a mode.
pub fn clock_new(step: integer) -> TickClock {
    TickClock { step: max(step, 1), banked: 0,
                rate_num: 1, rate_den: 1, rate_carry: 0,
                pump_at: 0, pumped: false }
}

// Convert wall-clock microseconds — what loft's `ticks()` answers — to
// this clock's base units.
//
// ⚠ **A multiplication, so it is exact.**  `main.loft` used to divide
// microseconds by 1e6 into float seconds before the simulation saw
// them, which threw the exactness away at the boundary and is half of
// what plan 26 was opened for (`plans/26` § 1).
pub fn clock_units_from_micros(us: integer) -> integer {
    us * 3
}

// The other direction, in SECONDS.
//
// ⚠⚠ **The seam plan 26 L3 was going to CLOSE, and what it did instead
// was change hands.**  Before L3 every one-shot timer in the game came
// through here to count float seconds — the boost and its cooldown, 90
// ticks of helper recovery, 20 s of tower rebuild, the wave lull — and
// `tick_timer.loft` took all four into integer units.  What is left is
// not simulation at all:
//
//   * **authoring** — `emit.loft` writes a captured situation as
//     `.keys` text and a person reads it, and *seconds* is the unit a
//     person authors in.  Exactly `bank_fraction`'s position one family
//     over, and exactly as exact: a duration is a whole number of base
//     units, so the round trip through `clock_units_from_seconds`
//     recovers the integer this answered.
//   * **presentation** — the camera's ease is `1 − e^(−k·dt)` over real
//     frame time and is float by nature (`plans/26` § The invariant).
//
// ⚠ **No mover and no timer may call this.**  A mover that did would be
// throwing away what `bank_gain` was built to keep, and a timer that did
// would be re-introducing the direction problem `tick_timer.loft` exists
// to delete.
pub fn clock_seconds_from_units(units: integer) -> float {
    (units as float) / (CLOCK_UNITS_PER_SECOND as float)
}

// Float seconds to base units, ROUNDED — the authoring door's other
// half.
//
// ⚠ **Rounding rather than truncating, for `tests/26_l1`'s reason one
// layer out**: a float second's worth of units is a hair over or under
// its exact count, and truncating a hair-under value costs a whole unit
// at every boundary.  The error is ~1e-10 against a step of 2 000 000.
//
// ⚠ Negative time answers 0 rather than a negative count — the same
// refusal `clock_advance` makes, stated here too because the rounding
// would otherwise turn a small negative into `-0.5` and then into 0 by
// accident rather than by rule.
pub fn clock_units_from_seconds(seconds: float) -> integer {
    if seconds <= 0.0 { return 0; }
    ((seconds * (CLOCK_UNITS_PER_SECOND as float)) + 0.5) as integer? ?? 0
}

// ─── The rate: how fast sim time runs against the wall (L4) ─────

// Run simulated time at `num / den` of wall time.
//
// ⚠ **A RATIONAL rather than a float, and that is the whole reason it
// needs no epsilon.**  `clock_set_rate(clk, 1, 3)` is a third of speed
// exactly, for ever: a frame worth 100 units contributes 33 and the
// leftover 1/3 is carried, where `elapsed * 0.3333…` drifts against a
// rate it could not state in the first place.  Same distinction
// `tick_bank.loft` § THE RATE IS SCALED AND THE TIME IS NOT draws one
// family over — except that a clock's rate is authored by a KEYPRESS
// rather than by `numbers.json`, so it is exact rather than quantised.
//
// ⚠ **Pause is `0 / 1`**, and it banks nothing while keeping what is
// banked — so unpausing resumes mid-step rather than re-phasing the
// simulation.  A step of 0 is a mistake, never a pause
// (`clock_new`).
//
// ⚠ A negative `num` is clamped to 0 rather than rewinding.  Time
// going backwards is dropped everywhere else in this file, and a rate
// that ran the simulation in reverse would need every consumer to have
// an inverse tick.
//
// ⚠ **Changing the rate DROPS the wall carry**, because the carry is
// measured in `den`-ths of a base unit and the old `den` is gone.  It
// is under one base unit — a third of a microsecond — and a rate
// change is an event a player caused, not a per-frame path.
// Example: @FIX-007
pub fn clock_set_rate(clk: TickClock, num: integer, den: integer) {
    clk.rate_num   = max(num, 0);
    clk.rate_den   = max(den, 1);
    clk.rate_carry = 0;
}

// The rate's numerator — 0 for a paused clock.
pub fn clock_rate_num(clk: const TickClock) -> integer {
    clk.rate_num
}

// The rate's denominator — never 0, and 1 for an unconfigured clock.
pub fn clock_rate_den(clk: const TickClock) -> integer {
    if clk.rate_den <= 0 { return 1; }
    clk.rate_den
}

// Wall time in, simulated time out — exact, with the remainder carried.
//
// ⚠ **Private, and it is the ONE place the rate is applied.**  Every
// duration door goes through here and the count door does not, which is
// what makes "a rate slows the wall, never a script" one line rather
// than a rule three call sites have to remember.
fn clock_scaled(clk: TickClock, elapsed: integer) -> integer {
    // ⚠ A clock built from a partial literal has `rate_den` 0 and runs
    // UNSCALED — `plans/26` § The two rules under all of it.
    if clk.rate_den <= 0 { return elapsed; }
    cs_total       = clk.rate_carry + elapsed * clk.rate_num;
    cs_sim         = (cs_total / clk.rate_den) ?? 0;
    clk.rate_carry = cs_total - cs_sim * clk.rate_den;
    cs_sim
}

// ─── The duration doors ─────────────────────────────────────────

// Spend elapsed time, and answer the whole steps it bought.
//
// ⚠ **Integer division, not a subtraction loop.**  The loop is what
// lost the tick — eleven subtractions of a repeating fraction leave a
// remainder a hair under the twelfth — and it is also O(steps), so a
// frame that delivered an hour walked five thousand iterations to say
// so.  One division answers both.
//
// ⚠ **Time going backwards is DROPPED, not banked.**  A negative
// elapsed is a clock that was reset or a caller subtracting in the
// wrong order; banking it would owe the simulation negative ticks and
// then swallow the next real frame.  Answering 0 and keeping the bank
// is the only behaviour that cannot corrupt a run.
//
// ⚠ **No cap, and `clock_advance_capped` is the door that has one.**
// A frame that delivers 20 s buys 30 ticks here, which is the property
// dryopea's 654 gate measurements rest on.
// Example: @FIX-001
pub fn clock_advance(clk: TickClock, elapsed: integer) -> integer {
    if elapsed <= 0 { return 0; }
    clk.banked = clk.banked + clock_scaled(clk, elapsed);
    ca_whole   = (clk.banked / clk.step) ?? 0;
    clk.banked = clk.banked - ca_whole * clk.step;
    ca_whole
}

// The same, for a driver that may have been backgrounded — at most
// `max_steps`, and the excess is GONE.
//
// ⚠⚠ **It DROPS rather than defers, and the difference is the whole
// door.**  `clock_advance` has already reduced the bank to the
// sub-step remainder by the time this clamps, so the steps beyond the
// cap are not owed: the next frame answers 1, not a burst.  The
// tempting wrong version clamps the answer and leaves the backlog
// banked, which runs the simulation behind the wall for ever after —
// `tests/26_l4` § A cap that defers is that version, kept as the
// control, and it is the only thing that makes this function's gate
// non-trivial.
//
// ⚠ **And it must not compress**, which is moros's own note: *a
// compressed catch-up is the variable-step bug wearing a bound's
// clothes*.  Nothing here can — the step is fixed and this answers a
// COUNT — but a driver that multiplied its tick's dt to "catch up"
// would reintroduce exactly what `plans/22` § LOD is warned against.
//
// ⚠ A cap below 1 is clamped to 1, for `clock_new`'s reason: a cap of
// zero is a simulation that can never advance, which is a mistake
// rather than a mode.  Pause is a rate of 0.
// Example: @FIX-004
pub fn clock_advance_capped(clk: TickClock, elapsed: integer,
                            max_steps: integer) -> integer {
    cac_due = clock_advance(clk, elapsed);
    cac_cap = max(max_steps, 1);
    if cac_due <= cac_cap { return cac_due; }
    cac_cap
}

// Spend the time since the last reading of an ABSOLUTE wall clock.
//
// ⚠ **The server pump's door: it owns the wall and the caller owns no
// subtraction.**  `now - last` written at the call site is where a
// driver gets the order backwards once and silently swallows a frame —
// `clock_advance` drops negative time, so the mistake looks like a
// stutter rather than an error.
//
// ⚠ **The FIRST call primes and answers none.**  A pump whose first
// reading was treated as elapsed would open a server with its whole
// uptime as one burst of ticks.
//
// ⚠ **A wall going backwards re-primes** rather than dropping the
// reading, because an absolute clock that jumped back is the new truth;
// dropping it would owe every future pump the difference.
//
// ⚠ No cap — moros's server has none and `plans/26` § 3 says why the
// two repos are both right.  A pump that needs one wraps this in the
// same clamp `clock_advance_capped` applies.
// Example: @FIX-003
pub fn clock_pump(clk: TickClock, now: integer) -> integer {
    if !clk.pumped {
        clk.pumped  = true;
        clk.pump_at = now;
        return 0;
    }
    if now <= clk.pump_at {
        clk.pump_at = now;
        return 0;
    }
    cp_dt       = now - clk.pump_at;
    clk.pump_at = now;
    clock_advance(clk, cp_dt)
}

// Ask for `n` steps, and answer how many that was.
//
// ⚠ **It does not touch the bank, and that is the whole point of it
// being a separate door.**  A count is not elapsed time: a script
// saying `tick 30` neither spends a live session's remainder nor
// leaves one behind.  `play.loft` § TWO ways to ask made this
// distinction one level up and measured what folding them costs.
//
// ⚠ It looks trivial and is not free to delete: without it a caller
// that has a count must spell it as `n * step`, which is the exact
// mistake `tests/19_p1` records — and which is only safe here because
// the arithmetic is integer.  The door is what says so.
//
// ⚠⚠ **`_clk` IS UNDERSCORED BECAUSE IT IS UNREAD BY DESIGN, AND THE
// UNDERSCORE IS LOAD-BEARING** (0.1.1).  Not reading it *is* the
// contract — a count must not spend a live session's remainder — so
// `never-read` is a true report of an intended fact rather than a
// defect.  Left bare it fails the chunk's `LOFT_DENY_WARNINGS=1` CI,
// and both tempting fixes are wrong: touching `clk` to quiet it breaks
// the contract, and an `.allow_warnings` file switches the gate off for
// every FUTURE warning in this package too.
//
// ⚠ **The parameter stays rather than the signature dropping it.**  A
// caller holding a clock should reach for `clock_step(clk, n)` beside
// `clock_advance(clk, elapsed)`; a bare `step(n)` would be an
// arithmetic helper that names no case, and naming the case is the
// entire reason this door exists.
//
// Example: @FIX-002
pub fn clock_step(_clk: const TickClock, n: integer) -> integer {
    max(n, 0)
}

// The step this clock ticks on, in base units.
pub fn clock_step_units(clk: const TickClock) -> integer {
    clk.step
}

// Time banked toward the next step, in base units — always in
// `[0, step)`.
//
// ⚠ **Readable so a run can be RESTORED**, which is the whole of what
// a rollback consumer needs from the timing half (`plans/26` § What
// this plan does NOT build): re-running N steps from a restored bank
// is bit-identical because `clock_step` is exact.
// Example: @FIX-006
pub fn clock_banked(clk: const TickClock) -> integer {
    clk.banked
}

// Put a clock back where it was — the other half of `clock_banked`.
//
// ⚠ Clamped into `[0, step)` rather than trusted: a bank at or past
// the step is a state `clock_advance` can never leave behind, and
// accepting one would make the next call answer a step nobody's time
// paid for.
pub fn clock_restore(clk: TickClock, banked: integer) {
    if banked <= 0 { clk.banked = 0; return; }
    clk.banked = (banked % clk.step) ?? 0;
}

// ─── The alpha: where the frame falls between two steps (L5) ────

// How far the bank is toward the next step, in `[0, 1)`.
//
// ⚠ **The one number a renderer needs from a fixed-step clock**, and
// the reason it is here rather than at the call site: `banked / step`
// written by hand is a caller reaching INTO the accumulator, and a
// caller that can read `banked` can also write it.  This is the read
// door, and `clock_banked` (raw units, for save/restore) is the other
// one — two questions, two answers, one field.
//
// ⚠ **Half-open by construction, not by a clamp.**  `clock_advance`
// leaves `banked` in `[0, step)` and `clock_restore` refuses anything
// else, so the division cannot reach 1.0 — which is what a caller
// lerping between two states depends on: an alpha of exactly 1.0 would
// draw the NEXT state one frame early, on the frame before the tick
// that produces it.
//
// ⚠⚠ **What a consumer does with it is a PRESENTATION decision and this
// file must not make it.**  The textbook use is
// `lerp(previous_state, current_state, alpha)` — and dryopea measured it
// against two alternatives over one drive (`tests/26_l5_the_alpha.loft`,
// `@M035`):
//
//   * `lerp(prev, cur, alpha)` draws the step that has ALREADY happened,
//     so it is **exactly one step behind, for ever** — 2.598 m here,
//     because a step is 2/3 of a second;
//   * `cur + alpha·(cur − prev)` is **exact to machine precision** while
//     the velocity holds, and jumps a whole step's travel on the frame
//     the mover starts or stops;
//   * a first-order ease toward `cur` needs no previous state, lags
//     between the two, and is the only one whose picture depends on the
//     frames it was drawn on.
//
// ⚠ Three prices on three different axes, and which one is right is a
// function of how long the STEP is — so a clock that picked one would be
// wrong for every consumer whose step is a different length.  This
// answers *where between two steps are we*; it does not answer *what
// should be drawn there*.
// Example: @FIX-005
pub fn clock_alpha(clk: const TickClock) -> float {
    if clk.step <= 0 { return 0.0; }
    ((clk.banked as float) / (clk.step as float)) ?? 0.0
}

// ─── Composition: a slow clock driven by a fast one (L4) ────────

// Spend `n` steps of `fast` on `slow`, and answer the slow steps that
// bought.
//
// ⚠⚠ **The case is a strategy layer over a unit layer** — dryopea has
// the shape twice already (the camera steps at frame rate while the sim
// steps at 1.5 Hz, and `plans/22` § LOD contemplates ticking distant
// things every N ticks).  ⚠ **And it is the OPPOSITE of that plan's
// warning**: *bank progress, never multiply it* refuses to give a
// distant enemy `N × dt` of movement in one tick.  This gives a slower
// CLOCK the time those N ticks took, and it still ticks one step at a
// time.
//
// ⚠ **Spelling it `n * step` at the call site is the shape of mistake
// `tests/19_p1` records** — a count folded into a duration, wrong for
// 602 of the first 1000 `n`.  Here it is exact, because both sides are
// integers; the door exists so that the next consumer does not
// re-derive it in seconds and find out the hard way, and
// `tests/26_l4` § Composing by hand is exact says so out loud rather
// than letting this comment overclaim.
//
// ⚠ **A slow clock driven this way LAGS its driver by less than one
// fast step, and never leads.**  The fast clock's own bank is time
// that has not become a tick yet, so it has not been handed on — which
// is the correct answer and not an approximation: the composed clock is
// exactly as far along as the ticks that actually happened.
//
// ⚠ **`slow`'s own rate still applies.**  A rate set on both clocks
// multiplies, which is a composition a caller asked for; set it on the
// one that faces the wall.
// Example: @FIX-008
pub fn clock_drive(slow: TickClock, fast: const TickClock, n: integer) -> integer {
    if n <= 0 { return 0; }
    clock_advance(slow, n * clock_step_units(fast))
}

src/tick_timer.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// A one-shot duration that fires once, in exact integer time —
// plan 26 phase L3.
//
// ── ⚠⚠ THE DIRECTION PROBLEM, AND WHY THIS FILE DELETES IT ───────
//
// `CLAUDE.md` § Timers and epsilons carries the rule this file
// answers, and it is a MEASURED one rather than a caution:
//
// > **A banked timer's DIRECTION is half the epsilon rule — counting
// > UP and counting DOWN do not accumulate the same way, and NEITHER
// > IS SAFE.**
//
// `tower.loft::TOWER_REPAIR_EPSILON` carried the table.  Over a
// `1 / 1.5` s tick, for durations that ARE exact multiples of it:
//
//   | T      | ticks | counting UP        | counting DOWN |
//   |---|---|---|---|
//   |  4.0 s |    6  | 3.9999999999999996 | residue > 0   |
//   | 20.0 s |   30  | **exactly 20.0**   | residue > 0   |
//   | 30.0 s |   45  | 30.000000000000018 | residue < 0   |
//   | 60.0 s |   90  | 59.99999999999992  | residue > 0   |
//
// Four sites in dryopea re-stated that arithmetic, three of them with
// their own 1e-9 nudge, and each nudge had to be argued separately
// because *which way it had to lean depended on the direction of the
// count*.  20.0 s counting up needed none at all and was exact by luck;
// 60.0 s counting down needed one or the helper came back on the
// ninety-FIRST tick.
//
// ⚠⚠ **In integer time the question does not arise, and that is the
// finding rather than the fix.**  A `Timer` holds ONE number — base
// units spent since it was armed — and both readings come off it:
// `timer_spent` is the up-count, `timer_left` is `total - spent`.  Two
// directions, one arithmetic, so there is nothing left to get
// backwards.  ⚠ It is not that both directions were made exact; it is
// that there are no longer two directions.
//
// `tests/26_l3_the_timers.loft` is the gate, and it keeps BOTH float
// paths beside the integer one as the control — the same construction
// `tests/26_l1` needed, for the same reason: *integers agreeing with
// integers is not a measurement*.
//
// ── ⚠⚠ IT IS NOT A `Bank`, AND THE REFUTATION IS MEASURED ────────
//
// `plans/26` § The invariant claims a family boundary:
//
// > A one-shot's remainder DIES at its boundary; a bank's is
// > load-bearing FOR EVER.
//
// A claim that clean is the one to attack, so L3 attacked it: build a
// one-shot out of `bank_gain` at a rate of `1 / T` units per second and
// see what breaks.  Two things do, and both are silent:
//
//   1. **It fires again.**  A bank has no boundary — spend another T
//      seconds and it releases a second unit.  Every one-shot in
//      dryopea is armed by an EVENT (a crew member delivered, a boost
//      pressed, a wave cleared), so a second firing is a helper that
//      rejoins twice.
//   2. **The residue leaks into the NEXT arming.**  The boost cooldown
//      is 5.0 s, which is 7.5 ticks: the first one takes 8 ticks and a
//      bank-backed one would take **7** for the second boost, because
//      half a tick of the first cooldown is still banked.  That is the
//      bank being RIGHT about its own job — a scout that banked
//      two-thirds of a hex is two-thirds of a hex closer, permanently —
//      and wrong about this one.
//
// ⚠ So the two types stay apart, and `tests/26_l3` § A one-shot built
// on a bank is the assertion, not this comment.  ⚠⚠ One clever type
// that "handles both" is the over-unification plan 26 was written to
// avoid.
//
// ── ⚠ WHAT IT HOLDS, AND WHY `total` IS A FIELD HERE ─────────────
//
// `tick_bank.loft` refuses to hold its `whole` and takes it per call,
// because loft defaults an omitted struct field silently
// ([loft#914](https://github.com/loft-lang/loft/issues/914)) and a
// defaulted `whole` of 0 is a mover that never moves — a partial
// `Enemy { }` literal would freeze it.
//
// ⚠ **A `Timer` holds its `total`, and the same rule is what says it
// may**: a defaulted `total` of 0 is an UNARMED timer, which is exactly
// what every zero-valued field it replaces already meant — `recover:
// 0.0` is "not recovering", `boost_left: 0.0` is "not boosting",
// `stand: 0.0` is "not standing", `lull: 0.0` is "send now".  So
// `Timer { }` is correct-neutral at every site, where `Bank { whole: 0 }`
// would have been correct-neutral at none.
//
// ⚠ It is also what lets one timer answer BOTH directions without the
// caller passing the duration back in every time, and what makes
// "armed" a state rather than a second boolean beside it.
//
// ── ⚠ WHAT IS DELIBERATELY NOT HERE ─────────────────────────────
//
// **No repeat, no auto-rearm.**  A timer that re-armed itself would be
// a `Bank` with a boundary bolted on, which is the collapse above.  A
// caller that wants a metronome arms it again and says so.
//
// **No callback.**  `timer_spend` answers *did it fire*; it never calls
// anything.  Same rule as `clock_advance`, and for the same reason —
// one type serves a tick loop, a script runner and a test.
//
// **No pause.**  A paused timer is one nobody spends time into, and the
// driver already decides how much time anything gets (`tick_clock.loft`
// § WHAT IS DELIBERATELY NOT HERE).  A `paused` flag here would be a
// second place that answers "is time passing".
//
// **No RATE.**  That is `tick_bank.loft`, and the whole file above is
// why the two are not one.

use tick_clock::*;

// A one-shot duration and how much of it has been spent.
//
// ⚠ **Both integers, and `total == 0` is UNARMED** — the zero-default
// that makes a partial struct literal correct everywhere this replaces
// a `0.0` seconds field.  See the header.
pub struct Timer {
    // Base units spent since it was armed.  Always in `[0, total)`
    // between calls: the call that reaches `total` fires and disarms,
    // so a spent timer is an unarmed one rather than a saturated one.
    pub spent: integer,

    // The whole duration, in base units.  Zero means nothing is
    // running.
    pub total: integer,
}

// A timer nobody has armed.
pub fn timer_new() -> Timer {
    Timer { spent: 0, total: 0 }
}

// A timer armed for `units` — the factory, for a struct literal that
// wants one already running.
//
// ⚠ `Enemy`'s pre-walk window is the case: an enemy is built already
// standing, so the field has to arrive armed.
pub fn timer_armed(units: integer) -> Timer {
    if units <= 0 { return timer_new(); }
    Timer { spent: 0, total: units }
}

// Arm it for `units`, from zero.
//
// ⚠ **A duration of zero or less DISARMS rather than firing.**  Both
// readings are defensible and this one is what the call sites already
// mean: a schedule authored with a lull of 0 sends the next wave
// immediately because nothing is running, not because a timer fired.
// ⚠ It also re-arms from wherever it was, so a boost pressed during a
// boost restarts the clock rather than extending it — the behaviour the
// float version had, stated rather than inherited.
// Example: @FIX-011
pub fn timer_arm(t: Timer, units: integer) {
    t.spent = 0;
    if units <= 0 { t.total = 0; return; }
    t.total = units;
}

// Is this timer running — armed, and not yet fired?
pub fn timer_running(t: const Timer) -> boolean {
    t.total > 0
}

// Base units still to go.  Zero for a timer that is not running.
//
// ⚠ **The DOWN reading**, and it is a subtraction rather than a second
// field.  `helper.recover` and `Vehicle.boost_left` were both stored
// this way and each carried its own epsilon; there is one number now
// and it is exact.
pub fn timer_left(t: const Timer) -> integer {
    if t.total <= 0 { return 0; }
    max(t.total - t.spent, 0)
}

// Base units gone by.  Zero for a timer that is not running.
//
// ⚠ **The UP reading**, off the same number.  `TowerCharge.repair` was
// stored this way — *seconds banked toward the rebuild* — because zero
// had to mean "nobody is working on it", and that is still what an
// unarmed timer says.
pub fn timer_spent(t: const Timer) -> integer {
    if t.total <= 0 { return 0; }
    t.spent
}

// The whole duration this timer was armed for, in base units.
pub fn timer_total(t: const Timer) -> integer {
    t.total
}

// How far through it is, in `[0, 1)` — 0.0 for a timer that is not
// running.
//
// ⚠ **The presentation door, and the one place a float appears** — a
// HUD showing a rebuild part-done, exactly `bank_fraction`'s position
// one family over.  Nothing in the simulation may read it: a
// comparison against 1.0 here is the epsilon problem walking back in
// through the display.
pub fn timer_fraction(t: const Timer) -> float {
    if t.total <= 0 { return 0.0; }
    ((t.spent as float) / (t.total as float)) ?? 0.0
}

// Spend `units` of it.  Answers whether THIS call finished it.
//
// ⚠ **It fires exactly once and then disarms**, which is the whole
// difference from `bank_gain`: the remainder past the boundary is
// DISCARDED, because a one-shot's residue means nothing afterwards.
// Carrying it would make the next arming shorter than the one before —
// measured in `tests/26_l3` § A one-shot built on a bank, where a 5.0 s
// cooldown costs 8 ticks the first time and 7 the second.
//
// ⚠ A timer that is not running answers false and stays that way, so a
// caller may spend into it unconditionally — which is what
// `vehicle_burn` does with the cooldown every single tick.
//
// ⚠ Non-positive time is dropped rather than owed, for
// `clock_advance`'s reason: time going backwards must not un-fire
// anything.
// Example: @FIX-011
pub fn timer_spend(t: Timer, units: integer) -> boolean {
    if t.total <= 0 { return false; }
    if units <= 0 { return false; }
    t.spent = t.spent + units;
    if t.spent < t.total { return false; }
    t.spent = 0;
    t.total = 0;
    true
}

// Stop it without firing.
//
// ⚠ **A door of its own rather than `timer_arm(t, 0)`, because it is a
// DECISION** — the same argument `bank_reset` makes.  A vehicle that
// respawns loses the boost it was in the middle of; a cooldown that is
// merely running keeps everything.  Two behaviours, and the one that
// throws work away says so by name.
pub fn timer_cancel(t: Timer) {
    t.spent = 0;
    t.total = 0;
}

// Put a timer back from a DOWN reading — `units` left of `total`.
//
// ⚠ Clamped rather than trusted, for `clock_restore`'s reason: `left`
// past `total` is a state `timer_spend` can never leave behind, and a
// `left` of zero or less is a timer that has already fired, which is an
// unarmed one.
pub fn timer_restore_left(t: Timer, left: integer, total: integer) {
    if total <= 0 || left <= 0 { timer_cancel(t); return; }
    t.total = total;
    t.spent = max(total - left, 0);
}

// Put a timer back from an UP reading — `units` spent of `total`.
//
// ⚠ The other half, and the two are not interchangeable at the call
// site: `emit.loft` writes whichever reading its field was authored in,
// so a tower's rebuild round-trips as *banked* and a helper's recovery
// as *left*.
pub fn timer_restore_spent(t: Timer, spent: integer, total: integer) {
    if total <= 0 || spent <= 0 { timer_cancel(t); return; }
    if spent >= total { timer_cancel(t); return; }
    t.total = total;
    t.spent = spent;
}

tests/01-doors.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// One test per DOOR, and each one is that door's worked EXAMPLE.
//
// ⚠⚠ **The pair is the deliverable.**  A snippet in a doc comment rots
// silently — nothing compiles it, so it goes stale at the first
// signature change and the reader finds out.  A test nobody links to is
// correct and invisible, which is how a consumer ends up hand-rolling
// the thing the library already does.  So every public door carries an
// `// Example: @FIX-0NN` line pointing here, and every tag below is
// reachable from the function it demonstrates.
//
// ⚠ These are examples FIRST and assertions second: each one is written
// the way a caller would actually write it, not the way that would be
// shortest to assert on.
use fixstep::*;

// A step of 2/3 s in this package's first consumer's base unit — 1/3 of
// a microsecond, 3 000 000 to the second.  ⚠ The unit is the CONSUMER's
// choice; the library counts whatever it is handed.
const UNITS_PER_SECOND: integer = 3000000;
const STEP:             integer = 2000000;
const FRAME_60:         integer = 50000;

// The whole call site of a GL or browser frame loop.
//
// ⚠ The remainder CARRIES, which is the property everything else rests
// on: many small frames and one big one reach the same state, so the
// simulation is not frame-rate dependent.
// @FIX-001 — a frame loop spends elapsed time and gets a COUNT back
fn test_a_frame_loop_spends_its_backlog() {
    fl_clk = clock_new(STEP);

    fl_ticks = 0;
    for _ in 0..120 {                       // two seconds at 60 fps
        for _ in 0..clock_advance(fl_clk, FRAME_60) {
            fl_ticks = fl_ticks + 1;        // <- the game's tick goes here
        }
    }
    assert(fl_ticks == 3, "two seconds at 60 fps bought {fl_ticks} ticks of 2/3 s");

    // The same two seconds delivered as ONE frame buys the same three.
    fl_one = clock_new(STEP);
    assert(clock_advance(fl_one, 2 * UNITS_PER_SECOND) == 3,
           "one frame of 2 s bought a different count from 120 frames of 1/60 s");
}

// ⚠⚠ **A count is not a duration, and spelling one as the other is the
// mistake this door exists to remove.**  Through a FLOAT accumulator,
// `n × step` seconds comes back `n − 1` for 602 of the first 1000 `n`.
// Here it is exact — and the door is what says so, so a caller with a
// count never has to reach for the arithmetic.
// @FIX-002 — a script, a test or a turn commit asks for a COUNT
fn test_a_script_asks_for_a_count() {
    sc_clk = clock_new(STEP);

    assert(clock_step(sc_clk, 30) == 30, "a script's `tick 30` was not thirty ticks");

    // ⚠ And it does not touch the bank: a count neither spends a live
    // session's remainder nor leaves one behind.
    clock_advance(sc_clk, FRAME_60);
    sc_banked = clock_banked(sc_clk);
    clock_step(sc_clk, 30);
    assert(clock_banked(sc_clk) == sc_banked,
           "a count spent {sc_banked - clock_banked(sc_clk)} units of somebody's frame");

    // The identity the integer accumulator exists for.
    sc_a = clock_new(STEP);
    sc_b = clock_new(STEP);
    for sc_n in 1..200 {
        assert(clock_advance(sc_a, sc_n * STEP) == clock_step(sc_b, sc_n),
               "advance(n * step) != step(n) at n = {sc_n}");
    }
}

// ⚠ `now - last` written at the call site is where a driver gets the
// order backwards once and silently swallows a frame.  Hand the door an
// ABSOLUTE reading instead.
// @FIX-003 — a server pump owns the wall, and the caller owns no subtraction
fn test_a_server_pump_owns_the_wall() {
    pm_clk = clock_new(STEP);

    // ⚠ The FIRST call primes and answers none — a pump that treated its
    // first reading as elapsed would open a server with the whole uptime
    // as one burst of ticks.
    assert(clock_pump(pm_clk, 1000000000) == 0, "the first pump answered ticks");

    assert(clock_pump(pm_clk, 1000000000 + 2 * STEP) == 2, "two steps of wall, two ticks");
    assert(clock_pump(pm_clk, 1000000000 + 3 * STEP) == 1, "one more step, one more tick");

    // A wall that jumped BACKWARDS re-primes rather than owing the
    // difference for ever.
    assert(clock_pump(pm_clk, 500000000) == 0, "a backwards wall answered ticks");
    assert(clock_pump(pm_clk, 500000000 + STEP) == 1, "the pump did not re-prime");
}

// ⚠⚠ **The cap DROPS the excess and must never DEFER it.**  The version
// a driver writes by mistake clamps the answer and leaves the backlog
// banked — which is not a cap but a deferral, and it runs the
// simulation behind the wall for ever afterwards.
//
// This test is that mistake, reproduced beside the shipped door, because
// both answer *fewer ticks on the stalled frame* and only the frame
// AFTER tells them apart.
// @FIX-004 — a loop that may be BACKGROUNDED caps its catch-up
fn test_a_backgrounded_loop_drops_what_it_cannot_run() {
    cp_clk = clock_new(STEP);

    // A twenty-second stall arrives as one frame.
    cp_ran = clock_advance_capped(cp_clk, 20 * UNITS_PER_SECOND, 4);
    assert(cp_ran == 4, "the cap let {cp_ran} ticks through instead of 4");

    // ⚠ The frame after the stall answers ONE, not a burst — the thirty
    // ticks past the cap are GONE.
    assert(clock_advance(cp_clk, STEP) == 1,
           "the frame after the stall answered a burst — this is a deferral, not a cap");
}

// ⚠⚠ **This answers *where between two steps are we*, and NOT *what
// should be drawn there*.**  The textbook use is
// `lerp(previous, current, alpha)`, which is smooth and exactly one step
// behind; `current + alpha·(current − previous)` is exact while the
// velocity holds and jumps a whole step when it changes.  Which is right
// is a function of how long the step is, so the library answers the
// number and the renderer owns the policy.
// @FIX-005 — drawing BETWEEN two steps
fn test_drawing_between_two_steps() {
    al_clk = clock_new(STEP);

    al_out = 0;
    al_max = 0.0;
    for _ in 0..120 {
        clock_advance(al_clk, FRAME_60);
        al_a = clock_alpha(al_clk);
        // <- the renderer draws at `al_a` here
        if al_a < 0.0 || al_a >= 1.0 { al_out = al_out + 1; }
        if al_a > al_max { al_max = al_a; }
    }
    // ⚠ HALF-OPEN: an alpha of exactly 1.0 would draw the NEXT state on
    // the frame BEFORE the step that produces it.
    assert(al_out == 0, "{al_out} of 120 frames read an alpha outside [0, 1)");
    assert(al_max > 0.9, "the highest alpha was {al_max} — this fixture never banked "
                       + "a part-step, so the range assertion above is two zeros");
}

// ⚠ The library owns the TIMING half of rollback exactly, and no more:
// it cannot rewind game state without owning the game.  `clock_banked`
// and `clock_restore` are the pair, and `clock_step` is what makes the
// re-run bit-identical.
// @FIX-006 — a rollback re-simulation re-runs N steps IDENTICALLY
fn test_a_rollback_re_runs_the_same_steps() {
    rb_clk = clock_new(STEP);
    clock_advance(rb_clk, 7 * FRAME_60);      // land mid-step
    rb_saved = clock_banked(rb_clk);

    // ⚠ 33 frames, NOT 40, and the number is the control's: 40 frames
    // are exactly one step, so a clock that forgot its carry would
    // answer the same count and the replay would look correct.  33
    // frames only reach the boundary WITH the restored remainder.
    rb_first = clock_advance(rb_clk, 33 * FRAME_60);

    // Rewind and replay the identical stream.
    rb_back = clock_new(STEP);
    clock_restore(rb_back, rb_saved);
    rb_again = clock_advance(rb_back, 33 * FRAME_60);

    assert(rb_first == rb_again && clock_banked(rb_clk) == clock_banked(rb_back),
           "the replay played {rb_again} steps against {rb_first}, banking "
           + "{clock_banked(rb_back)} against {clock_banked(rb_clk)}");

    // The control, in the same function: a rollback that dropped the
    // remainder answers a DIFFERENT count over the identical stream.
    rb_zero = clock_new(STEP);
    rb_lost = clock_advance(rb_zero, 33 * FRAME_60);
    assert(rb_lost != rb_again,
           "a clock restored to zero played {rb_lost} steps too — this stream cannot "
           + "see a dropped remainder, so the replay assertion above proves nothing");
}

// ⚠ **A RATIONAL, so it is exact for ever.**  `elapsed * 0.3333…`
// drifts against a rate it could not state in the first place; `1/3`
// carries its remainder and does not.
// @FIX-007 — pause, slow motion, fast forward
fn test_pause_and_slow_motion() {
    rt_clk = clock_new(STEP);

    clock_set_rate(rt_clk, 0, 1);                       // pause
    assert(clock_advance(rt_clk, 10 * UNITS_PER_SECOND) == 0, "a paused clock ticked");

    // ⚠⚠ A paused clock can still run its own tests: the rate scales
    // every DURATION door and NO count door.
    assert(clock_step(rt_clk, 5) == 5, "a paused clock refused a script's count");

    clock_set_rate(rt_clk, 1, 3);                       // a third of speed
    rt_n = clock_advance(rt_clk, 60 * UNITS_PER_SECOND);
    assert(rt_n == 30, "a wall minute at 1/3 speed bought {rt_n} ticks of 2/3 s, not 30");

    clock_set_rate(rt_clk, 3, 1);                       // triple
    assert(clock_advance(rt_clk, UNITS_PER_SECOND) == 4,
           "triple speed did not treble the wall");
}

// The case is a strategy layer over a unit layer: units at 30 Hz, the
// economy at 1 Hz, driven by the ticks that actually happened.
//
// ⚠ **A driven clock LAGS its driver by less than one fast step and
// never leads** — the fast clock's own bank is time that has not become
// a tick yet, so it has not been handed on.  That is the correct answer
// rather than an approximation.
// @FIX-008 — a slow layer driven by a fast one
fn test_a_slow_layer_driven_by_a_fast_one() {
    dr_fast = clock_new(100000);       // 1/30 s
    dr_slow = clock_new(3000000);      // 1 s

    dr_slow_n = 0;
    for _ in 0..600 {                  // ten seconds of 60 fps frames
        dr_slow_n = dr_slow_n + clock_drive(dr_slow, dr_fast,
                                            clock_advance(dr_fast, FRAME_60));
    }
    assert(dr_slow_n == 10, "ten seconds drove {dr_slow_n} one-second steps");
}

// ⚠⚠ **A `Bank` holds the CARRY and nothing else.**  The rate arrives
// per call, because *a damaged robot moves slower* makes speed a
// property of a CONDITION rather than of the mover; and `whole` is a
// PARAMETER rather than a field, because loft defaults an omitted struct
// field silently — a bank carrying its own scale would default to zero
// in every partial literal and freeze that mover.
// @FIX-009 — a mover spends a RATE and gets whole units
fn test_a_mover_spends_a_rate() {
    mv_b    = bank_new();
    mv_rate = bank_rate(2.5);                       // 2.5 hexes a second
    mv_whole = bank_whole(UNITS_PER_SECOND);

    mv_moved = 0;
    for _ in 0..90 {                                // sixty seconds of 2/3 s steps
        mv_moved = mv_moved + bank_gain(mv_b, mv_rate, STEP, mv_whole);
    }
    // ⚠ EXACT: 2.5 hex/s for 60 s is 150, and no epsilon anywhere.
    assert(mv_moved == 150, "2.5 hex/s over a simulated minute covered {mv_moved}");

    // ⚠ And the same minute delivered in TENTHS of a step covers the
    // same ground — a rate is a rate, whatever the timestep is.
    mv_c = bank_new();
    mv_fine = 0;
    for _ in 0..900 {
        mv_fine = mv_fine + bank_gain(mv_c, mv_rate, STEP / 10 ?? 0, mv_whole);
    }
    assert(mv_fine == 150, "the same minute at a tenth of the step covered {mv_fine}");
}

// ⚠ The two are separate doors because a caller that asks once per FRAME
// and moves once per STEP would otherwise consume the carry sixty times
// a tick.  `bank_most` is the CEILING and it touches nothing.
// @FIX-010 — asking how far, WITHOUT spending it
fn test_asking_without_spending() {
    as_b     = bank_new();
    as_rate  = bank_rate(3.0);
    as_whole = bank_whole(UNITS_PER_SECOND);

    as_ceiling = bank_most(as_rate, STEP, as_whole);
    assert(as_ceiling == 2, "3 hex/s over 2/3 s can reach {as_ceiling} hexes, not 2");

    // Asked sixty times, the bank is untouched.
    for _ in 0..60 { bank_most(as_rate, STEP, as_whole); }
    assert(bank_progress(as_b) == 0, "asking spent {bank_progress(as_b)} of the carry");
}

// ⚠⚠ **A `Timer` is NOT a `Bank`, and the refutation is measured rather
// than argued.**  A one-shot built on a bank fires a SECOND time with
// nobody re-arming it, and carries its residue into the next arming — so
// a 5 s cooldown costs 8 steps the first time and 7 the second.  A
// bank's remainder is load-bearing for ever; a one-shot's dies at its
// boundary, and picking the wrong one is silent.
// @FIX-011 — a cooldown, a countdown, a lull
fn test_a_cooldown_fires_once_exactly() {
    cd_t = timer_new();
    timer_arm(cd_t, 5 * UNITS_PER_SECOND);

    cd_fired = 0;
    cd_steps = 0;
    for _ in 0..40 {
        if timer_running(cd_t) {
            cd_steps = cd_steps + 1;
            if timer_spend(cd_t, STEP) { cd_fired = cd_fired + 1; }
        }
    }
    // ⚠ 5 s is seven and a half steps of 2/3 s, so it fires on the
    // EIGHTH — and exactly once, however long the loop runs.
    assert(cd_fired == 1 && cd_steps == 8,
           "the cooldown fired {cd_fired} times over {cd_steps} steps");

    // Re-arming starts clean: the second run costs the same as the first.
    timer_arm(cd_t, 5 * UNITS_PER_SECOND);
    cd_again = 0;
    for _ in 0..40 {
        if timer_running(cd_t) {
            cd_again = cd_again + 1;
            timer_spend(cd_t, STEP);
        }
    }
    assert(cd_again == cd_steps,
           "the second arming cost {cd_again} steps against the first's {cd_steps} — a "
           + "residue leaked across the boundary, which is what a BANK would do");
}

// ⚠⚠ **The exponential form COMPOSES and the linear one does not**, so
// the picture does not depend on how the frames happened to be cut.
// The linear version is reproduced here as the control — without it this
// test is green for an ease that ignores `dt` entirely.
// @FIX-012 — an eased value, and why it is not `k · dt`
fn test_an_eased_value_is_frame_rate_independent() {
    ea_k = 6.0;

    ea_one = approach(0.0, 1.0, ea_k, 1.0);
    ea_many = 0.0;
    for _ in 0..60 { ea_many = approach(ea_many, 1.0, ea_k, 1.0 / 60.0); }
    assert(abs(ea_one - ea_many) < 1e-12,
           "one frame of 1 s gave {ea_one} and sixty of 1/60 s gave {ea_many}");

    // The control: the linear form a consumer reaches for instead.
    el_one = 0.0 + (1.0 - 0.0) * min(ea_k * 1.0, 1.0);
    el_many = 0.0;
    for _ in 0..60 { el_many = el_many + (1.0 - el_many) * min(ea_k / 60.0, 1.0); }
    assert(abs(el_one - el_many) > 1e-3,
           "the linear form agreed to {abs(el_one - el_many)} — this gate cannot tell "
           + "the two forms apart, so its green means nothing");

    // ⚠ A frame with no time in it moves nothing, which is what a PAUSED
    // consumer hands down.
    assert(ease_fraction(ea_k, 0.0) == 0.0, "a zero-length frame closed part of the gap");
}

// ⚠⚠ **A real key set reaches the wrong-way swing.**  A bearing of 360°
// and a target of 60° is a true turn of 60°; eased as plain numbers it
// is −300°, and the value spins five sixths of a circle the wrong way
// while looking correct the whole time.
// @FIX-013 — easing an ANGLE, the short way round
fn test_easing_an_angle_takes_the_short_way() {
    an_turn = wrap_delta(60.0 - 360.0);
    assert(an_turn == 60.0, "the short way from 360 to 60 is {an_turn} degrees");

    // Eased, it arrives by going UP through 0 rather than down through 180.
    an_now = 360.0;
    for _ in 0..30 { an_now = approach_angle(an_now, 60.0, 6.0, 1.0 / 60.0); }
    assert(an_now > 0.0 && an_now < 60.0,
           "half a second of easing reached {an_now} degrees — outside (0, 60) means it "
           + "went the long way");

    // ⚠ And the answer stays in [0, 360): an angle that drifted negative
    // would compare wrongly against every other bearing in the consumer.
    assert(wrap_deg(-90.0) == 270.0, "wrap_deg(-90) is {wrap_deg(-90.0)}");
}

tests/02-literals.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// A `Timer` and a `TickClock` are built by partial literal OUTSIDE this library — the zero
// defaults are part of their contract (`tick_timer.loft` § Timer, `tick_clock.loft` § the rate):
// an omitted `total` is an unarmed timer, an omitted rate runs unscaled.  So every field is `pub`.
use fixstep::*;

fn test_a_defaulted_timer_is_unarmed() {
  t = Timer { };
  assert(!timer_running(t), "a defaulted timer is not running");
}

fn test_a_clock_from_a_partial_literal_runs_unscaled() {
  c = TickClock { step: 10 };
  assert(clock_advance(c, 35) == 3, "35 units buy 3 steps of 10, got a different count");
  assert(clock_rate_den(c) == 1, "and reads as unscaled");
}