Files
synthesis/tests/mycelium.test.ts
Денис Шкабатур 0d35cdcc73 phase 7: Mycelium — persistent knowledge network connecting runs
- Mycelium graph: nodes/edges/strength, deposit discoveries, weighted extraction
- Fungal nodes: ECS entities on world map with bioluminescent glow animation
- Knowledge system: deposit at nodes (+ auto on death), memory flash retrieval
- Mycosis: visual tint overlay on prolonged node contact, reveal threshold
- Spore shop: 5 Cradle bonuses (health, elements, knowledge boost)
- MetaState extended with MyceliumGraphData, IndexedDB persistence updated
- GameScene: node spawning, glow rendering, E-key interaction, mycosis overlay
- CradleScene: spore shop UI, Mycelium stats, purchased effects forwarding
- 36 new tests (385 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:47:03 +03:00

538 lines
18 KiB
TypeScript

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