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>
223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|