[lane B+C] The phage ally: tend the flora and the biome fights for you

The first ALLY, and the reputation loop that earns it — "don't shoot the biome, it helps you"
(V2_IDEAS) made mechanical.

- combat/enemies.js + balance.js: the `phage` archetype. A friendly seeker that hunts the
  nearest FOE (not the player) and rams it — a bacteriophage bursts its host after `bursts`
  hits. Fast (26 > every foe) so it catches things, fragile (hp 8) so it is spent, not a wall.
  Entities now carry a `foe` flag (foes true; flora + phage false); kill() generalised so ANY
  ally scores nothing and never combos. Fallback silhouette is a bare icosahedron = the phage's
  real icosahedral capsid. Teal (EMISSIVE.ally), distinct from cyan "don't shoot" flora.

- combat/index.js: BIOME STANDING — one run-scoped float. flora:tended raises it, flora:harmed
  drops it 25; at the symbiotic threshold the biome BUDS a phage near you (capped 3, 3s cooldown),
  spending standing. Emits `standing {value}` for a future Lane E meter.

- levels/enemies.js: ARCHETYPES += phage; catalogue `phage`.

Verified deterministically in-engine: tending flora budded a phage, which hunted a spore_pod foe
(30hp) and rammed it dead; three-colour readability holds (amber foe / teal ally / cyan friend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 16:36:03 +10:00
parent 5b076aef54
commit 71b0663b77
6 changed files with 92 additions and 12 deletions

View File

@ -180,8 +180,10 @@ Ratified round 2 (round-1 requests):
- `hazard:proximity {kind, distance}` — rear-chaser telemetry (reflux surge); drives E's - `hazard:proximity {kind, distance}` — rear-chaser telemetry (reflux surge); drives E's
rear indicator, C's most important UI dependency. rear indicator, C's most important UI dependency.
- `flora:tended {s}` / `flora:harmed {s}` — a friendly microbe's aura gave the player coat / - `flora:tended {s}` / `flora:harmed {s}` — a friendly microbe's aura gave the player coat /
a friendly microbe was killed by the player (round-2 microbe pass; seeds a future BIOME a friendly microbe was killed by the player (round-2 microbe pass; feeds BIOME STANDING).
STANDING reputation meter — "don't shoot the biome, it helps you in level 5", V2_IDEAS). - `standing {value}` — BIOME STANDING 0..100 (combat/index.js owns it). Tending flora raises it;
harming flora drops it; crossing the threshold buds phage allies. E can meter it. ("Don't
shoot the biome, it helps you" — V2_IDEAS, now partly real.)
## Asset manifest contract (D owns `assets.js` + `manifest.json`) ## Asset manifest contract (D owns `assets.js` + `manifest.json`)

View File

@ -63,6 +63,14 @@ export const BALANCE = {
// first entity authored to be skinned by a Trellis mesh (assets/models/spore_pod.glb). // first entity authored to be skinned by a Trellis mesh (assets/models/spore_pod.glb).
spore_pod: { hp: 30, radius: 2.4, score: 120, drift: 1.4, bob: 0.5, spore_pod: { hp: 30, radius: 2.4, score: 120, drift: 1.4, bob: 0.5,
auraRadius: 4.4, auraDps: 20, auraTick: 0.2, pool: 16 }, auraRadius: 4.4, auraDps: 20, auraTick: 0.2, pool: 16 },
// phage — the ALLY (bacteriophage). A friendly seeker that hunts the nearest FOE and rams
// it (`punch`), bursting after `bursts` hits (a phage lyses its host and dies). FAST (26 >
// every foe) so it actually catches things; FRAGILE (hp 8) so a stray pellet or a foe pops
// it. score 0 — an ally never scores. Budded by high BIOME STANDING (combat/index.js), the
// payoff for tending flora. escort* = where it loiters when no foe is in `huntS` range.
phage: { hp: 8, radius: 1.1, score: 0, speed: 26, turn: 3.2, punch: 20, bursts: 2,
huntS: 70, escortLead: 10, escortR: 5, pool: 8 },
}, },
darts: { pool: 48, radius: 0.45 }, darts: { pool: 48, radius: 0.45 },

View File

@ -44,6 +44,7 @@ const ARCHETYPES = {
cfg: B.enemies.flora, cfg: B.enemies.flora,
geo: () => new THREE.CapsuleGeometry(B.enemies.flora.radius * 0.45, B.enemies.flora.radius * 1.1, 3, 8), geo: () => new THREE.CapsuleGeometry(B.enemies.flora.radius * 0.45, B.enemies.flora.radius * 1.1, 3, 8),
color: EMISSIVE.neutral, color: EMISSIVE.neutral,
foe: false,
}, },
// spore_pod — a foe yeast cluster. Its own pool so its blobby GLB doesn't reskin the // spore_pod — a foe yeast cluster. Its own pool so its blobby GLB doesn't reskin the
// food-debris floater; behaviour shares the floater branch (drift + corrosive aura). // food-debris floater; behaviour shares the floater branch (drift + corrosive aura).
@ -52,6 +53,16 @@ const ARCHETYPES = {
geo: () => new THREE.IcosahedronGeometry(B.enemies.spore_pod.radius, 1), geo: () => new THREE.IcosahedronGeometry(B.enemies.spore_pod.radius, 1),
color: EMISSIVE.hostile, color: EMISSIVE.hostile,
}, },
// phage — the ALLY. A friendly seeker that hunts the nearest FOE and rams it (a bacteriophage
// bursts its host). Teal, distinct from cyan flora. Budded by high BIOME STANDING. Fallback
// silhouette is a bare icosahedron = the phage's real icosahedral capsid (a Hunyuan mesh with
// tail fibres is the eventual upgrade).
phage: {
cfg: B.enemies.phage,
geo: () => new THREE.IcosahedronGeometry(B.enemies.phage.radius, 0),
color: EMISSIVE.ally,
foe: false,
},
}; };
export function createEnemies({ scene, world, bus, rng, assets = null }) { export function createEnemies({ scene, world, bus, rng, assets = null }) {
@ -77,7 +88,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.count = 0; mesh.count = 0;
scene.add(mesh); scene.add(mesh);
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [] }; pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false };
} }
// --- darts: the turret's homing projectile (hostile => white-hot) ------------------ // --- darts: the turret's homing projectile (hostile => white-hot) ------------------
@ -122,7 +133,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
: (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded : (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded
const e = { const e = {
id: nextId++, type, cfg, id: nextId++, type, cfg, foe: pool.foe,
s, x: Math.sin(th) * r, y: Math.cos(th) * r, // theta = atan2(x, y), per the frame convention s, x: Math.sin(th) * r, y: Math.cos(th) * r, // theta = atan2(x, y), per the frame convention
vs: 0, vx: 0, vy: 0, vs: 0, vx: 0, vy: 0,
hp: cfg.hp, radius: cfg.radius, alive: true, hp: cfg.hp, radius: cfg.radius, alive: true,
@ -148,10 +159,11 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
function kill(e, kind) { function kill(e, kind) {
e.alive = false; e.alive = false;
if (e.type === 'flora') { if (!e.foe) {
// Shooting a friendly commensal: no score, no combo — and a signal for the (future) // An ally died (flora or phage): never scores, never combos. Flora killed by the player's
// BIOME STANDING meter. "Don't shoot the biome", made mechanical. // own fire (not 'spent') signals the BIOME STANDING meter — "don't shoot the biome", made
bus.emit('flora:harmed', { s: e.s }); // mechanical. A phage just bursts ('spent') or is popped, quietly.
if (e.type === 'flora' && kind !== 'spent') bus.emit('flora:harmed', { s: e.s });
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, kind }); bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, kind });
bus.emit('audio:cue', { name: 'enemy_die' }); bus.emit('audio:cue', { name: 'enemy_die' });
onDeath?.(e); onDeath?.(e);
@ -236,6 +248,33 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
bus.emit('flora:tended', { s: e.s }); // for the (future) BIOME STANDING meter bus.emit('flora:tended', { s: e.s }); // for the (future) BIOME STANDING meter
} }
} }
} else if (e.type === 'phage') {
// The ALLY. A friendly seeker that hunts the nearest FOE (not the player) and rams it —
// a bacteriophage bursts its host. No foe in range => it escorts, easing to a standoff
// point that orbits just ahead of the player. ponytail: nearest-foe is an O(phages·live)
// scan; with pool 8 and ~30 live it is ~free — swap to forEachNear only if it ever bites.
let target = null, best = cfg.huntS * cfg.huntS;
for (const o of live) {
if (!o.alive || !o.foe) continue; // hunt foes only, never flora/other phages
const d2 = (o.s - e.s) ** 2 + (o.x - e.x) ** 2 + (o.y - e.y) ** 2;
if (d2 < best) { best = d2; target = o; }
}
e.phase += dt * 1.5; // slow orbit while escorting
const tS = target ? target.s : ps.s + cfg.escortLead;
const tX = target ? target.x : ps.x + Math.cos(e.phase) * cfg.escortR;
const tY = target ? target.y : ps.y + Math.sin(e.phase) * cfg.escortR;
const ds = tS - e.s, dx = tX - e.x, dy = tY - e.y;
const d = Math.hypot(ds, dx, dy) || 1e-5;
const k = Math.min(1, cfg.turn * dt);
e.vs += ((ds / d) * cfg.speed - e.vs) * k;
e.vx += ((dx / d) * cfg.speed - e.vx) * k;
e.vy += ((dy / d) * cfg.speed - e.vy) * k;
e.s += e.vs * dt; e.x += e.vx * dt; e.y += e.vy * dt;
if (target && d < cfg.radius + target.radius) {
hurt(target, cfg.punch, 'phage'); // ram it
onBlast?.(e.pos, 1.2, 0.2);
if ((e.hits = (e.hits || 0) + 1) >= cfg.bursts) { kill(e, 'spent'); continue; }
}
} else if (e.type === 'seeker') { } else if (e.type === 'seeker') {
// pursue in spline space — a chase for three float subtractions // pursue in spline space — a chase for three float subtractions
const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y; const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y;
@ -356,7 +395,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
let n = 0; let n = 0;
for (const e of pool.slots) { for (const e of pool.slots) {
if (!e.alive) continue; if (!e.alive) continue;
if (type === 'seeker') { if (type === 'seeker' || type === 'phage') { // hunters point along their heading
_dir.set(e.vs, e.vx, e.vy); _dir.set(e.vs, e.vx, e.vy);
_q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD); _q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD);
} else { } else {

View File

@ -71,7 +71,27 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
score.samples += e.sample ?? 0; score.samples += e.sample ?? 0;
}); });
// BIOME STANDING — the flora reputation loop (V2_IDEAS: "don't shoot the biome, it helps you
// in level 5"). Tend flora (fly through its aura -> flora:tended) to earn it; shoot flora
// (flora:harmed) to lose a chunk. At the symbiotic threshold the biome BUDS phage allies near
// you that hunt your foes — the payoff. A `standing` event feeds a future HUD meter (Lane E).
// ponytail: one run-scoped float, not a system. Persistence/tiers/L5-gate come when C needs them.
let standing = 0, phageBudCd = 0;
const setStanding = (v) => { standing = Math.max(0, Math.min(100, v)); bus.emit('standing', { value: standing }); };
const offTended = bus.on('flora:tended', () => { setStanding(standing + 2); budPhage(); });
const offHarmed = bus.on('flora:harmed', () => setStanding(standing - 25));
function budPhage() {
if (standing < 40 || phageBudCd > 0) return;
if (enemies.live.filter((e) => e.type === 'phage' && e.alive).length >= 3) return; // ally cap
if (enemies.spawn('phage', { s: player.state.s + 8 })) {
setStanding(standing - 20); // spend standing to bud
phageBudCd = 3; // seconds between buds (decremented below)
bus.emit('audio:cue', { name: 'pickup' }); // a friendly chirp when an ally is born
}
}
function update(dt) { function update(dt) {
if (phageBudCd > 0) phageBudCd -= dt;
enemies.update(dt, player); enemies.update(dt, player);
weapons.update(dt); weapons.update(dt);
hazards.update(dt); hazards.update(dt);
@ -92,7 +112,7 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
// pump never re-fires past events, so nothing double-spawns. // pump never re-fires past events, so nothing double-spawns.
reset(fromS = 0) { hazards.reset(fromS); }, reset(fromS = 0) { hazards.reset(fromS); },
dispose() { dispose() {
offEvent(); offDie(); offPickup(); offEvent(); offDie(); offPickup(); offTended(); offHarmed();
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose(); enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
}, },
}; };

View File

@ -16,6 +16,9 @@ export const EMISSIVE = {
hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core
hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot
neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive) neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive)
ally: { core: 0x33ffbe, rim: 0xd6fff0 }, // teal — an active friendly unit (phage).
// distinct from cyan "don't shoot" flora:
// this one HELPS, and reads as its own thing.
pickup: { core: 0x7dffb0, rim: 0xdaffe9 }, pickup: { core: 0x7dffb0, rim: 0xdaffe9 },
gate: { core: 0xb06aff, rim: 0xe6d0ff }, gate: { core: 0xb06aff, rim: 0xe6d0ff },
acid: { core: 0xc8ff3a, rim: 0xeaffb0 }, acid: { core: 0xc8ff3a, rim: 0xeaffb0 },

View File

@ -20,8 +20,9 @@
// Pure data + pure functions: no imports, no THREE. Must stay node-runnable (qa selfcheck). // Pure data + pure functions: no imports, no THREE. Must stay node-runnable (qa selfcheck).
/** The archetypes Lane B implements. Selfcheck enforces membership. /** The archetypes Lane B implements. Selfcheck enforces membership.
* Round-2 microbe pass added `flora` (the first friend) and `spore_pod` (a distinct-mesh foe). */ * Round-2 microbe pass added `flora` (the first friend), `spore_pod` (a distinct-mesh foe),
export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod']; * and `phage` (the first ally a friendly seeker that hunts foes). */
export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod', 'phage'];
/** /**
* Fields: * Fields:
@ -92,6 +93,13 @@ export const ENEMIES = Object.freeze({
'Mechanically a floater, but its own archetype so it carries a distinct blobby ' + 'Mechanically a floater, but its own archetype so it carries a distinct blobby ' +
'silhouette: the first foe authored to be skinned by a Trellis mesh.', 'silhouette: the first foe authored to be skinned by a Trellis mesh.',
}, },
phage: {
archetype: 'phage', biome: '*', tint: 0x33ffbe, wall: false,
note: 'Bacteriophage — the most abundant biological entity in you; hunts one bacterial ' +
'species and bursts it. THE ALLY: a friendly seeker that chases your FOES, not you ' +
'(teal, not the cyan "don\'t shoot" flora). Budded by high BIOME STANDING — the payoff ' +
'for tending flora instead of spraying it. Placeable by C, but mainly biome-spawned.',
},
// ---- passthrough: bare archetype names -------------------------------------------- // ---- passthrough: bare archetype names --------------------------------------------
// Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and // Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and