import { describe, it, expect, beforeEach } from 'vitest'; import type { MyceliumGraphData, MetaState, RunState } from '../src/run/types'; import { RunPhase } from '../src/run/types'; import { createMetaState } from '../src/run/meta'; import { createMyceliumGraph, depositKnowledge, getNode, getEdge, getNodesByType, getConnectedNodes, getGraphStats, } from '../src/mycelium/graph'; import { extractMemoryFlashes, generateFlashText, } from '../src/mycelium/knowledge'; import { createMycosisState, updateMycosis, getMycosisVisuals, } from '../src/mycelium/mycosis'; import { getAvailableBonuses, purchaseBonus, canAffordBonus, } from '../src/mycelium/shop'; import { spawnFungalNodes, } from '../src/mycelium/nodes'; import type { MycosisState, MemoryFlash, FungalNodeInfo } from '../src/mycelium/types'; import { MYCOSIS_CONFIG } from '../src/mycelium/types'; // ─── Test helpers ──────────────────────────────────────────────── function createTestRunState(runId: number = 1): RunState { return { runId, schoolId: 'alchemist', phase: RunPhase.Exploration, phaseTimer: 0, elapsed: 60000, escalation: 0, crisisActive: false, crisisResolved: false, discoveries: { elements: new Set(['Na', 'Cl', 'H', 'O']), reactions: new Set(['Na+Cl']), compounds: new Set(['NaCl']), creatures: new Set(['crystallid']), }, alive: true, }; } function createTestMeta(): MetaState { const meta = createMetaState(); meta.totalRuns = 2; meta.spores = 100; meta.mycelium = createMyceliumGraph(); return meta; } // ─── Mycelium Graph Tests ──────────────────────────────────────── describe('Mycelium Graph', () => { let graph: MyceliumGraphData; beforeEach(() => { graph = createMyceliumGraph(); }); it('should create an empty graph', () => { expect(graph.nodes).toHaveLength(0); expect(graph.edges).toHaveLength(0); expect(graph.totalDeposits).toBe(0); expect(graph.totalExtractions).toBe(0); }); it('should deposit element knowledge from a run', () => { const run = createTestRunState(1); const result = depositKnowledge(graph, run); expect(result.newNodes).toBeGreaterThan(0); // Should have nodes for elements + reactions + compounds + creatures expect(graph.nodes.length).toBeGreaterThanOrEqual(4); expect(graph.totalDeposits).toBe(1); }); it('should create nodes for all discovery types', () => { const run = createTestRunState(1); depositKnowledge(graph, run); // 4 elements (Na, Cl, H, O) + 1 reaction + 1 compound + 1 creature = 7 expect(graph.nodes).toHaveLength(7); const elementNodes = getNodesByType(graph, 'element'); expect(elementNodes).toHaveLength(4); const reactionNodes = getNodesByType(graph, 'reaction'); expect(reactionNodes).toHaveLength(1); const compoundNodes = getNodesByType(graph, 'compound'); expect(compoundNodes).toHaveLength(1); const creatureNodes = getNodesByType(graph, 'creature'); expect(creatureNodes).toHaveLength(1); }); it('should create edges between related discoveries', () => { const run = createTestRunState(1); depositKnowledge(graph, run); // Elements that form a reaction should be connected to the reaction // Na + Cl → NaCl: edges Na→reaction, Cl→reaction, reaction→compound expect(graph.edges.length).toBeGreaterThan(0); }); it('should strengthen existing nodes on re-deposit', () => { const run1 = createTestRunState(1); depositKnowledge(graph, run1); const naNode = getNode(graph, 'element:Na'); expect(naNode).toBeDefined(); const initialStrength = naNode!.strength; // Second deposit with same discoveries should strengthen const run2 = createTestRunState(2); run2.discoveries.elements = new Set(['Na']); // Only Na run2.discoveries.reactions = new Set(); run2.discoveries.compounds = new Set(); run2.discoveries.creatures = new Set(); const result = depositKnowledge(graph, run2); expect(result.strengthened).toBe(1); expect(result.newNodes).toBe(0); const naNodeAfter = getNode(graph, 'element:Na'); expect(naNodeAfter!.strength).toBeGreaterThan(initialStrength); }); it('should cap node strength at 1.0', () => { const run = createTestRunState(1); run.discoveries.elements = new Set(['Na']); run.discoveries.reactions = new Set(); run.discoveries.compounds = new Set(); run.discoveries.creatures = new Set(); // Deposit many times for (let i = 0; i < 20; i++) { depositKnowledge(graph, run); } const naNode = getNode(graph, 'element:Na'); expect(naNode!.strength).toBeLessThanOrEqual(1.0); }); it('should look up nodes by ID', () => { const run = createTestRunState(1); depositKnowledge(graph, run); const node = getNode(graph, 'element:Na'); expect(node).toBeDefined(); expect(node!.type).toBe('element'); expect(node!.knowledgeId).toBe('Na'); const missing = getNode(graph, 'element:Au'); expect(missing).toBeUndefined(); }); it('should look up edges between nodes', () => { const run = createTestRunState(1); depositKnowledge(graph, run); // There should be edges from Na and Cl to the Na+Cl reaction const naEdge = getEdge(graph, 'element:Na', 'reaction:Na+Cl'); // Edge may exist in either direction — check both const naEdgeReverse = getEdge(graph, 'reaction:Na+Cl', 'element:Na'); expect(naEdge ?? naEdgeReverse).toBeDefined(); }); it('should return connected nodes', () => { const run = createTestRunState(1); depositKnowledge(graph, run); // Na should be connected to the Na+Cl reaction const connected = getConnectedNodes(graph, 'element:Na'); expect(connected.length).toBeGreaterThan(0); }); it('should report graph statistics', () => { const run = createTestRunState(1); depositKnowledge(graph, run); const stats = getGraphStats(graph); expect(stats.nodeCount).toBe(7); expect(stats.edgeCount).toBeGreaterThan(0); expect(stats.totalDeposits).toBe(1); expect(stats.averageStrength).toBeGreaterThan(0); expect(stats.averageStrength).toBeLessThanOrEqual(1); }); }); // ─── Knowledge System Tests ────────────────────────────────────── describe('Knowledge System', () => { let graph: MyceliumGraphData; beforeEach(() => { graph = createMyceliumGraph(); const run = createTestRunState(1); depositKnowledge(graph, run); }); it('should extract memory flashes from populated graph', () => { const flashes = extractMemoryFlashes(graph, 3); expect(flashes.length).toBeGreaterThan(0); expect(flashes.length).toBeLessThanOrEqual(3); }); it('should return empty flashes from empty graph', () => { const emptyGraph = createMyceliumGraph(); const flashes = extractMemoryFlashes(emptyGraph, 3); expect(flashes).toHaveLength(0); }); it('should generate flash text for element hints', () => { const flash = generateFlashText('element', 'Na'); expect(flash.type).toBe('element_hint'); expect(flash.text.length).toBeGreaterThan(0); expect(flash.textRu.length).toBeGreaterThan(0); // Should contain the element name somewhere expect(flash.text).toContain('Na'); expect(flash.textRu).toContain('Na'); }); it('should generate flash text for reaction hints', () => { const flash = generateFlashText('reaction', 'Na+Cl'); expect(flash.type).toBe('reaction_hint'); expect(flash.text.length).toBeGreaterThan(0); }); it('should generate flash text for creature hints', () => { const flash = generateFlashText('creature', 'crystallid'); expect(flash.type).toBe('creature_hint'); expect(flash.text.length).toBeGreaterThan(0); }); it('should increment extraction counter', () => { expect(graph.totalExtractions).toBe(0); extractMemoryFlashes(graph, 3); expect(graph.totalExtractions).toBe(1); }); it('should prefer stronger nodes for flashes', () => { // Strengthen Na by depositing multiple times for (let i = 0; i < 5; i++) { const run = createTestRunState(i + 2); run.discoveries.elements = new Set(['Na']); run.discoveries.reactions = new Set(); run.discoveries.compounds = new Set(); run.discoveries.creatures = new Set(); depositKnowledge(graph, run); } // Extract many flashes — Na should appear frequently let naCount = 0; for (let i = 0; i < 10; i++) { const flashes = extractMemoryFlashes(graph, 3); for (const f of flashes) { if (f.text.includes('Na') || f.textRu.includes('Na')) naCount++; } } // Na should appear at least once in 10 rounds of 3 flashes expect(naCount).toBeGreaterThan(0); }); }); // ─── Mycosis Tests ─────────────────────────────────────────────── describe('Mycosis', () => { let state: MycosisState; beforeEach(() => { state = createMycosisState(); }); it('should create initial mycosis state at zero', () => { expect(state.level).toBe(0); expect(state.exposure).toBe(0); expect(state.revealing).toBe(false); }); it('should increase level when near a fungal node', () => { updateMycosis(state, 1000, true); // 1 second near a node expect(state.level).toBeGreaterThan(0); expect(state.exposure).toBe(1000); }); it('should decrease level when away from nodes', () => { // Build up some mycosis updateMycosis(state, 5000, true); const peakLevel = state.level; // Move away updateMycosis(state, 2000, false); expect(state.level).toBeLessThan(peakLevel); }); it('should not go below zero', () => { updateMycosis(state, 10000, false); expect(state.level).toBe(0); }); it('should not exceed max level', () => { updateMycosis(state, 100000, true); // Very long exposure expect(state.level).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxLevel); }); it('should start revealing at threshold', () => { // Build up to reveal threshold const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000; updateMycosis(state, timeToThreshold + 1000, true); expect(state.revealing).toBe(true); }); it('should stop revealing when level drops below threshold', () => { // Build up past threshold const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000; updateMycosis(state, timeToThreshold + 2000, true); expect(state.revealing).toBe(true); // Let it decay const decayTime = (state.level - MYCOSIS_CONFIG.revealThreshold + 0.1) / MYCOSIS_CONFIG.decayRate * 1000; updateMycosis(state, decayTime + 1000, false); expect(state.revealing).toBe(false); }); it('should return visual parameters based on level', () => { updateMycosis(state, 5000, true); const visuals = getMycosisVisuals(state); expect(visuals.tintColor).toBe(MYCOSIS_CONFIG.tintColor); expect(visuals.tintAlpha).toBeGreaterThan(0); expect(visuals.tintAlpha).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxTintAlpha); expect(visuals.distortionStrength).toBeGreaterThanOrEqual(0); }); it('should have zero visuals when mycosis is zero', () => { const visuals = getMycosisVisuals(state); expect(visuals.tintAlpha).toBe(0); expect(visuals.distortionStrength).toBe(0); }); }); // ─── Spore Shop Tests ──────────────────────────────────────────── describe('Spore Shop', () => { it('should list available bonuses', () => { const bonuses = getAvailableBonuses(); expect(bonuses.length).toBeGreaterThan(0); // Each bonus should have required fields for (const bonus of bonuses) { expect(bonus.id).toBeTruthy(); expect(bonus.nameRu).toBeTruthy(); expect(bonus.cost).toBeGreaterThan(0); expect(bonus.effect).toBeDefined(); } }); it('should check affordability correctly', () => { const meta = createTestMeta(); meta.spores = 15; const bonuses = getAvailableBonuses(); const cheapBonus = bonuses.find(b => b.cost <= 15); const expensiveBonus = bonuses.find(b => b.cost > 15); if (cheapBonus) { expect(canAffordBonus(meta, cheapBonus.id)).toBe(true); } if (expensiveBonus) { expect(canAffordBonus(meta, expensiveBonus.id)).toBe(false); } }); it('should deduct spores on purchase', () => { const meta = createTestMeta(); const initialSpores = meta.spores; const bonuses = getAvailableBonuses(); const bonus = bonuses[0]; const result = purchaseBonus(meta, bonus.id); expect(result).not.toBeNull(); expect(meta.spores).toBe(initialSpores - bonus.cost); }); it('should return the purchased effect', () => { const meta = createTestMeta(); const bonuses = getAvailableBonuses(); const bonus = bonuses.find(b => b.effect.type === 'extra_health'); expect(bonus).toBeDefined(); const result = purchaseBonus(meta, bonus!.id); expect(result).not.toBeNull(); expect(result!.type).toBe('extra_health'); }); it('should fail to purchase with insufficient spores', () => { const meta = createTestMeta(); meta.spores = 0; const bonuses = getAvailableBonuses(); const result = purchaseBonus(meta, bonuses[0].id); expect(result).toBeNull(); expect(meta.spores).toBe(0); // unchanged }); it('should not allow non-repeatable bonuses to be purchased twice', () => { const meta = createTestMeta(); meta.spores = 200; const bonuses = getAvailableBonuses(); const nonRepeatable = bonuses.find(b => !b.repeatable); if (!nonRepeatable) return; // skip if all are repeatable const result1 = purchaseBonus(meta, nonRepeatable.id); expect(result1).not.toBeNull(); const result2 = purchaseBonus(meta, nonRepeatable.id); expect(result2).toBeNull(); }); }); // ─── Fungal Node Spawning Tests ────────────────────────────────── describe('Fungal Node Spawning', () => { // Build a simple 20x20 grid with walkable ground const biome = { id: 'test', name: 'Test', nameRu: 'Тест', description: '', descriptionRu: '', tileSize: 16, mapWidth: 20, mapHeight: 20, tiles: [ { id: 0, name: 'ground', nameRu: 'земля', color: '#555', walkable: true, damage: 0, interactive: false, resource: false }, { id: 1, name: 'acid-deep', nameRu: 'кислота', color: '#0f0', walkable: false, damage: 5, interactive: false, resource: false }, { id: 2, name: 'scorched-earth', nameRu: 'выжженная', color: '#333', walkable: true, damage: 0, interactive: false, resource: false }, ], generation: { elevationScale: 0.1, detailScale: 0.2, elevationRules: [{ below: 0.5, tileId: 0 }], geyserThreshold: 0.9, mineralThreshold: 0.8, geyserOnTile: 0, mineralOnTiles: [0], }, }; function createTestGrid(): number[][] { const grid: number[][] = []; for (let y = 0; y < 20; y++) { const row: number[] = []; for (let x = 0; x < 20; x++) { // Mostly ground, some acid row.push(x === 0 && y === 0 ? 1 : 0); } grid.push(row); } return grid; } it('should spawn fungal nodes on walkable tiles', async () => { const { createWorld } = await import('bitecs'); const world = createWorld(); const grid = createTestGrid(); const nodeData = spawnFungalNodes(world, grid, biome, 42); expect(nodeData.size).toBeGreaterThan(0); }); it('should not spawn on non-walkable tiles', async () => { const { createWorld } = await import('bitecs'); const world = createWorld(); // Make grid entirely non-walkable const grid: number[][] = []; for (let y = 0; y < 20; y++) { grid.push(Array(20).fill(1)); // all acid } const nodeData = spawnFungalNodes(world, grid, biome, 42); expect(nodeData.size).toBe(0); }); it('should use deterministic placement based on seed', async () => { const { createWorld } = await import('bitecs'); const grid = createTestGrid(); const world1 = createWorld(); const data1 = spawnFungalNodes(world1, grid, biome, 42); const world2 = createWorld(); const data2 = spawnFungalNodes(world2, grid, biome, 42); // Same seed → same positions const positions1 = [...data1.values()].map(d => `${d.tileX},${d.tileY}`).sort(); const positions2 = [...data2.values()].map(d => `${d.tileX},${d.tileY}`).sort(); expect(positions1).toEqual(positions2); }); it('should maintain minimum spacing between nodes', async () => { const { createWorld } = await import('bitecs'); const world = createWorld(); const grid = createTestGrid(); const nodeData = spawnFungalNodes(world, grid, biome, 42); const positions = [...nodeData.values()].map(d => ({ x: d.tileX, y: d.tileY })); // No two nodes should be closer than minSpacing // (on a 20x20 grid with spacing 8, there can be only a few nodes) for (let i = 0; i < positions.length; i++) { for (let j = i + 1; j < positions.length; j++) { const dx = positions[i].x - positions[j].x; const dy = positions[i].y - positions[j].y; const dist = Math.sqrt(dx * dx + dy * dy); expect(dist).toBeGreaterThanOrEqual(7); // slight tolerance } } }); });