diff --git a/IMPLEMENTATION-PLAN.md b/IMPLEMENTATION-PLAN.md index e9525d8..acf65c6 100644 --- a/IMPLEMENTATION-PLAN.md +++ b/IMPLEMENTATION-PLAN.md @@ -172,7 +172,7 @@ --- -## Phase 7: Mycelium +## Phase 7: Mycelium ✅ **Цель:** Подземная сеть, связывающая раны **Зависимости:** Phase 6 diff --git a/PROGRESS.md b/PROGRESS.md index a210d0b..788e84a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 7 — Mycelium +## Up Next: Phase 8 — First 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) | diff --git a/src/data/mycelium.json b/src/data/mycelium.json new file mode 100644 index 0000000..67f0dee --- /dev/null +++ b/src/data/mycelium.json @@ -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": "...Мицелий помнит всех, кто шёл этими тропами..." } + ] + } +} diff --git a/src/ecs/components.ts b/src/ecs/components.ts index 2a98344..d776af2 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -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 +}; diff --git a/src/mycelium/graph.ts b/src/mycelium/graph.ts new file mode 100644 index 0000000..46c3054 --- /dev/null +++ b/src/mycelium/graph.ts @@ -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(); + + 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; +} diff --git a/src/mycelium/index.ts b/src/mycelium/index.ts new file mode 100644 index 0000000..5051df5 --- /dev/null +++ b/src/mycelium/index.ts @@ -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'; diff --git a/src/mycelium/knowledge.ts b/src/mycelium/knowledge.ts new file mode 100644 index 0000000..24ce487 --- /dev/null +++ b/src/mycelium/knowledge.ts @@ -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(); + + // 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 0–1 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; +} diff --git a/src/mycelium/mycosis.ts b/src/mycelium/mycosis.ts new file mode 100644 index 0000000..cf0e1ff --- /dev/null +++ b/src/mycelium/mycosis.ts @@ -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, + }; +} diff --git a/src/mycelium/nodes.ts b/src/mycelium/nodes.ts new file mode 100644 index 0000000..08f0b5d --- /dev/null +++ b/src/mycelium/nodes.ts @@ -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 { + const nodeData = new Map(); + const tileSize = biome.tileSize; + + // Find tile IDs that fungal nodes can spawn on + const spawnTileIds = new Set(); + 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 (0–1). + * 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; +} diff --git a/src/mycelium/shop.ts b/src/mycelium/shop.ts new file mode 100644 index 0000000..ec9c71a --- /dev/null +++ b/src/mycelium/shop.ts @@ -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(); + +/** 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(); +} diff --git a/src/mycelium/types.ts b/src/mycelium/types.ts new file mode 100644 index 0000000..d77bf02 --- /dev/null +++ b/src/mycelium/types.ts @@ -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 0–1 (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 0–1 */ + 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 (0–1) */ + clarity: number; +} + +// ─── Mycosis (visual distortion state) ────────────────────────── + +/** Player's current mycosis (fungal influence) state */ +export interface MycosisState { + /** Current mycosis intensity 0–1 */ + 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; +} diff --git a/src/run/meta.ts b/src/run/meta.ts index 1934006..f2722f6 100644 --- a/src/run/meta.ts +++ b/src/run/meta.ts @@ -20,6 +20,7 @@ export function createMetaState(): MetaState { bestRunTime: 0, bestRunDiscoveries: 0, runHistory: [], + mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 }, }; } diff --git a/src/run/persistence.ts b/src/run/persistence.ts index 9dc3f89..6b545de 100644 --- a/src/run/persistence.ts +++ b/src/run/persistence.ts @@ -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, }; } diff --git a/src/run/types.ts b/src/run/types.ts index defef74..daf978e 100644 --- a/src/run/types.ts +++ b/src/run/types.ts @@ -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 0–1 (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 0–1 */ + weight: number; } export interface RunSummary { diff --git a/src/scenes/CradleScene.ts b/src/scenes/CradleScene.ts index c47d700..6fe0166 100644 --- a/src/scenes/CradleScene.ts +++ b/src/scenes/CradleScene.ts @@ -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, }); }); } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 1531d06..e5e33c6 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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; + 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'); diff --git a/tests/mycelium.test.ts b/tests/mycelium.test.ts new file mode 100644 index 0000000..0406800 --- /dev/null +++ b/tests/mycelium.test.ts @@ -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 + } + } + }); +});