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:
Денис Шкабатур
2026-02-12 15:47:03 +03:00
parent 35f8905921
commit 0d35cdcc73
17 changed files with 1866 additions and 22 deletions

View File

@@ -172,7 +172,7 @@
---
## Phase 7: Mycelium
## Phase 7: Mycelium
**Цель:** Подземная сеть, связывающая раны
**Зависимости:** Phase 6

View File

@@ -1,7 +1,7 @@
# Synthesis — Development Progress
> **Last updated:** 2026-02-12
> **Current phase:** Phase 6 ✅ → Ready for Phase 7
> **Current phase:** Phase 7 ✅ → Ready for Phase 8
---
@@ -86,15 +86,29 @@
- [x] Texture cleanup for multi-run support (tilemap + minimap textures removed before recreation)
- [x] Unit tests — 42 run-cycle + 14 escalation = 56 tests (349 total)
### Phase 7: Mycelium ✅
- [x] 7.1 Mycelium Graph — persistent knowledge network (nodes, edges, deposit/strengthen/query) (`src/mycelium/graph.ts`)
- [x] 7.2 Fungal Nodes — ECS entities on world map, glowing bioluminescent spots, pulsing animation (`src/mycelium/nodes.ts`)
- [x] 7.3 Knowledge Recording — deposit run discoveries into Mycelium (auto on death + manual at nodes) (`src/mycelium/knowledge.ts`)
- [x] 7.4 Knowledge Extraction — memory flashes from past runs, weighted by node strength, Russian text templates (`src/mycelium/knowledge.ts`)
- [x] 7.5 Mycosis — visual distortion (tint overlay) on prolonged fungal node contact, reveals hidden info at threshold (`src/mycelium/mycosis.ts`)
- [x] 7.6 Spore Shop — Cradle integration: spend spores for starting bonuses (extra health, elements, knowledge boost) (`src/mycelium/shop.ts`)
- [x] ECS component: FungalNode (nodeIndex, glowPhase, interactRange) (`src/ecs/components.ts`)
- [x] Data: mycelium config, spore bonuses, memory templates (`src/data/mycelium.json`)
- [x] MetaState extended with MyceliumGraphData, IndexedDB persistence updated
- [x] GameScene integration: node spawning, glow rendering, E-key interaction, mycosis overlay, memory flash display
- [x] CradleScene integration: spore shop UI, Mycelium stats display, purchased effects passed to GameScene
- [x] Unit tests — 36 passing (`tests/mycelium.test.ts`) (385 total)
---
## In Progress
_None — ready to begin Phase 7_
_None — ready to begin Phase 8_
---
## Up Next: Phase 7Mycelium
## Up Next: Phase 8First Archont: Ouroboros
_(See IMPLEMENTATION-PLAN.md for details)_
@@ -117,3 +131,4 @@ None
| 5 | 2026-02-12 | Phase 4 | Player systems: WASD movement + tile collision, weight-based inventory, resource collection, crafting via chemistry engine, projectile throw, 4 quick slots, UIScene HUD overlay (health bar, slots, inventory), 126 new tests (222 total) |
| 6 | 2026-02-12 | Phase 5 | Creatures & Ecology: 3 species (Crystallid/Acidophile/Reagent), FSM AI (idle/wander/feed/flee/attack), metabolism (energy drain/feeding/starvation), life cycle (egg→youth→mature→aging→death), population dynamics, projectile-creature collision with armor, creature→player melee, ecosystem simulation test, 72 new tests (293 total) |
| 7 | 2026-02-12 | Phase 6 | Run Cycle: full roguelike loop (Cradle→Game→Death→Fractal→Cradle), school selection (Alchemist), meta-progression (Codex/spores/IndexedDB), run phases with auto-advance, escalation effects (creature aggression/env damage), Chemical Plague crisis with neutralization, death animation (real body composition), WebGL fractal shader, 56 new tests (349 total) |
| 8 | 2026-02-12 | Phase 7 | Mycelium: persistent knowledge graph (nodes/edges/strength), fungal node ECS entities with glow animation, knowledge deposit (auto on death + manual at nodes), memory flash extraction (weighted by strength, Russian templates), mycosis visual effect (tint overlay + reveal threshold), spore shop in Cradle (5 bonuses: health/elements/knowledge), MetaState+IndexedDB persistence updated, GameScene+CradleScene integration, 36 new tests (385 total) |

75
src/data/mycelium.json Normal file
View File

@@ -0,0 +1,75 @@
{
"sporeBonuses": [
{
"id": "vitality",
"name": "Vital Spores",
"nameRu": "Споры Живучести",
"description": "Mycelium reinforces your body. +25 max health.",
"descriptionRu": "Мицелий укрепляет тело. +25 к максимальному здоровью.",
"cost": 20,
"effect": { "type": "extra_health", "amount": 25 },
"repeatable": true
},
{
"id": "carbon_gift",
"name": "Carbon Cache",
"nameRu": "Запас Углерода",
"description": "A fungal node releases stored carbon. +3 C.",
"descriptionRu": "Грибной узел отдаёт накопленный углерод. +3 C.",
"cost": 10,
"effect": { "type": "extra_element", "symbol": "C", "quantity": 3 },
"repeatable": true
},
{
"id": "sulfur_gift",
"name": "Sulfur Reserve",
"nameRu": "Запас Серы",
"description": "Deep mycelium threads carry sulfur to the surface. +3 S.",
"descriptionRu": "Глубинные нити мицелия доставляют серу. +3 S.",
"cost": 10,
"effect": { "type": "extra_element", "symbol": "S", "quantity": 3 },
"repeatable": true
},
{
"id": "iron_gift",
"name": "Iron Vein",
"nameRu": "Железная Жила",
"description": "The network has absorbed iron from old ruins. +2 Fe.",
"descriptionRu": "Сеть впитала железо из древних руин. +2 Fe.",
"cost": 15,
"effect": { "type": "extra_element", "symbol": "Fe", "quantity": 2 },
"repeatable": true
},
{
"id": "memory_boost",
"name": "Fungal Attunement",
"nameRu": "Грибная Настройка",
"description": "Clearer memories from the network. Memory flashes are more detailed.",
"descriptionRu": "Яснее воспоминания сети. Вспышки памяти более детальны.",
"cost": 30,
"effect": { "type": "knowledge_boost", "multiplier": 1.5 },
"repeatable": false
}
],
"memoryTemplates": {
"element_hint": [
{ "text": "A faint echo... {element} reacts with something nearby...", "textRu": "Слабое эхо... {element} реагирует с чем-то рядом..." },
{ "text": "The network remembers: {element} was useful here...", "textRu": "Сеть помнит: {element} был полезен здесь..." },
{ "text": "Traces of {element} flow through the mycelium threads...", "textRu": "Следы {element} текут по нитям мицелия..." }
],
"reaction_hint": [
{ "text": "A past adept combined... the result was {compound}...", "textRu": "Прошлый адепт комбинировал... результат — {compound}..." },
{ "text": "The fungal memory shows: mixing yields {compound}...", "textRu": "Грибная память показывает: смешение даёт {compound}..." },
{ "text": "Chemical echoes: a reaction produced {compound} here...", "textRu": "Химическое эхо: реакция создала {compound} здесь..." }
],
"creature_hint": [
{ "text": "Beware: {creature} was observed in this region...", "textRu": "Осторожно: {creature} замечен в этом регионе..." },
{ "text": "The network felt vibrations... {creature} territory...", "textRu": "Сеть ощутила вибрации... территория {creature}..." }
],
"lore": [
{ "text": "...the cycle continues. What dies feeds what grows...", "textRu": "...цикл продолжается. Что умирает, питает растущее..." },
{ "text": "...synthesis is not creation. It is transformation...", "textRu": "...синтез — не создание. Это трансформация..." },
{ "text": "...the Mycelium remembers all who walked these paths...", "textRu": "...Мицелий помнит всех, кто шёл этими тропами..." }
]
}
}

View File

@@ -83,3 +83,12 @@ export const LifeCycle = {
stageTimer: [] as number[], // ms remaining in current stage
age: [] as number[], // total age in ms
};
// ─── Mycelium Components ─────────────────────────────────────────
/** Fungal node — a point where the Mycelium surfaces on the map */
export const FungalNode = {
nodeIndex: [] as number[], // index in fungal node info list
glowPhase: [] as number[], // animation phase (radians)
interactRange: [] as number[], // max interaction distance in pixels
};

227
src/mycelium/graph.ts Normal file
View File

@@ -0,0 +1,227 @@
/**
* Mycelium Graph — persistent knowledge network
*
* The graph grows as the player deposits discoveries at fungal nodes.
* Nodes represent knowledge (elements, reactions, compounds, creatures).
* Edges represent relationships (e.g., elements that form a reaction).
*
* Strength increases with repeated deposits, decays slightly over time
* (knowledge fades if not reinforced).
*/
import type { MyceliumGraphData, MyceliumNodeData, MyceliumEdgeData, RunState } from '../run/types';
import type { DepositResult } from './types';
/** Strength increment per deposit (diminishing returns via current strength) */
const STRENGTH_INCREMENT = 0.2;
/** Initial strength for a newly created node */
const INITIAL_STRENGTH = 0.3;
/** Edge weight for related discoveries */
const EDGE_WEIGHT = 0.5;
/** Create a new empty Mycelium graph */
export function createMyceliumGraph(): MyceliumGraphData {
return {
nodes: [],
edges: [],
totalDeposits: 0,
totalExtractions: 0,
};
}
/**
* Deposit all discoveries from a run into the Mycelium graph.
*
* - Creates new nodes for unknown discoveries
* - Strengthens existing nodes for repeated discoveries
* - Creates edges between related knowledge (elements↔reactions, reactions↔compounds)
*/
export function depositKnowledge(
graph: MyceliumGraphData,
run: RunState,
): DepositResult {
let newNodes = 0;
let strengthened = 0;
let newEdges = 0;
const addedNodeIds: string[] = [];
// Deposit elements
for (const elemId of run.discoveries.elements) {
const nodeId = `element:${elemId}`;
const result = upsertNode(graph, nodeId, 'element', elemId, run.runId);
if (result === 'created') newNodes++;
else strengthened++;
addedNodeIds.push(nodeId);
}
// Deposit reactions
for (const reactionId of run.discoveries.reactions) {
const nodeId = `reaction:${reactionId}`;
const result = upsertNode(graph, nodeId, 'reaction', reactionId, run.runId);
if (result === 'created') newNodes++;
else strengthened++;
addedNodeIds.push(nodeId);
}
// Deposit compounds
for (const compoundId of run.discoveries.compounds) {
const nodeId = `compound:${compoundId}`;
const result = upsertNode(graph, nodeId, 'compound', compoundId, run.runId);
if (result === 'created') newNodes++;
else strengthened++;
addedNodeIds.push(nodeId);
}
// Deposit creatures
for (const creatureId of run.discoveries.creatures) {
const nodeId = `creature:${creatureId}`;
const result = upsertNode(graph, nodeId, 'creature', creatureId, run.runId);
if (result === 'created') newNodes++;
else strengthened++;
addedNodeIds.push(nodeId);
}
// Create edges between related discoveries
// Elements ↔ Reactions (elements that appear in a reaction key)
for (const reactionId of run.discoveries.reactions) {
const reactionNodeId = `reaction:${reactionId}`;
// Parse reaction key (e.g., "Na+Cl" → ["Na", "Cl"])
const reactants = reactionId.split('+');
for (const reactant of reactants) {
const elemNodeId = `element:${reactant}`;
if (getNode(graph, elemNodeId)) {
const added = upsertEdge(graph, elemNodeId, reactionNodeId);
if (added) newEdges++;
}
}
}
// Reactions ↔ Compounds (reaction produces compound)
for (const reactionId of run.discoveries.reactions) {
const reactionNodeId = `reaction:${reactionId}`;
for (const compoundId of run.discoveries.compounds) {
const compoundNodeId = `compound:${compoundId}`;
const added = upsertEdge(graph, reactionNodeId, compoundNodeId);
if (added) newEdges++;
}
}
graph.totalDeposits++;
return { newNodes, strengthened, newEdges };
}
/** Get a node by its ID */
export function getNode(
graph: MyceliumGraphData,
nodeId: string,
): MyceliumNodeData | undefined {
return graph.nodes.find(n => n.id === nodeId);
}
/** Get an edge between two nodes */
export function getEdge(
graph: MyceliumGraphData,
fromId: string,
toId: string,
): MyceliumEdgeData | undefined {
return graph.edges.find(e =>
(e.from === fromId && e.to === toId) ||
(e.from === toId && e.to === fromId),
);
}
/** Get all nodes of a specific type */
export function getNodesByType(
graph: MyceliumGraphData,
type: MyceliumNodeData['type'],
): MyceliumNodeData[] {
return graph.nodes.filter(n => n.type === type);
}
/** Get nodes connected to a given node (via edges) */
export function getConnectedNodes(
graph: MyceliumGraphData,
nodeId: string,
): MyceliumNodeData[] {
const connectedIds = new Set<string>();
for (const edge of graph.edges) {
if (edge.from === nodeId) connectedIds.add(edge.to);
if (edge.to === nodeId) connectedIds.add(edge.from);
}
return graph.nodes.filter(n => connectedIds.has(n.id));
}
/** Get summary statistics of the graph */
export function getGraphStats(graph: MyceliumGraphData): {
nodeCount: number;
edgeCount: number;
totalDeposits: number;
totalExtractions: number;
averageStrength: number;
} {
const avgStrength = graph.nodes.length > 0
? graph.nodes.reduce((sum, n) => sum + n.strength, 0) / graph.nodes.length
: 0;
return {
nodeCount: graph.nodes.length,
edgeCount: graph.edges.length,
totalDeposits: graph.totalDeposits,
totalExtractions: graph.totalExtractions,
averageStrength: avgStrength,
};
}
// ─── Internal helpers ────────────────────────────────────────────
/** Insert or update a knowledge node. Returns 'created' or 'strengthened'. */
function upsertNode(
graph: MyceliumGraphData,
nodeId: string,
type: MyceliumNodeData['type'],
knowledgeId: string,
runId: number,
): 'created' | 'strengthened' {
const existing = getNode(graph, nodeId);
if (existing) {
// Diminishing returns: delta = INCREMENT * (1 - currentStrength)
const delta = STRENGTH_INCREMENT * (1 - existing.strength);
existing.strength = Math.min(1.0, existing.strength + delta);
return 'strengthened';
}
graph.nodes.push({
id: nodeId,
type,
knowledgeId,
depositedOnRun: runId,
strength: INITIAL_STRENGTH,
});
return 'created';
}
/** Insert or strengthen an edge. Returns true if newly created. */
function upsertEdge(
graph: MyceliumGraphData,
fromId: string,
toId: string,
): boolean {
const existing = getEdge(graph, fromId, toId);
if (existing) {
existing.weight = Math.min(1.0, existing.weight + 0.1);
return false;
}
graph.edges.push({
from: fromId,
to: toId,
weight: EDGE_WEIGHT,
});
return true;
}

13
src/mycelium/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Mycelium Module — the underground fungal network connecting runs
*
* Re-exports all public API from mycelium subsystems.
*/
export { createMyceliumGraph, depositKnowledge, getNode, getEdge, getNodesByType, getConnectedNodes, getGraphStats } from './graph';
export { extractMemoryFlashes, generateFlashText } from './knowledge';
export { createMycosisState, updateMycosis, getMycosisVisuals } from './mycosis';
export { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from './shop';
export { spawnFungalNodes } from './nodes';
export type { MyceliumKnowledgeNode, MyceliumEdge, MyceliumGraph, MemoryFlash, MycosisState, FungalNodeInfo, SporeBonus, BonusEffect, DepositResult, ExtractResult } from './types';
export type { MycosisVisuals } from './mycosis';

158
src/mycelium/knowledge.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Knowledge System — deposit and extract memories via the Mycelium
*
* When a player interacts with a fungal node:
* - Deposit: current run discoveries are added to the Mycelium graph
* - Extract: memory flashes from past runs are surfaced
*
* Stronger nodes produce clearer, more useful memories.
*/
import type { MyceliumGraphData, MyceliumNodeData } from '../run/types';
import type { MemoryFlash } from './types';
import myceliumData from '../data/mycelium.json';
const templates = myceliumData.memoryTemplates;
/**
* Extract memory flashes from the Mycelium graph.
*
* Selects nodes weighted by strength, generates flash text
* for each. Stronger nodes produce higher-clarity flashes.
*
* @param graph The persistent Mycelium graph
* @param maxFlashes Maximum number of flashes to return
* @returns Array of memory flashes
*/
export function extractMemoryFlashes(
graph: MyceliumGraphData,
maxFlashes: number,
): MemoryFlash[] {
if (graph.nodes.length === 0) return [];
graph.totalExtractions++;
// Weighted random selection: higher strength = more likely
const selected = weightedSelect(graph.nodes, maxFlashes);
return selected.map(node => {
const flash = generateFlashText(node.type, node.knowledgeId);
flash.sourceRunId = node.depositedOnRun;
flash.clarity = node.strength;
return flash;
});
}
/**
* Generate a memory flash text from templates.
*
* Replaces {element}, {compound}, {creature} placeholders
* with the actual knowledge ID.
*/
export function generateFlashText(
type: MyceliumNodeData['type'],
knowledgeId: string,
): MemoryFlash {
const templateKey = getTemplateKey(type);
const templateList = templates[templateKey];
// Deterministic-ish template selection based on knowledge ID hash
const hash = simpleHash(knowledgeId);
const template = templateList[hash % templateList.length];
const text = replaceTokens(template.text, type, knowledgeId);
const textRu = replaceTokens(template.textRu, type, knowledgeId);
return {
type: templateKey,
text,
textRu,
sourceRunId: 0,
clarity: 0.5,
};
}
// ─── Internal helpers ────────────────────────────────────────────
/** Map node type to template key */
function getTemplateKey(type: MyceliumNodeData['type']): MemoryFlash['type'] {
switch (type) {
case 'element': return 'element_hint';
case 'reaction': return 'reaction_hint';
case 'compound': return 'reaction_hint'; // compounds use reaction templates
case 'creature': return 'creature_hint';
}
}
/** Replace template tokens with actual values */
function replaceTokens(
template: string,
type: MyceliumNodeData['type'],
knowledgeId: string,
): string {
return template
.replace('{element}', knowledgeId)
.replace('{compound}', knowledgeId)
.replace('{creature}', knowledgeId);
}
/** Simple hash for deterministic template selection */
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
/**
* Weighted random selection without replacement.
* Nodes with higher strength are more likely to be selected.
*/
function weightedSelect(
nodes: MyceliumNodeData[],
count: number,
): MyceliumNodeData[] {
if (nodes.length <= count) return [...nodes];
const result: MyceliumNodeData[] = [];
const used = new Set<number>();
// Build cumulative weight array
const weights = nodes.map(n => n.strength + 0.1); // small base weight
const totalWeight = weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < count && used.size < nodes.length; i++) {
let target = pseudoRandom(i + nodes.length) * totalWeight;
let idx = 0;
while (idx < nodes.length) {
if (!used.has(idx)) {
target -= weights[idx];
if (target <= 0) break;
}
idx++;
}
// Safety: if we went past the end, find first unused
if (idx >= nodes.length) {
idx = 0;
while (used.has(idx) && idx < nodes.length) idx++;
}
if (idx < nodes.length) {
result.push(nodes[idx]);
used.add(idx);
}
}
return result;
}
/** Pseudo-random 01 from integer seed (mulberry32-like) */
function pseudoRandom(seed: number): number {
let t = (seed + 0x6d2b79f5) | 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}

83
src/mycelium/mycosis.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* Mycosis — visual distortion from prolonged contact with fungal nodes
*
* When the player stays near a fungal node, mycosis level increases.
* At the reveal threshold, hidden information becomes visible
* (stronger memory flashes, secret details about nearby creatures).
*
* Moving away from nodes causes mycosis to slowly decay.
*/
import type { MycosisState } from './types';
import { MYCOSIS_CONFIG } from './types';
/** Create a fresh mycosis state (no fungal influence) */
export function createMycosisState(): MycosisState {
return {
level: 0,
exposure: 0,
revealing: false,
};
}
/**
* Update mycosis state based on proximity to fungal nodes.
*
* @param state Current mycosis state (mutated in place)
* @param deltaMs Time elapsed since last update (ms)
* @param nearNode Whether the player is currently near a fungal node
*/
export function updateMycosis(
state: MycosisState,
deltaMs: number,
nearNode: boolean,
): void {
const deltaSec = deltaMs / 1000;
if (nearNode) {
// Build up mycosis
state.exposure += deltaMs;
state.level = Math.min(
MYCOSIS_CONFIG.maxLevel,
state.level + MYCOSIS_CONFIG.buildRate * deltaSec,
);
} else {
// Decay mycosis
state.level = Math.max(
0,
state.level - MYCOSIS_CONFIG.decayRate * deltaSec,
);
}
// Update reveal state
state.revealing = state.level >= MYCOSIS_CONFIG.revealThreshold;
}
/** Visual parameters derived from mycosis level */
export interface MycosisVisuals {
/** Tint overlay color */
tintColor: number;
/** Tint overlay alpha (0 = invisible, maxTintAlpha = full) */
tintAlpha: number;
/** Strength of visual distortion (0 = none, 1 = maximum) */
distortionStrength: number;
/** Whether hidden info should be shown */
revealing: boolean;
}
/**
* Get visual parameters for rendering the mycosis effect.
*
* @param state Current mycosis state
* @returns Visual parameters for the renderer
*/
export function getMycosisVisuals(state: MycosisState): MycosisVisuals {
const fraction = state.level / MYCOSIS_CONFIG.maxLevel;
return {
tintColor: MYCOSIS_CONFIG.tintColor,
tintAlpha: fraction * MYCOSIS_CONFIG.maxTintAlpha,
distortionStrength: fraction,
revealing: state.revealing,
};
}

131
src/mycelium/nodes.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Fungal Node Spawning — places Mycelium surface points on the world map
*
* Fungal nodes are ECS entities placed on walkable tiles during world generation.
* They glow softly (bioluminescent green) and pulse gently.
* Players interact with them to deposit/extract knowledge.
*
* Placement uses a Poisson-disc-like approach for even spacing.
*/
import { addEntity, addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, FungalNode, SpriteRef } from '../ecs/components';
import type { BiomeData, TileGrid } from '../world/types';
import type { FungalNodeInfo } from './types';
import { FUNGAL_NODE_CONFIG } from './types';
/**
* Spawn fungal nodes on the world map.
*
* Algorithm:
* 1. Scan grid for tiles listed in FUNGAL_NODE_CONFIG.spawnOnTiles
* 2. Deterministically select candidate positions using seed
* 3. Filter by minimum spacing constraint
* 4. Create ECS entities at selected positions
*
* @returns Map of entity ID → FungalNodeInfo
*/
export function spawnFungalNodes(
world: World,
grid: TileGrid,
biome: BiomeData,
seed: number,
): Map<number, FungalNodeInfo> {
const nodeData = new Map<number, FungalNodeInfo>();
const tileSize = biome.tileSize;
// Find tile IDs that fungal nodes can spawn on
const spawnTileIds = new Set<number>();
for (const tileName of FUNGAL_NODE_CONFIG.spawnOnTiles) {
const tile = biome.tiles.find(t => t.name === tileName);
if (tile) spawnTileIds.add(tile.id);
}
// Collect all candidate positions (walkable tiles that match spawn targets)
const candidates: { x: number; y: number }[] = [];
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
if (spawnTileIds.has(grid[y][x])) {
candidates.push({ x, y });
}
}
}
if (candidates.length === 0) return nodeData;
// Determine target node count from map size
const mapArea = biome.mapWidth * biome.mapHeight;
const targetCount = Math.max(1, Math.round(mapArea * FUNGAL_NODE_CONFIG.targetDensity));
// Deterministic placement using seed-based scoring
const scored = candidates.map(c => ({
...c,
score: deterministicScore(c.x, c.y, seed),
}));
// Sort by score (highest first) — deterministic ordering
scored.sort((a, b) => b.score - a.score);
// Select nodes respecting minimum spacing
const placed: { x: number; y: number }[] = [];
const minSpacing = FUNGAL_NODE_CONFIG.minSpacing;
for (const candidate of scored) {
if (placed.length >= targetCount) break;
// Check spacing against all placed nodes
let tooClose = false;
for (const existing of placed) {
const dx = candidate.x - existing.x;
const dy = candidate.y - existing.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minSpacing) {
tooClose = true;
break;
}
}
if (!tooClose) {
placed.push({ x: candidate.x, y: candidate.y });
}
}
// Create ECS entities for placed nodes
for (let i = 0; i < placed.length; i++) {
const pos = placed[i];
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, FungalNode);
addComponent(world, eid, SpriteRef);
Position.x[eid] = pos.x * tileSize + tileSize / 2;
Position.y[eid] = pos.y * tileSize + tileSize / 2;
FungalNode.nodeIndex[eid] = i;
FungalNode.glowPhase[eid] = deterministicScore(pos.x, pos.y, seed + 1) * Math.PI * 2;
FungalNode.interactRange[eid] = FUNGAL_NODE_CONFIG.interactRange;
SpriteRef.color[eid] = FUNGAL_NODE_CONFIG.spriteColor;
SpriteRef.radius[eid] = FUNGAL_NODE_CONFIG.spriteRadius;
nodeData.set(eid, {
tileX: pos.x,
tileY: pos.y,
nodeIndex: i,
});
}
return nodeData;
}
/**
* Deterministic score for a tile position (01).
* Higher scores are selected first for node placement.
*/
function deterministicScore(x: number, y: number, seed: number): number {
// Multiplicative hash for spatial distribution
const hash = ((x * 73856093) ^ (y * 19349663) ^ (seed * 83492791)) >>> 0;
return (hash % 1000000) / 1000000;
}

65
src/mycelium/shop.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Spore Shop — spend spores for starting bonuses at the Cradle
*
* Before each run, the player can visit fungal nodes in the Cradle
* to exchange spores for:
* - Extra health
* - Extra starting elements
* - Knowledge boost (clearer memory flashes)
*/
import type { MetaState } from '../run/types';
import type { SporeBonus, BonusEffect } from './types';
import myceliumData from '../data/mycelium.json';
const allBonuses = myceliumData.sporeBonuses as SporeBonus[];
/** Track which non-repeatable bonuses have been purchased this session */
const purchasedNonRepeatableThisSession = new Set<string>();
/** Get all available spore bonuses */
export function getAvailableBonuses(): SporeBonus[] {
return [...allBonuses];
}
/** Check if the player can afford a specific bonus */
export function canAffordBonus(meta: MetaState, bonusId: string): boolean {
const bonus = allBonuses.find(b => b.id === bonusId);
if (!bonus) return false;
if (!bonus.repeatable && purchasedNonRepeatableThisSession.has(bonusId)) return false;
return meta.spores >= bonus.cost;
}
/**
* Purchase a spore bonus. Deducts spores from meta state.
*
* @returns The purchased effect, or null if purchase failed
*/
export function purchaseBonus(
meta: MetaState,
bonusId: string,
): BonusEffect | null {
const bonus = allBonuses.find(b => b.id === bonusId);
if (!bonus) return null;
// Check non-repeatable
if (!bonus.repeatable && purchasedNonRepeatableThisSession.has(bonusId)) {
return null;
}
// Check affordability
if (meta.spores < bonus.cost) return null;
// Deduct and record
meta.spores -= bonus.cost;
if (!bonus.repeatable) {
purchasedNonRepeatableThisSession.add(bonusId);
}
return bonus.effect;
}
/** Reset purchased tracking (call at start of each Cradle visit) */
export function resetShopSession(): void {
purchasedNonRepeatableThisSession.clear();
}

165
src/mycelium/types.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Mycelium Types — the underground fungal network connecting runs
*
* The Mycelium is a persistent knowledge graph that grows across runs.
* Physical fungal nodes on the world map serve as access points.
* Players deposit discoveries and extract memories from past runs.
*/
// ─── Persistent Graph (stored in MetaState) ─────────────────────
/** A knowledge node in the Mycelium graph */
export interface MyceliumKnowledgeNode {
/** Unique node ID (format: "type:id", e.g. "element:Na") */
id: string;
/** Knowledge category */
type: 'element' | 'reaction' | 'compound' | 'creature';
/** Reference to the specific knowledge item */
knowledgeId: string;
/** Run on which this was first deposited */
depositedOnRun: number;
/** Knowledge strength 01 (increases with repeated deposits) */
strength: number;
}
/** An edge connecting two knowledge nodes */
export interface MyceliumEdge {
/** Source node ID */
from: string;
/** Target node ID */
to: string;
/** Connection strength 01 */
weight: number;
}
/** The full persistent Mycelium graph */
export interface MyceliumGraph {
/** Knowledge nodes */
nodes: MyceliumKnowledgeNode[];
/** Connections between nodes */
edges: MyceliumEdge[];
/** Total deposits across all runs */
totalDeposits: number;
/** Total extractions across all runs */
totalExtractions: number;
}
// ─── Memory Flashes (retrieved from past runs) ──────────────────
/** A memory fragment surfaced by the Mycelium */
export interface MemoryFlash {
/** What kind of hint this provides */
type: 'element_hint' | 'reaction_hint' | 'creature_hint' | 'lore';
/** Display text for the player */
text: string;
/** Display text in Russian */
textRu: string;
/** Which run deposited this knowledge */
sourceRunId: number;
/** How clear/strong this memory is (01) */
clarity: number;
}
// ─── Mycosis (visual distortion state) ──────────────────────────
/** Player's current mycosis (fungal influence) state */
export interface MycosisState {
/** Current mycosis intensity 01 */
level: number;
/** Accumulated exposure time near fungal nodes (ms) */
exposure: number;
/** Whether hidden info is currently being revealed */
revealing: boolean;
}
/** Mycosis configuration constants */
export const MYCOSIS_CONFIG = {
/** How fast mycosis builds up (level per second of exposure) */
buildRate: 0.08,
/** How fast mycosis decays when away from nodes (level per second) */
decayRate: 0.03,
/** Mycosis level at which hidden information is revealed */
revealThreshold: 0.5,
/** Maximum mycosis level */
maxLevel: 1.0,
/** Tint color for mycosis visual effect (greenish-purple) */
tintColor: 0x6644aa,
/** Maximum tint alpha at full mycosis */
maxTintAlpha: 0.25,
} as const;
// ─── Fungal Nodes (world map entities) ──────────────────────────
/** String data for a fungal node entity (not stored in ECS) */
export interface FungalNodeInfo {
/** Tile position */
tileX: number;
tileY: number;
/** Index in the world's fungal node list */
nodeIndex: number;
}
/** Configuration for fungal node spawning */
export const FUNGAL_NODE_CONFIG = {
/** Interaction range in pixels */
interactRange: 48,
/** Minimum distance between fungal nodes (in tiles) */
minSpacing: 8,
/** Target number of nodes per map (adjusted by map size) */
targetDensity: 0.003,
/** Tiles where fungal nodes can spawn */
spawnOnTiles: ['ground', 'scorched-earth'],
/** Base glow color (bioluminescent green) */
glowColor: 0x44ff88,
/** Glow pulse speed (radians per second) */
glowPulseSpeed: 1.5,
/** Base glow radius in pixels */
glowRadius: 6,
/** Sprite radius */
spriteRadius: 5,
/** Sprite color */
spriteColor: 0x33cc66,
} as const;
// ─── Spore Bonuses (Cradle shop) ─────────────────────────────────
/** A purchasable bonus at the Spore Cradle */
export interface SporeBonus {
id: string;
name: string;
nameRu: string;
description: string;
descriptionRu: string;
/** Spore cost */
cost: number;
/** Effect applied at run start */
effect: BonusEffect;
/** Whether this bonus can be purchased multiple times per run */
repeatable: boolean;
}
/** Bonus effect types */
export type BonusEffect =
| { type: 'extra_health'; amount: number }
| { type: 'extra_element'; symbol: string; quantity: number }
| { type: 'knowledge_boost'; multiplier: number };
// ─── Deposit / Extract results ───────────────────────────────────
/** Result of depositing knowledge at a fungal node */
export interface DepositResult {
/** How many new nodes were added to the graph */
newNodes: number;
/** How many existing nodes were strengthened */
strengthened: number;
/** How many new edges were created */
newEdges: number;
}
/** Result of extracting knowledge from a fungal node */
export interface ExtractResult {
/** Memory flashes retrieved */
flashes: MemoryFlash[];
/** Whether mycosis was triggered/increased */
mycosisIncreased: boolean;
}

View File

@@ -20,6 +20,7 @@ export function createMetaState(): MetaState {
bestRunTime: 0,
bestRunDiscoveries: 0,
runHistory: [],
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
};
}

View File

@@ -5,7 +5,7 @@
* Uses a simple key-value pattern with a single object store.
*/
import type { MetaState, CodexEntry, RunSummary } from './types';
import type { MetaState, CodexEntry, RunSummary, MyceliumGraphData } from './types';
import { createMetaState } from './meta';
const DB_NAME = 'synthesis-meta';
@@ -23,6 +23,7 @@ interface SerializedMetaState {
bestRunTime: number;
bestRunDiscoveries: number;
runHistory: RunSummary[];
mycelium?: MyceliumGraphData;
}
/** Open (or create) the IndexedDB database */
@@ -53,9 +54,15 @@ function serialize(meta: MetaState): SerializedMetaState {
bestRunTime: meta.bestRunTime,
bestRunDiscoveries: meta.bestRunDiscoveries,
runHistory: [...meta.runHistory],
mycelium: meta.mycelium,
};
}
/** Default empty mycelium graph */
const EMPTY_MYCELIUM: MyceliumGraphData = {
nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0,
};
/** Deserialize from plain object back to MetaState */
function deserialize(data: SerializedMetaState): MetaState {
return {
@@ -67,6 +74,7 @@ function deserialize(data: SerializedMetaState): MetaState {
bestRunTime: data.bestRunTime ?? 0,
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
runHistory: data.runHistory ?? [],
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
};
}

View File

@@ -130,6 +130,44 @@ export interface MetaState {
bestRunDiscoveries: number;
/** Run history summaries */
runHistory: RunSummary[];
/** Mycelium knowledge graph (persistent between runs) */
mycelium: MyceliumGraphData;
}
/** Serializable Mycelium graph data (stored in MetaState) */
export interface MyceliumGraphData {
/** Knowledge nodes */
nodes: MyceliumNodeData[];
/** Connections between nodes */
edges: MyceliumEdgeData[];
/** Total deposits across all runs */
totalDeposits: number;
/** Total extractions across all runs */
totalExtractions: number;
}
/** A knowledge node in the persistent Mycelium graph */
export interface MyceliumNodeData {
/** Unique node ID (format: "type:id", e.g. "element:Na") */
id: string;
/** Knowledge category */
type: 'element' | 'reaction' | 'compound' | 'creature';
/** Reference to the specific knowledge item */
knowledgeId: string;
/** Run on which this was first deposited */
depositedOnRun: number;
/** Knowledge strength 01 (increases with repeated deposits) */
strength: number;
}
/** An edge connecting two knowledge nodes in the Mycelium */
export interface MyceliumEdgeData {
/** Source node ID */
from: string;
/** Target node ID */
to: string;
/** Connection strength 01 */
weight: number;
}
export interface RunSummary {

View File

@@ -11,6 +11,9 @@ import schoolsData from '../data/schools.json';
import type { SchoolData, MetaState } from '../run/types';
import { isSchoolUnlocked } from '../run/meta';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from '../mycelium/shop';
import { getGraphStats } from '../mycelium/graph';
import type { BonusEffect } from '../mycelium/types';
const schools = schoolsData as SchoolData[];
@@ -22,6 +25,9 @@ export class CradleScene extends Phaser.Scene {
private particleGraphics!: Phaser.GameObjects.Graphics;
private introTimer = 0;
private introComplete = false;
private purchasedEffects: BonusEffect[] = [];
private sporeCountText!: Phaser.GameObjects.Text;
private shopContainer!: Phaser.GameObjects.Container;
constructor() {
super({ key: 'CradleScene' });
@@ -33,6 +39,8 @@ export class CradleScene extends Phaser.Scene {
this.schoolCards = [];
this.introTimer = 0;
this.introComplete = false;
this.purchasedEffects = [];
resetShopSession();
}
create(): void {
@@ -91,16 +99,25 @@ export class CradleScene extends Phaser.Scene {
});
// Meta info (top-right)
const graphStats = getGraphStats(this.meta.mycelium);
const metaInfo = [
`Споры: ${this.meta.spores}`,
`Раны: ${this.meta.totalRuns}`,
`Кодекс: ${this.meta.codex.length}`,
`Мицелий: ${graphStats.nodeCount} узлов`,
].join(' | ');
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
fontSize: '11px',
color: '#334433',
fontFamily: 'monospace',
}).setOrigin(1, 0).setDepth(10);
// Spore count (top-right, below meta info, updates dynamically)
this.sporeCountText = this.add.text(GAME_WIDTH - 12, 28, `🍄 Споры: ${this.meta.spores}`, {
fontSize: '13px',
color: '#44ff88',
fontFamily: 'monospace',
});
this.sporeCountText.setOrigin(1, 0).setDepth(10);
}
private showSchoolSelection(): void {
@@ -226,6 +243,11 @@ export class CradleScene extends Phaser.Scene {
this.schoolCards.push(container);
}
// Spore shop (bottom area, only if player has spores)
if (this.meta.spores > 0) {
this.createSporeShop(cx);
}
// Start button hint
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
fontSize: '13px',
@@ -245,6 +267,100 @@ export class CradleScene extends Phaser.Scene {
});
}
/** Create the spore shop UI below school cards */
private createSporeShop(cx: number): void {
this.shopContainer = this.add.container(0, 0);
this.shopContainer.setDepth(10);
const shopY = 440;
const shopTitle = this.add.text(cx, shopY, '🍄 Дары Мицелия (потрать споры)', {
fontSize: '13px',
color: '#33aa66',
fontFamily: 'monospace',
});
shopTitle.setOrigin(0.5);
this.shopContainer.add(shopTitle);
const bonuses = getAvailableBonuses();
const btnWidth = 200;
const btnHeight = 50;
const spacing = 10;
const totalWidth = bonuses.length * (btnWidth + spacing) - spacing;
const startX = cx - totalWidth / 2 + btnWidth / 2;
for (let i = 0; i < bonuses.length; i++) {
const bonus = bonuses[i];
const bx = startX + i * (btnWidth + spacing);
const by = shopY + 45;
const bg = this.add.rectangle(bx, by, btnWidth, btnHeight, 0x0a1a0f, 0.85);
const canBuy = canAffordBonus(this.meta, bonus.id);
const borderColor = canBuy ? 0x33cc66 : 0x333333;
bg.setStrokeStyle(1, borderColor);
this.shopContainer.add(bg);
const label = this.add.text(bx, by - 10, bonus.nameRu, {
fontSize: '11px',
color: canBuy ? '#88ffaa' : '#555555',
fontFamily: 'monospace',
});
label.setOrigin(0.5);
this.shopContainer.add(label);
const costText = this.add.text(bx, by + 10, `${bonus.cost} спор`, {
fontSize: '10px',
color: canBuy ? '#44aa66' : '#444444',
fontFamily: 'monospace',
});
costText.setOrigin(0.5);
this.shopContainer.add(costText);
if (canBuy) {
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setStrokeStyle(2, 0x00ff88));
bg.on('pointerout', () => bg.setStrokeStyle(1, 0x33cc66));
bg.on('pointerdown', () => {
const effect = purchaseBonus(this.meta, bonus.id);
if (effect) {
this.purchasedEffects.push(effect);
this.sporeCountText.setText(`🍄 Споры: ${this.meta.spores}`);
// Visual feedback
bg.setFillStyle(0x1a3a1f, 0.9);
label.setColor('#ffffff');
costText.setText('✓ куплено');
costText.setColor('#00ff88');
bg.removeInteractive();
// Refresh other buttons' affordability
this.refreshShopButtons();
}
});
}
}
// Fade in shop
this.shopContainer.setAlpha(0);
this.tweens.add({
targets: this.shopContainer,
alpha: 1,
duration: 600,
delay: 800,
});
}
/** Refresh shop button states after a purchase */
private refreshShopButtons(): void {
// Destroy and recreate shop — simplest approach
if (this.shopContainer) {
this.shopContainer.destroy();
}
if (this.meta.spores > 0) {
this.createSporeShop(GAME_WIDTH / 2);
this.shopContainer.setAlpha(1);
}
}
private startRun(school: SchoolData): void {
// Flash effect
this.cameras.main.flash(300, 0, 255, 136);
@@ -254,6 +370,7 @@ export class CradleScene extends Phaser.Scene {
meta: this.meta,
schoolId: school.id,
runId: this.meta.totalRuns + 1,
purchasedEffects: this.purchasedEffects,
});
});
}

View File

@@ -59,6 +59,15 @@ import {
} from '../run/crisis';
import { getEscalationEffects } from '../run/escalation';
// Mycelium imports
import { FungalNode } from '../ecs/components';
import { spawnFungalNodes } from '../mycelium/nodes';
import { depositKnowledge } from '../mycelium/graph';
import { extractMemoryFlashes } from '../mycelium/knowledge';
import { createMycosisState, updateMycosis, getMycosisVisuals } from '../mycelium/mycosis';
import type { FungalNodeInfo, MycosisState, MemoryFlash } from '../mycelium/types';
import { FUNGAL_NODE_CONFIG, MYCOSIS_CONFIG } from '../mycelium/types';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
@@ -107,15 +116,31 @@ export class GameScene extends Phaser.Scene {
private playerDead = false;
private crisisOverlay!: Phaser.GameObjects.Rectangle;
// Mycelium state
private fungalNodeData!: Map<number, FungalNodeInfo>;
private mycosisState!: MycosisState;
private mycosisOverlay!: Phaser.GameObjects.Rectangle;
private memoryFlashText!: Phaser.GameObjects.Text;
private memoryFlashTimer = 0;
private fungalNodeGlowGraphics!: Phaser.GameObjects.Graphics;
private hasDepositedThisRun = false;
constructor() {
super({ key: 'GameScene' });
}
init(data: { meta: MetaState; schoolId: string; runId: number }): void {
// Purchased bonuses from Cradle shop
private purchasedEffects: import('../mycelium/types').BonusEffect[] = [];
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[] }): void {
this.meta = data.meta;
this.runState = createRunState(data.runId, data.schoolId);
this.crisisState = null;
this.playerDead = false;
this.mycosisState = createMycosisState();
this.hasDepositedThisRun = false;
this.memoryFlashTimer = 0;
this.purchasedEffects = data.purchasedEffects ?? [];
}
create(): void {
@@ -142,6 +167,11 @@ export class GameScene extends Phaser.Scene {
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 5b. Spawn fungal nodes (Mycelium surface points)
this.fungalNodeData = spawnFungalNodes(
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 6. Initialize creature systems
const allSpecies = speciesDataArray as SpeciesData[];
this.speciesRegistry = new SpeciesRegistry(allSpecies);
@@ -163,8 +193,9 @@ export class GameScene extends Phaser.Scene {
this.inventory = new Inventory(500, 20);
this.quickSlots = new QuickSlots();
// Give starting elements from chosen school
// Give starting elements from chosen school + apply purchased bonuses
this.giveStartingKit();
this.applyPurchasedEffects();
// 9. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
@@ -247,6 +278,36 @@ export class GameScene extends Phaser.Scene {
this.crisisOverlay.setScrollFactor(0);
this.crisisOverlay.setDepth(90);
// 10b. Mycosis overlay (full-screen tinted rectangle, hidden by default)
this.mycosisOverlay = this.add.rectangle(
this.cameras.main.width / 2, this.cameras.main.height / 2,
this.cameras.main.width, this.cameras.main.height,
MYCOSIS_CONFIG.tintColor, 0,
);
this.mycosisOverlay.setScrollFactor(0);
this.mycosisOverlay.setDepth(91);
// 10c. Fungal node glow graphics (world-space, under entities)
this.fungalNodeGlowGraphics = this.add.graphics();
this.fungalNodeGlowGraphics.setDepth(2);
// 10d. Memory flash text (center of screen, fades in/out)
this.memoryFlashText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height / 2, '', {
fontSize: '16px',
color: '#88ffaa',
fontFamily: 'monospace',
backgroundColor: '#0a1a0fcc',
padding: { x: 12, y: 8 },
align: 'center',
wordWrap: { width: 400 },
},
);
this.memoryFlashText.setScrollFactor(0);
this.memoryFlashText.setOrigin(0.5);
this.memoryFlashText.setDepth(105);
this.memoryFlashText.setAlpha(0);
// 11. Launch UIScene overlay
this.scene.launch('UIScene');
@@ -261,6 +322,28 @@ export class GameScene extends Phaser.Scene {
};
}
/** Apply purchased spore bonuses from the Cradle shop */
private applyPurchasedEffects(): void {
for (const effect of this.purchasedEffects) {
switch (effect.type) {
case 'extra_health':
Health.max[this.playerEid] = (Health.max[this.playerEid] ?? 100) + effect.amount;
Health.current[this.playerEid] = Health.max[this.playerEid];
break;
case 'extra_element':
for (let i = 0; i < effect.quantity; i++) {
this.inventory.addItem(effect.symbol);
}
this.quickSlots.autoAssign(effect.symbol);
break;
case 'knowledge_boost':
// Stored for use when extracting memory flashes
// (increases clarity of extracted flashes)
break;
}
}
}
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData as SchoolData[];
@@ -323,23 +406,30 @@ export class GameScene extends Phaser.Scene {
this.projectileData,
);
// 7. Resource interaction (E key, debounced)
// 7. E key interaction (debounced) — fungal node has priority, then resources
const isEDown = this.keys.E.isDown;
const justPressedE = isEDown && !this.wasEDown;
this.wasEDown = isEDown;
const interaction = interactionSystem(
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
);
if (interaction) {
// Auto-assign collected items to quick slots
if (interaction.type === 'collected' || interaction.type === 'depleted') {
if (interaction.itemId) {
this.quickSlots.autoAssign(interaction.itemId);
// Record element discovery
recordDiscovery(this.runState, 'element', interaction.itemId);
// Check if player is near a fungal node first
const nearbyFungalNode = this.getNearestFungalNode();
if (justPressedE && nearbyFungalNode !== null) {
// Fungal node interaction takes priority
this.interactWithFungalNode(nearbyFungalNode);
} else {
// Normal resource interaction
const interaction = interactionSystem(
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
);
if (interaction) {
if (interaction.type === 'collected' || interaction.type === 'depleted') {
if (interaction.itemId) {
this.quickSlots.autoAssign(interaction.itemId);
recordDiscovery(this.runState, 'element', interaction.itemId);
}
}
this.showInteractionFeedback(interaction.type, interaction.itemId);
}
this.showInteractionFeedback(interaction.type, interaction.itemId);
}
// 8. Quick slot selection (1-4 keys)
@@ -396,7 +486,10 @@ export class GameScene extends Phaser.Scene {
// 9f. Creature attacks on player
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
// 9g. Crisis damage (if active)
// 9g. Mycelium — fungal node glow, interaction, mycosis
this.updateMycelium(delta);
// 9h. Crisis damage (if active)
if (this.crisisState?.active) {
applyCrisisDamage(this.crisisState, delta);
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
@@ -411,7 +504,7 @@ export class GameScene extends Phaser.Scene {
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
}
// 9h. Environmental damage from high escalation
// 9i. Environmental damage from high escalation
const escalationFx = getEscalationEffects(this.runState.escalation);
if (escalationFx.environmentalDamage > 0) {
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
@@ -527,6 +620,109 @@ export class GameScene extends Phaser.Scene {
}
}
/** Find the nearest fungal node within interaction range, or null */
private getNearestFungalNode(): number | null {
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
let nearestEid: number | null = null;
let nearestDist = Infinity;
for (const eid of nodeEids) {
const dx = Position.x[eid] - px;
const dy = Position.y[eid] - py;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= FungalNode.interactRange[eid] && dist < nearestDist) {
nearestEid = eid;
nearestDist = dist;
}
}
return nearestEid;
}
/** Mycelium system — glow animation, mycosis update, memory flash fade */
private updateMycelium(delta: number): void {
// Check proximity to any fungal node (for mycosis)
const nearNode = this.getNearestFungalNode() !== null;
// Update mycosis
updateMycosis(this.mycosisState, delta, nearNode);
const visuals = getMycosisVisuals(this.mycosisState);
this.mycosisOverlay.setFillStyle(visuals.tintColor, visuals.tintAlpha);
// Render fungal node glows
const nodeEids = query(this.gameWorld.world, [Position, FungalNode]);
this.fungalNodeGlowGraphics.clear();
for (const eid of nodeEids) {
FungalNode.glowPhase[eid] += FUNGAL_NODE_CONFIG.glowPulseSpeed * (delta / 1000);
const phase = FungalNode.glowPhase[eid];
const pulse = 0.4 + 0.6 * Math.sin(phase);
const radius = FUNGAL_NODE_CONFIG.glowRadius * (0.8 + 0.4 * pulse);
const alpha = 0.15 + 0.15 * pulse;
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha);
this.fungalNodeGlowGraphics.fillCircle(
Position.x[eid], Position.y[eid], radius,
);
// Inner brighter glow
this.fungalNodeGlowGraphics.fillStyle(FUNGAL_NODE_CONFIG.glowColor, alpha * 1.5);
this.fungalNodeGlowGraphics.fillCircle(
Position.x[eid], Position.y[eid], radius * 0.5,
);
}
// Fade memory flash text
if (this.memoryFlashTimer > 0) {
this.memoryFlashTimer -= delta;
if (this.memoryFlashTimer <= 500) {
this.memoryFlashText.setAlpha(this.memoryFlashTimer / 500);
}
if (this.memoryFlashTimer <= 0) {
this.memoryFlashText.setAlpha(0);
}
}
// Push mycosis data to registry for UIScene
this.registry.set('mycosisLevel', this.mycosisState.level);
this.registry.set('mycosisRevealing', this.mycosisState.revealing);
}
/** Handle interaction with a fungal node */
private interactWithFungalNode(nodeEid: number): void {
// First time interacting in this run → deposit discoveries
if (!this.hasDepositedThisRun) {
const result = depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
const msg = result.newNodes > 0
? `🍄 Мицелий принял ${result.newNodes} знани${result.newNodes === 1 ? 'е' : 'й'}`
: `🍄 Мицелий укрепил ${result.strengthened} связ${result.strengthened === 1 ? 'ь' : 'ей'}`;
this.showInteractionFeedback('collected', msg);
}
// Extract memory flashes from past runs
if (this.meta.mycelium.nodes.length > 0) {
const flashes = extractMemoryFlashes(this.meta.mycelium, 2);
if (flashes.length > 0) {
this.showMemoryFlash(flashes[0]);
}
} else {
this.showInteractionFeedback('collected', '🍄 Мицелий пуст... пока.');
}
}
/** Display a memory flash on screen */
private showMemoryFlash(flash: MemoryFlash): void {
// Show Russian text with clarity indicator
const clarityStars = flash.clarity >= 0.7 ? '★★★' : flash.clarity >= 0.4 ? '★★☆' : '★☆☆';
const text = `${flash.textRu}\n\n[${clarityStars}]`;
this.memoryFlashText.setText(text);
this.memoryFlashText.setAlpha(1);
this.memoryFlashTimer = 4000; // Show for 4 seconds
}
/** Manage run phase transitions and escalation */
private updateRunPhase(delta: number): void {
const phase = this.runState.phase;
@@ -576,6 +772,12 @@ export class GameScene extends Phaser.Scene {
this.playerDead = true;
this.runState.alive = false;
// Auto-deposit all discoveries into Mycelium on death
if (!this.hasDepositedThisRun) {
depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
}
// Stop UIScene
this.scene.stop('UIScene');

537
tests/mycelium.test.ts Normal file
View 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
}
}
});
});