diff --git a/src/ecs/components.ts b/src/ecs/components.ts index e572997..06f3cf6 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -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 */ diff --git a/src/world/traces.ts b/src/world/traces.ts new file mode 100644 index 0000000..13a5cb6 --- /dev/null +++ b/src/world/traces.ts @@ -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; + } +} diff --git a/tests/world-traces.test.ts b/tests/world-traces.test.ts new file mode 100644 index 0000000..f1a7b5a --- /dev/null +++ b/tests/world-traces.test.ts @@ -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; + 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); + }); +});