Diurnal pattern-of-life (#13) & the pre-contact "calm before" tell (#6) ·
lib/sim/ai/civilian.ts · 2026-06-06
Two things were missing from the soul of the valley. First, it never slept — villagers wandered 24/7 at constant population, day and night identical. Second, the flagship counterinsurgency tell the tutorial explicitly teaches — civilians melting away before an ambush — did not exist in the engine. A patient player had nothing to read. Both are fixed, deterministically, off data the simulation already computes, with zero new saved state.
Everything below is produced by a headless probe you can run yourself —
scripts/atmospherics-probe.ts. It does not trust the same clock the brain reads: the
brain only sees time through sim.light, while the probe reads ground-truth time straight
from world.secondsOfDay (an independent oracle). "Indoors" isn't a guess either — it's the
exact snapped home point the mover actually walks to.
The World writes sim.light (ambient 0–1) every tick from the solar clock. The brain
keys the outdoor occupancy to that light — a pure function of how bright it is:
The clever part is the restraint: occupancy is keyed to the magnitude of the light, which is symmetric across dawn and dusk — so the brain never has to tell a sunrise from a sunset. We model the thing that's seen and measured (how many are out), not the event.
The outdoor curve, by the hour, before → after (survey-7, the tuned seed):
| Hour | Light | Before (flat) | After | Reads as |
|---|---|---|---|---|
| 06:00 | 0.53 | 100% | 46% | dawn — emerging |
| 12:00 | 1.00 | 100% | 83% | full pattern of life |
| 19:00 | 0.15 | 100% | 33% | dusk — melting in |
| 00–04 | 0.05 | 100% | 0% | indoors, valley asleep |
survey-43, 50 civilians) the
deep-night floor is ~13% of the population, not zero. That is not a bug in the rhythm:
those villagers are correctly fleeing/clearing insurgents infiltrating the draws after dark
(verified — an armed fighter 9–15 m from each of them). The gate accepts that real floor: night ≤ 22%
of population and ≤ 35% of midday. Both seeds pass.
In the same single pass the brain already makes over nearby armed men, it now also flags
staged fighters: an ambush cell holding its fire (brainState === "ambush")
or an infiltrator moving concealed ("patrolling" + technique === "concealed")
— alive, but not yet shooting — out to a 150 m sense radius (deliberately wider
than the 45 m "they're on top of us" ring, so the gentle melt fires first). Sensed
villagers quietly walk home, children first, and their departures are
staggered by a seeded dice roll so the fields thin over a few seconds rather
than teleporting empty. That thinning is the absence an alert player learns to read.
Measured 90 s before any round is fired (proven by asserting zero muzzle/blast effects during the window):
| Seed | Threatened cohort closes its distance home | Control cohort | Shots fired | Children |
|---|---|---|---|---|
| survey-7 (tuned) | 56% (4 → 2 out) | 0% — barely moves | 0 | lead |
| survey-43 (held-out) | 48% (18 → 9 out) | 24% | 0 | kids halve distance at t=42 s, before adults |
The control cohort — outdoor civilians who can't sense any staged cell — staying put is what proves the drop is the signal, not the clock.
Every decision derives only from the unit-id hash (trait()), the seeded
sim.rng, the deterministic sim.light, and sim unit state — never
Math.random or the wall clock. The probe re-runs each seed twice and asserts an
identical civilian-position fingerprint. No new field was added to the unit, so the
save round-trip is byte-identical.
npx tsc --noEmit — cleannpx tsx scripts/smoke.ts — SMOKE OK (serialize round-trip + no-NaN)npm run build — clean · npm run lint — exit 0npx tsx scripts/balance.ts 12 50 — no stall, civilian casualties 0 (the diurnal/melt
logic doesn't strand anyone or spike tick cost — the home snap is computed lazily, so daylight pays nothing)atmospherics-probe — OK on both modes, tuned and held-outA typed sim.dayFrac seam would be the cleanest way to tell a dawn event from a
dusk event — but it needs editing files this wave doesn't own, and the occupancy design
genuinely doesn't require it. A future feature that truly needs the distinction (roosters at dawn only,
say) should add that sanctioned seam — not a render-side or WeakMap hack,
which would break serialize-replay determinism.
The full per-hour tables and gate lines are saved alongside this report:
diurnal-survey-7.txt, melt-survey-7.txt, and the two
…-survey-43-holdout.txt files. The diurnal gate block from the tuned seed:
── GATES ── night(00-04) outdoor avg 0.0 = 0.0% of pop, 0.0% of midday [pop<=22% & midday<=35%] PASS midday(12) outdoor 20/24 = 83% [>=60%] PASS rising edge: dawn(06) 11 < midday 20 PASS falling edge: dusk(19) 8 < midday 20 PASS DETERMINISM (diurnal): two same-seed runs civ-pos-hash 2884555957 vs 2884555957 PASS ATMOSPHERICS-PROBE OK