PROCITY/web/js/world/magpie.js
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it,
a magpie owns one street for six weeks of the year, and six point sources put a life off-screen.

MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`,
:8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre
spine steps at yaw 0, each frame fully streamed, through the shell's own composer:

  worst draws  224 -> 225   (+1)
  worst tris   89,502 -> 93,298   (+3,796 of ~75k spare)
  sum draws over 102 frames  16,676 -> 16,663   (-13)

The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with
them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time.
`?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw
/ +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws.

§1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION.
`townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key,
so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD
mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path.
The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome
on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`,
so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns.
`gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had;
making the slot addressable fixed it at the root, and both are selected now. E's three new grounds
landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for.

§1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a
yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a
hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands).

THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted
mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem
instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument
is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37
chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin.
Zero on every real town (no hoists ⇒ no mesh) and zero at night.

§1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the
road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m
from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never
mentions it. `swoopPos()` is exported and pure. The hat stays cut.

MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a
CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from
swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on
(citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days
out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%.

§1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff,
AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a
limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot;
3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability
control — one manifest key renamed away, served from a third port — makes that source report
`no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors.

TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256
instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height,
and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson
relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to
furniture.js's own streetlight rule.

LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive
is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The
`magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision
IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch.
furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000.

Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38.
0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets,
magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones.

Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
2026-08-03 18:53:26 +10:00

334 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane B — magpie.js (v8 WAVE 1 §1.3 — THE MAGPIE)
//
// For about six weeks of the game's year, one bird on one street will not let you walk past it.
//
// ── WHAT IT COSTS ───────────────────────────────────────────────────────────────────────────────
// ONE town-wide `InstancedMesh`, `count` 0 or 1 ⇒ **+1 draw while a bird is on screen, exactly 0
// otherwise** (three.module.js:2193-2199 returns before the GL call at count 0; `render.calls++` is
// downstream at :4094 — the charter's own vendored-line verification). Geometry is a merged
// primitive bird, **154 triangles** against the ≤900 allowance. There is no atlas, no GLB, no fetch:
// procedural by construction, so `?noassets` is the same code path as everything else.
//
// ── TERRITORY SCALES WITH THE ROAD GRAPH (Fable's R38 binding correction 4) ─────────────────────
// A fixed handful of anchors is the trap: the synthetic default has ~6.8 km of street and
// `bendigo_real` has ~188 km across 2,593 edges, so a capacity-4 magpie on a real town is a swooping
// town where you never meet the magpie. Territories are therefore derived by **road length** — one
// per `TERRITORY_SPACING` metres — and only the NEAREST one is ever instantiated, refreshed off
// `chunks.onChunkBuilt` (the seam R37 §0.4 wired for exactly this) so the cap is spent where the
// player is. One bird at a time, on roughly one street per 1.2 km, for six weeks of the year.
// The 1-per-1.2 km figure is the *swooping* density, not the magpie density: territories are ~24 ha
// so a suburb is thick with magpies, and ~90% of males never swoop — so the sparse layer IS the 10%.
//
// ── THE HAT IS CUT ──────────────────────────────────────────────────────────────────────────────
// The ice-cream-container helmet is not here and must not come back without a street verb: `index.html`
// binds `] [ P M T` plus a click that enters a shop, and nothing else. (Charter cut list; R38
// binding correction 4.)
//
// ── THE LATCH IS RUNTIME-ONLY ───────────────────────────────────────────────────────────────────
// "It got me today" is a plain field on this module's state. It is cleared when the day rolls and it
// is **never written to localStorage** — the delta law says a v8 content layer does not grow the save
// file, and F's gate for this item is exactly "the day-roll clears the latch and localStorage gained
// no new key". There is no persistence code in this file to go wrong.
//
// ── POSITION IS A PURE FUNCTION ─────────────────────────────────────────────────────────────────
// `swoopPos(launch, t)` is exported and side-effect-free — the tram's proven contract (R9/R21), the
// thing that makes a moving object gate-checkable at all. The only stateful part is *when* a launch
// starts, which is recorded as three numbers.
//
// ── LANE E'S 894-TRI `magpie` GLB: NOT ADOPTED THIS ROUND, AND THE REASON IS THE WHITE ──────────
// E shipped it mid-round with a caveat traced (not guessed) to the operator's PBR bake: **it reads
// as a CROW** — the white nape, wing bar and tail are gone, and E confirms the loss is already in
// the raw 500k mesh, so our decimation is exonerated. Measured against what is in this file:
// procedural bird **182 tris**, white nape + rump + wing bars baked as vertex colours, 0 fetches
// E's GLB **894 tris** (4.9×), no white, +1 fetch, +1 material
// A magpie you see for 1.2 seconds in your peripheral vision at speed IS its white bar — that black
// -and-white flash is the entire read. A uniformly dark bird is a currawong or a crow, and the joke
// this item exists for does not land. So **§1.3 does not hold for the tint; the GLB does.** When E
// hand-tints the 512² atlas, the swap is `birdGeometry()` / `birdMaterial()` below — two factory
// singletons, one call site each, the same use-if-ready shape `furniture.js` uses for the depot
// props. Deliberately NO speculative `loadGLB` here: the asset is not in the manifest, and a boot
// that probes for a file nobody has shipped is the blind probe R26 spent a round killing.
//
// The other two Lane E R38 props are a decided NO, on arithmetic rather than taste: `paling_fence`
// is 1,121 tris and this town wants **905 fence runs** (5 × 181 lots) ⇒ **1.01 M triangles** against
// ~75 k spare, and it cannot ride `boxMats` (BoxGeometry, one shared untextured shell material), so
// it would also pay a draw. `water_tank` at 500 tris replaces a 24-tri pair. The primitives are not
// a stopgap here, they are the answer. `hills_hoist` and `tv_aerial` were killed outright by E's own
// spike (thin wire returns as thousands of disconnected fragments; a voxel remesh coarse enough for
// 600 tris erases the pole) — so those two are permanently primitive, which is what shipped.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { rng, frange } from '../core/prng.js';
import { resolveEdges } from './planutil.js';
import { applyWind } from './wind.js';
const TERRITORY_SPACING = 1200; // metres of street per swooping bird (see header)
const TERRITORY_MAX = 400; // sanity cap on the candidate list (bendigo lands ~157)
const DEFEND_R = 34; // he starts at the edge of his patch, not across the suburb
const PERCH_Y = 4.32; // on the streetlight arm (furniture.js: pole to 4.2, arm at 4.1)
const SWOOP_T = 1.25, RETURN_T = 1.9, COOLDOWN = 5.5;
const WING_SPAN = 0.34; // the `wing` wind mode's reference half-span
// ── the season, and the vacuous version of it that this file shipped first ──────────────────────
// A 364-day year (the game already runs a 7-day week: `weekNightOf(day) = (day-1) % 7`), swooping
// from ~1 August for six weeks — the real window, 42/364 = 11.5% of the year. Straight, that is a
// **feature nobody will ever see**: day 1 is January, a game day is 240 seconds, and no playtest has
// reached day 212. So the year carries a seeded offset — you arrive in a town and it is whatever
// time of year it is THERE.
//
// **The first cut of that offset was `rng(citySeed, 'swoopseason', 0)` and it was VACUOUS — measured,
// not suspected.** `plan.citySeed` is the `?seed=` value and **the town key is deliberately NOT in
// it** (plan_osm.js:515 keeps it off the plan so the Melbourne golden stays frozen), so all 23 real
// towns share one seed, therefore one offset, therefore ONE answer: on the default seed, **24 of 24
// towns were 214 days from swooping season**. The mechanism produced a constant. A magpie nobody can
// meet is the same as no magpie, and it would have gated green because `?magpie=1` forces it.
//
// The offset is therefore keyed on **(citySeed, townKey)**: 23 towns, 23 positions in the year. The
// synthetic default has no town key and uses its own arm, so `?seed=` still moves it. Measured over
// the shipped corpus below — see LANE_B_NOTES §38 for the count that is actually in season.
// `?magpie=1` forces the season open for gates and for John.
const YEAR_DAYS = 364, SEASON_START = 212, SEASON_LEN = 42;
export function seasonOffset(citySeed, townKey = null) {
return Math.floor(rng(citySeed >>> 0, 'swoopseason:' + (townKey || 'synthetic'), 0)() * YEAR_DAYS);
}
export function inSwoopSeason(day, citySeed, townKey = null) {
const yd = (((day | 0) - 1 + seasonOffset(citySeed, townKey)) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
return yd >= SEASON_START && yd < SEASON_START + SEASON_LEN;
}
/** Days until this town's season opens (0 while it is open). Diagnostic for the notes + gates. */
export function daysToSeason(day, citySeed, townKey = null) {
if (inSwoopSeason(day, citySeed, townKey)) return 0;
const yd = (((day | 0) - 1 + seasonOffset(citySeed, townKey)) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS;
return yd < SEASON_START ? SEASON_START - yd : YEAR_DAYS - yd + SEASON_START;
}
/**
* swoopPos(launch, t) → [x, y, z] — PURE. `launch` is {ax,ay,az, tx,ty,tz, back:boolean}; `t` is
* 0..1 through the leg. Out along a parabola that crests above the player and drops onto the back of
* the head; back on a lazier, higher arc. No module state is read; no state is written.
*/
export function swoopPos(launch, t) {
const u = Math.max(0, Math.min(1, t));
const { ax, ay, az, tx, ty, tz } = launch;
const x = ax + (tx - ax) * u;
const z = az + (tz - az) * u;
const lift = launch.back ? 2.6 : 1.25;
const y = ay + (ty - ay) * u + lift * 4 * u * (1 - u); // the arc, crest at u = 0.5
return [x, y, z];
}
// ── the bird ────────────────────────────────────────────────────────────────────────────────────
// Merged primitives with baked vertex colours: black body/head/wings, white nape, white wing bar,
// white rump. 154 triangles. One geometry, one material, one InstancedMesh.
const BLACK = new THREE.Color('#191a1c'), WHITE = new THREE.Color('#e6e4dd'), BILL = new THREE.Color('#b9bcc0');
function paint(geo, color) {
const n = geo.attributes.position.count;
const c = new Float32Array(n * 3);
for (let i = 0; i < n; i++) { c[i * 3] = color.r; c[i * 3 + 1] = color.g; c[i * 3 + 2] = color.b; }
geo.setAttribute('color', new THREE.BufferAttribute(c, 3));
return geo;
}
let _geo = null;
function birdGeometry() {
if (_geo) return _geo;
const parts = [];
// body — nose along Z (three's forward), so a lookAt-style basis points the bird where it flies
parts.push(paint(new THREE.SphereGeometry(0.085, 8, 6).scale(1, 0.9, 1.75), BLACK));
parts.push(paint(new THREE.SphereGeometry(0.055, 6, 5).translate(0, 0.035, -0.14), BLACK)); // head
parts.push(paint(new THREE.ConeGeometry(0.021, 0.08, 5).rotateX(-Math.PI / 2).translate(0, 0.03, -0.22), BILL));
parts.push(paint(new THREE.BoxGeometry(0.052, 0.014, 0.055).translate(0, 0.06, -0.10), WHITE)); // nape
parts.push(paint(new THREE.BoxGeometry(0.11, 0.02, 0.13).translate(0, 0.055, 0.10), WHITE)); // rump/back
parts.push(paint(new THREE.BoxGeometry(0.05, 0.022, 0.30).translate(0, 0.0, 0.26), BLACK)); // tail
// wings: flat, spanning ±WING_SPAN in x — the `wing` wind weight is |x| / WING_SPAN
for (const s of [-1, 1]) {
parts.push(paint(new THREE.PlaneGeometry(WING_SPAN, 0.15).rotateX(-Math.PI / 2)
.translate(s * WING_SPAN / 2, 0.045, -0.01), BLACK));
parts.push(paint(new THREE.PlaneGeometry(WING_SPAN * 0.42, 0.05).rotateX(-Math.PI / 2)
.translate(s * WING_SPAN * 0.72, 0.048, 0.03), WHITE)); // the wing bar
}
for (const p of parts) { p.deleteAttribute('uv'); if (!p.attributes.normal) p.computeVertexNormals(); }
_geo = mergeGeometries(parts, false);
parts.forEach((p) => p.dispose());
return _geo;
}
let _mat = null;
function birdMaterial() {
if (_mat) return _mat;
_mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0, side: THREE.DoubleSide });
applyWind(_mat, { topY: WING_SPAN, mode: 'wing' }); // R38: the |x|-weighted beat; exactly 0 at dead calm
return _mat;
}
/**
* createMagpie({ scene, plan, citySeed, camera, chunks, force }) → { group, update, dispose, state }
* `force` (?magpie=1) forces the season open. The shell never constructs this under ?classic=1 or
* ?magpie=0, so both are byte-identical by absence.
*/
export function createMagpie({ scene, plan, citySeed, townKey = null, camera, chunks, lighting, force = false }) {
const group = new THREE.Group(); group.name = 'magpie';
const state = {
territories: 0, roadKm: 0, active: null, mode: 'absent', swoopsToday: 0,
gotYouToday: false, day: 1, dayRolls: 0, inSeason: false, forced: !!force, launches: 0,
// Diagnostics so "is there a magpie in this town?" is answerable without a 200-day soak.
townKey, seasonOffset: seasonOffset(citySeed >>> 0, townKey),
get daysToSeason() { return daysToSeason(this.day, citySeed >>> 0, townKey); },
};
// ── candidate territories, scaled to the graph ──
const edges = resolveEdges(plan);
let total = 0;
for (const e of edges) total += Math.hypot(e.bx - e.ax, e.bz - e.az);
state.roadKm = +(total / 1000).toFixed(2);
const want = Math.max(1, Math.min(TERRITORY_MAX, Math.round(total / TERRITORY_SPACING)));
// Walk the graph accumulating length and drop an anchor every `step` metres — length-proportional,
// so it is the ROAD GRAPH that sets the count, not the edge count (a real town's edges are short
// and numerous; the synthetic town's are few and long, and both must land somewhere sane).
const step = total / want;
const terr = [];
let acc = 0, next = step * 0.5;
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az, len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len, px = -uz, pz = ux;
while (next < acc + len && terr.length < TERRITORY_MAX) {
// HE PERCHES ON A POLE THAT EXISTS. The first cut put the anchor at an arbitrary distance
// along the edge and the bird hovered in open air over the road — every number green, the
// picture wrong, which is precisely R37's lesson and it took a screenshot to see it again.
// `furniture.js` plants streetlights at `s = 8, 26, 44, …` (every 18 m from 8), alternating
// sides starting at +1, offset `width/2 + 0.7` — so snap to the NEAREST of those and reuse the
// same side rule. Same literal in two files is the R27 trap, so if the cadence ever moves this
// is the second place to change; the alternative (reading live furniture instances) can't work
// because territories are chosen before the chunk that holds the pole is built.
const raw = next - acc;
const k = Math.max(0, Math.round((raw - 8) / 18));
const s = Math.min(Math.max(8, 8 + k * 18), Math.max(8, len - 4));
const side = (k % 2 === 0) ? 1 : -1;
const r = rng(citySeed >>> 0, 'magpie', terr.length);
const off = (e.width / 2 + 0.7) * side;
terr.push({
// half a metre out along the lamp arm, which reaches over the road
x: e.ax + ux * s + px * off * 0.88, z: e.az + uz * s + pz * off * 0.88,
y: PERCH_Y, yaw: frange(r, -Math.PI, Math.PI), edgeId: e.id, poleS: s,
seed: (citySeed ^ (terr.length * 2654435761)) >>> 0,
});
next += step;
}
acc += len;
}
state.territories = terr.length;
const mesh = new THREE.InstancedMesh(birdGeometry(), birdMaterial(), 1);
mesh.name = 'magpie-bird';
mesh.castShadow = false; mesh.receiveShadow = false;
mesh.frustumCulled = false; // one instance, moving fast, town-wide bounds — culling it is wrong
mesh.count = 0; // 0 ⇒ three returns before the GL call ⇒ exactly 0 draws
group.add(mesh);
scene.add(group);
// ── proximity-first territory selection, refreshed on the R37 §0.4 chunk seam ──
let dirty = true, home = null;
const markDirty = () => { dirty = true; };
const offBuilt = chunks && chunks.onChunkBuilt ? chunks.onChunkBuilt(markDirty) : () => {};
const offGone = chunks && chunks.onChunkDisposed ? chunks.onChunkDisposed(markDirty) : () => {};
function pickHome(px, pz) {
let best = null, bd = Infinity;
for (const t of terr) { const d = (t.x - px) ** 2 + (t.z - pz) ** 2; if (d < bd) { bd = d; best = t; } }
home = best; state.active = best ? { x: +best.x.toFixed(2), z: +best.z.toFixed(2), edgeId: best.edgeId } : null;
}
// ── the swoop ──
let phase = 'perched', tPhase = 0, cool = 0, launch = null;
const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(1, 1, 1);
const _fwd = new THREE.Vector3(), _prev = new THREE.Vector3(), _look = new THREE.Matrix4();
const _upv = new THREE.Vector3(0, 1, 0);
let lastPos = null;
function seasonOpen() {
const day = (typeof window !== 'undefined' && window.PROCITY && window.PROCITY.game)
? (window.PROCITY.game.day | 0) : 1;
if (day !== state.day) {
// THE DAY ROLL CLEARS THE LATCH. No storage is touched here or anywhere in this file.
// It also puts him back on his pole: you slept, it is dawn, and a swoop cannot be "in
// progress" across a night. Without that reset the latch clears and is then re-set half a
// second later by the leg that was already in the air — which is exactly what the first run
// of this measurement showed (`swoopsToday` 1 → 0 but `gotYouToday` still true), and it would
// have made F's gate read as a failed clear when the clear had in fact worked.
state.day = day; state.gotYouToday = false; state.swoopsToday = 0; state.dayRolls++;
phase = 'perched'; tPhase = 0; cool = COOLDOWN; launch = null; lastPos = null;
}
state.inSeason = force || inSwoopSeason(day, citySeed >>> 0, townKey);
return state.inSeason;
}
// `folded` squashes the instance in X. The wings are baked into the one shared geometry (they have
// to be — one geometry is what makes this 1 draw), so there is no pose to switch to; scaling the
// ±0.34 m wing planes to ±0.14 m reads as a bird sitting with its wings in, which is what a
// perched magpie looks like and what the first screenshot showed it emphatically was not.
function place(x, y, z, fx, fz, folded = false) {
_s.set(folded ? 0.42 : 1, 1, 1);
_p.set(x, y, z);
_fwd.set(fx, 0, fz);
if (_fwd.lengthSq() < 1e-6) _fwd.set(0, 0, 1);
_fwd.normalize();
_look.lookAt(new THREE.Vector3(0, 0, 0), _fwd, _upv);
_q.setFromRotationMatrix(_look);
mesh.setMatrixAt(0, _m.compose(_p, _q, _s));
mesh.instanceMatrix.needsUpdate = true;
mesh.count = 1;
}
function update(dt) {
if (!terr.length) { mesh.count = 0; state.mode = 'absent'; return; }
const p = camera.position;
if (dirty || !home) { dirty = false; pickHome(p.x, p.z); }
if (!seasonOpen()) { mesh.count = 0; state.mode = 'out-of-season'; return; }
// He roosts at night like every other magpie.
if (lighting && lighting.isNight && lighting.isNight()) { mesh.count = 0; state.mode = 'roosting'; return; }
const d = Math.hypot(home.x - p.x, home.z - p.z);
if (d > DEFEND_R * 2.2) { mesh.count = 0; state.mode = 'far'; return; } // not even in sight
cool = Math.max(0, cool - dt);
if (phase === 'perched') {
state.mode = 'perched';
place(home.x, home.y, home.z, Math.cos(home.yaw), Math.sin(home.yaw), true);
if (d < DEFEND_R && cool <= 0) {
// launch: the target is the player's position AT LAUNCH, frozen — so the whole leg stays a
// pure function of three recorded numbers rather than of a live camera.
launch = { ax: home.x, ay: home.y, az: home.z,
tx: p.x, ty: Math.max(1.15, p.y - 0.35), tz: p.z, back: false };
phase = 'out'; tPhase = 0; state.launches++; state.swoopsToday++;
try { window.PROCITY?.audio?.playSfx?.('magpie-carol', { gain: 1 }); } catch { /* silent-and-happy */ }
}
lastPos = null;
return;
}
tPhase += dt;
const dur = phase === 'out' ? SWOOP_T : RETURN_T;
const u = Math.min(1, tPhase / dur);
const [x, y, z] = swoopPos(launch, u);
if (lastPos) place(x, y, z, x - lastPos[0], z - lastPos[2]);
else place(x, y, z, launch.tx - launch.ax, launch.tz - launch.az);
lastPos = [x, y, z];
state.mode = phase === 'out' ? 'swooping' : 'returning';
if (phase === 'out' && u >= 0.62 && !state.gotYouToday) state.gotYouToday = true; // runtime latch only
if (u >= 1) {
if (phase === 'out') {
launch = { ax: launch.tx, ay: launch.ty, az: launch.tz, tx: home.x, ty: home.y, tz: home.z, back: true };
phase = 'back'; tPhase = 0; lastPos = null;
} else { phase = 'perched'; cool = COOLDOWN; lastPos = null; }
}
}
function dispose() {
offBuilt(); offGone();
mesh.dispose && mesh.dispose();
group.remove(mesh);
scene.remove(group);
// geometry + material are module-shared singletons (one town per session) — not disposed here
}
return { group, update, dispose, state, swoopPos, get count() { return mesh.count; },
get territories() { return terr; } };
}