- 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>
538 lines
18 KiB
TypeScript
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
|
|
}
|
|
}
|
|
});
|
|
});
|