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); }); });