feat: world trace placement — ruins and markers from past runs
Law of Trace: "nothing disappears without a trace." - WorldTrace ECS component (traceType, sourceRunId, glowPhase) - Death site markers placed at exact death positions (dark red) - Discovery site markers for runs with 3+ discoveries (blue-gray) - Deterministic position derivation from world seed - Traces from current AND previous great cycle included - Biome filtering, map bounds clamping, glow animation - 13 new tests (562 total), all passing Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -93,6 +93,22 @@ export const Boss = {
|
||||
cycleCount: [] as number[], // completed cycles (synced from BossState)
|
||||
};
|
||||
|
||||
// ─── World Trace Components (Great Cycle) ────────────────────────
|
||||
|
||||
/** A trace left by a previous run — ruin, death marker, discovery site */
|
||||
export const WorldTrace = {
|
||||
traceType: [] as number[], // TraceType enum (0=death_site, 1=discovery_site)
|
||||
sourceRunId: [] as number[], // which run left this trace
|
||||
glowPhase: [] as number[], // animation phase for pulsing glow
|
||||
interactRange: [] as number[], // interaction range in pixels
|
||||
};
|
||||
|
||||
/** TraceType enum values */
|
||||
export const TraceType = {
|
||||
DeathSite: 0,
|
||||
DiscoverySite: 1,
|
||||
} as const;
|
||||
|
||||
// ─── Mycelium Components ─────────────────────────────────────────
|
||||
|
||||
/** Fungal node — a point where the Mycelium surfaces on the map */
|
||||
|
||||
194
src/world/traces.ts
Normal file
194
src/world/traces.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* World Trace Placement — places ruins and markers from past runs
|
||||
*
|
||||
* GDD (Law of Trace): "Nothing disappears without a trace;
|
||||
* each cycle leaves an imprint in the next."
|
||||
*
|
||||
* When generating a new world, we check for traces from past runs
|
||||
* in the same biome (from the current and previous great cycle).
|
||||
* Death sites become ruin markers; discovery sites become faded experiment traces.
|
||||
*/
|
||||
|
||||
import { addEntity, addComponent } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { Position, SpriteRef, WorldTrace, TraceType } from '../ecs/components';
|
||||
import type { BiomeData } from './types';
|
||||
import type { RunTrace, GreatCycleState } from '../run/types';
|
||||
import { getTracesForBiome, getDeathTraces } from '../run/cycle';
|
||||
|
||||
/** String data for a world trace entity (not stored in ECS — numeric only there) */
|
||||
export interface WorldTraceInfo {
|
||||
/** Entity ID */
|
||||
eid: number;
|
||||
/** Type of trace */
|
||||
traceType: 'death_site' | 'discovery_site';
|
||||
/** Run that left this trace */
|
||||
sourceRunId: number;
|
||||
/** School used in that run */
|
||||
schoolId: string;
|
||||
/** Key elements discovered (for discovery sites) */
|
||||
keyElements: string[];
|
||||
/** Tile position */
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
}
|
||||
|
||||
/** Visual config for trace markers */
|
||||
const TRACE_CONFIG = {
|
||||
death_site: {
|
||||
color: 0x884444, // dark red — blood/decay
|
||||
radius: 6,
|
||||
interactRange: 40,
|
||||
glowColor: 0xaa3333, // red glow
|
||||
},
|
||||
discovery_site: {
|
||||
color: 0x446688, // blue-gray — knowledge
|
||||
radius: 5,
|
||||
interactRange: 36,
|
||||
glowColor: 0x4488aa, // blue glow
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Spawn world trace entities from past run traces.
|
||||
*
|
||||
* For each trace from the current/previous cycle in this biome:
|
||||
* - Death sites: place a ruin marker at the death position
|
||||
* - Remaining traces: place discovery markers at a position derived from
|
||||
* the run's world seed (since we don't store exact discovery locations)
|
||||
*
|
||||
* @returns Array of WorldTraceInfo for display/interaction
|
||||
*/
|
||||
export function spawnWorldTraces(
|
||||
world: World,
|
||||
cycleState: GreatCycleState,
|
||||
biomeId: string,
|
||||
biome: BiomeData,
|
||||
): WorldTraceInfo[] {
|
||||
const traces = getTracesForBiome(cycleState, biomeId);
|
||||
if (traces.length === 0) return [];
|
||||
|
||||
const result: WorldTraceInfo[] = [];
|
||||
const tileSize = biome.tileSize;
|
||||
const mapW = biome.mapWidth;
|
||||
const mapH = biome.mapHeight;
|
||||
|
||||
// Place death site markers
|
||||
const deathTraces = getDeathTraces(traces);
|
||||
for (const trace of deathTraces) {
|
||||
const pos = trace.deathPosition;
|
||||
if (!pos) continue;
|
||||
|
||||
// Clamp to map bounds
|
||||
const tx = Math.max(0, Math.min(pos.tileX, mapW - 1));
|
||||
const ty = Math.max(0, Math.min(pos.tileY, mapH - 1));
|
||||
|
||||
const eid = createTraceEntity(
|
||||
world, tx, ty, tileSize,
|
||||
TraceType.DeathSite, trace.runId,
|
||||
TRACE_CONFIG.death_site,
|
||||
);
|
||||
|
||||
result.push({
|
||||
eid,
|
||||
traceType: 'death_site',
|
||||
sourceRunId: trace.runId,
|
||||
schoolId: trace.schoolId,
|
||||
keyElements: trace.keyElements,
|
||||
tileX: tx,
|
||||
tileY: ty,
|
||||
});
|
||||
}
|
||||
|
||||
// Place discovery markers for traces with significant discoveries
|
||||
// (Only for traces without death sites, to avoid double-marking)
|
||||
const deathRunIds = new Set(deathTraces.map(t => t.runId));
|
||||
const discoveryTraces = traces.filter(
|
||||
t => !deathRunIds.has(t.runId) && t.discoveryCount >= 3,
|
||||
);
|
||||
|
||||
for (const trace of discoveryTraces) {
|
||||
// Derive a position from the run's seed (deterministic but varied)
|
||||
const tx = deriveTracePosition(trace.worldSeed, 0, mapW);
|
||||
const ty = deriveTracePosition(trace.worldSeed, 1, mapH);
|
||||
|
||||
const eid = createTraceEntity(
|
||||
world, tx, ty, tileSize,
|
||||
TraceType.DiscoverySite, trace.runId,
|
||||
TRACE_CONFIG.discovery_site,
|
||||
);
|
||||
|
||||
result.push({
|
||||
eid,
|
||||
traceType: 'discovery_site',
|
||||
sourceRunId: trace.runId,
|
||||
schoolId: trace.schoolId,
|
||||
keyElements: trace.keyElements,
|
||||
tileX: tx,
|
||||
tileY: ty,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Create a single trace entity with position, sprite, and trace component */
|
||||
function createTraceEntity(
|
||||
world: World,
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
tileSize: number,
|
||||
traceType: number,
|
||||
sourceRunId: number,
|
||||
config: { color: number; radius: number; interactRange: number },
|
||||
): number {
|
||||
const eid = addEntity(world);
|
||||
|
||||
addComponent(world, eid, Position);
|
||||
Position.x[eid] = tileX * tileSize + tileSize / 2;
|
||||
Position.y[eid] = tileY * tileSize + tileSize / 2;
|
||||
|
||||
addComponent(world, eid, SpriteRef);
|
||||
SpriteRef.color[eid] = config.color;
|
||||
SpriteRef.radius[eid] = config.radius;
|
||||
|
||||
addComponent(world, eid, WorldTrace);
|
||||
WorldTrace.traceType[eid] = traceType;
|
||||
WorldTrace.sourceRunId[eid] = sourceRunId;
|
||||
WorldTrace.glowPhase[eid] = Math.random() * Math.PI * 2;
|
||||
WorldTrace.interactRange[eid] = config.interactRange;
|
||||
|
||||
return eid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic tile position from a seed.
|
||||
* Ensures traces from the same run appear in consistent locations.
|
||||
*/
|
||||
function deriveTracePosition(seed: number, axis: number, mapSize: number): number {
|
||||
// Mulberry32-style hash
|
||||
let t = (seed + axis * 0x6d2b79f5 + 0x9e3779b9) | 0;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
const normalized = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
|
||||
// Keep away from edges (10% margin)
|
||||
const margin = Math.floor(mapSize * 0.1);
|
||||
return margin + Math.floor(normalized * (mapSize - 2 * margin));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trace glow animation (pulsing effect).
|
||||
* Called in the game update loop.
|
||||
*/
|
||||
export function updateTraceGlow(
|
||||
traceInfos: WorldTraceInfo[],
|
||||
delta: number,
|
||||
): void {
|
||||
const pulseSpeed = 1.2; // radians per second
|
||||
const deltaSec = delta / 1000;
|
||||
|
||||
for (const info of traceInfos) {
|
||||
WorldTrace.glowPhase[info.eid] += pulseSpeed * deltaSec;
|
||||
}
|
||||
}
|
||||
222
tests/world-traces.test.ts
Normal file
222
tests/world-traces.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createWorld } from 'bitecs';
|
||||
import type { GreatCycleState, RunTrace } from '../src/run/types';
|
||||
import { RunPhase } from '../src/run/types';
|
||||
import { createGreatCycleState } from '../src/run/cycle';
|
||||
import { Position, SpriteRef, WorldTrace, TraceType } from '../src/ecs/components';
|
||||
import { spawnWorldTraces, updateTraceGlow, type WorldTraceInfo } from '../src/world/traces';
|
||||
import biomeData from '../src/data/biomes.json';
|
||||
import type { BiomeData } from '../src/world/types';
|
||||
|
||||
const biomes = biomeData as BiomeData[];
|
||||
const catalyticWastes = biomes.find(b => b.id === 'catalytic-wastes')!;
|
||||
|
||||
function makeTrace(
|
||||
runId: number,
|
||||
biomeId: string,
|
||||
deathPos: { tileX: number; tileY: number } | null,
|
||||
discoveryCount: number = 5,
|
||||
): RunTrace {
|
||||
return {
|
||||
runId,
|
||||
runInCycle: runId,
|
||||
schoolId: 'alchemist',
|
||||
biomeId,
|
||||
deathPosition: deathPos,
|
||||
phaseReached: RunPhase.Resolution,
|
||||
crisisResolved: false,
|
||||
discoveryCount,
|
||||
keyElements: ['Na', 'O'],
|
||||
duration: 60000,
|
||||
worldSeed: runId * 1000 + 42,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── World Trace Spawning ────────────────────────────────────────
|
||||
|
||||
describe('World Trace Spawning', () => {
|
||||
let world: ReturnType<typeof createWorld>;
|
||||
let cycle: GreatCycleState;
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
cycle = createGreatCycleState();
|
||||
});
|
||||
|
||||
it('should return empty for no traces', () => {
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should spawn death site markers', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 20, tileY: 30 }),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].traceType).toBe('death_site');
|
||||
expect(result[0].sourceRunId).toBe(1);
|
||||
expect(result[0].tileX).toBe(20);
|
||||
expect(result[0].tileY).toBe(30);
|
||||
});
|
||||
|
||||
it('death site entities should have correct ECS components', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 15 }),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
const eid = result[0].eid;
|
||||
|
||||
// Position at tile center
|
||||
const tileSize = catalyticWastes.tileSize;
|
||||
expect(Position.x[eid]).toBe(10 * tileSize + tileSize / 2);
|
||||
expect(Position.y[eid]).toBe(15 * tileSize + tileSize / 2);
|
||||
|
||||
// Sprite
|
||||
expect(SpriteRef.color[eid]).toBe(0x884444); // dark red for death site
|
||||
expect(SpriteRef.radius[eid]).toBe(6);
|
||||
|
||||
// Trace component
|
||||
expect(WorldTrace.traceType[eid]).toBe(TraceType.DeathSite);
|
||||
expect(WorldTrace.sourceRunId[eid]).toBe(1);
|
||||
expect(WorldTrace.interactRange[eid]).toBe(40);
|
||||
});
|
||||
|
||||
it('should spawn discovery markers for runs without death sites', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', null, 5), // no death pos, 5 discoveries
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].traceType).toBe('discovery_site');
|
||||
});
|
||||
|
||||
it('should NOT spawn discovery markers for low discovery count', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', null, 2), // only 2 discoveries (< 3 threshold)
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should prefer death marker over discovery marker for same run', () => {
|
||||
// Run with both death position and discoveries
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 20, tileY: 30 }, 10),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
// Should only have death_site, not both
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].traceType).toBe('death_site');
|
||||
});
|
||||
|
||||
it('should include traces from previous cycle', () => {
|
||||
cycle.previousCycleTraces = [
|
||||
makeTrace(5, 'catalytic-wastes', { tileX: 40, tileY: 50 }),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].sourceRunId).toBe(5);
|
||||
});
|
||||
|
||||
it('should filter by biome', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 20 }),
|
||||
makeTrace(2, 'kinetic-mountains', { tileX: 30, tileY: 40 }),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].sourceRunId).toBe(1);
|
||||
});
|
||||
|
||||
it('should clamp death positions to map bounds', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 9999, tileY: 9999 }),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result[0].tileX).toBeLessThan(catalyticWastes.mapWidth);
|
||||
expect(result[0].tileY).toBeLessThan(catalyticWastes.mapHeight);
|
||||
});
|
||||
|
||||
it('should handle multiple traces from multiple runs', () => {
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 10 }),
|
||||
makeTrace(2, 'catalytic-wastes', { tileX: 30, tileY: 30 }),
|
||||
makeTrace(3, 'catalytic-wastes', null, 8), // discovery only
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
const deathSites = result.filter(r => r.traceType === 'death_site');
|
||||
const discoverySites = result.filter(r => r.traceType === 'discovery_site');
|
||||
expect(deathSites.length).toBe(2);
|
||||
expect(discoverySites.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Trace Glow Animation ────────────────────────────────────────
|
||||
|
||||
describe('Trace Glow Animation', () => {
|
||||
it('should advance glow phase over time', () => {
|
||||
const world = createWorld();
|
||||
const cycle = createGreatCycleState();
|
||||
cycle.currentCycleTraces = [
|
||||
makeTrace(1, 'catalytic-wastes', { tileX: 20, tileY: 30 }),
|
||||
];
|
||||
|
||||
const result = spawnWorldTraces(world, cycle, 'catalytic-wastes', catalyticWastes);
|
||||
const initialPhase = WorldTrace.glowPhase[result[0].eid];
|
||||
|
||||
updateTraceGlow(result, 1000); // 1 second
|
||||
const newPhase = WorldTrace.glowPhase[result[0].eid];
|
||||
|
||||
expect(newPhase).toBeGreaterThan(initialPhase);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Discovery Site Position Determinism ─────────────────────────
|
||||
|
||||
describe('Discovery Site Position Determinism', () => {
|
||||
it('same seed should produce same position', () => {
|
||||
const world1 = createWorld();
|
||||
const world2 = createWorld();
|
||||
const cycle1 = createGreatCycleState();
|
||||
const cycle2 = createGreatCycleState();
|
||||
|
||||
const trace = makeTrace(1, 'catalytic-wastes', null, 5);
|
||||
cycle1.currentCycleTraces = [trace];
|
||||
cycle2.currentCycleTraces = [{ ...trace }]; // same data
|
||||
|
||||
const result1 = spawnWorldTraces(world1, cycle1, 'catalytic-wastes', catalyticWastes);
|
||||
const result2 = spawnWorldTraces(world2, cycle2, 'catalytic-wastes', catalyticWastes);
|
||||
|
||||
expect(result1[0].tileX).toBe(result2[0].tileX);
|
||||
expect(result1[0].tileY).toBe(result2[0].tileY);
|
||||
});
|
||||
|
||||
it('different seeds should produce different positions', () => {
|
||||
const world1 = createWorld();
|
||||
const world2 = createWorld();
|
||||
const cycle1 = createGreatCycleState();
|
||||
const cycle2 = createGreatCycleState();
|
||||
|
||||
cycle1.currentCycleTraces = [makeTrace(1, 'catalytic-wastes', null, 5)];
|
||||
cycle2.currentCycleTraces = [makeTrace(99, 'catalytic-wastes', null, 5)]; // different seed
|
||||
|
||||
const result1 = spawnWorldTraces(world1, cycle1, 'catalytic-wastes', catalyticWastes);
|
||||
const result2 = spawnWorldTraces(world2, cycle2, 'catalytic-wastes', catalyticWastes);
|
||||
|
||||
// Very unlikely to be at the same spot with different seeds
|
||||
const sameSpot = result1[0].tileX === result2[0].tileX && result1[0].tileY === result2[0].tileY;
|
||||
expect(sameSpot).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user