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>
This commit is contained in:
537
tests/mycelium.test.ts
Normal file
537
tests/mycelium.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user