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:
@@ -172,7 +172,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7: Mycelium
|
## Phase 7: Mycelium ✅
|
||||||
|
|
||||||
**Цель:** Подземная сеть, связывающая раны
|
**Цель:** Подземная сеть, связывающая раны
|
||||||
**Зависимости:** Phase 6
|
**Зависимости:** Phase 6
|
||||||
|
|||||||
21
PROGRESS.md
21
PROGRESS.md
@@ -1,7 +1,7 @@
|
|||||||
# Synthesis — Development Progress
|
# Synthesis — Development Progress
|
||||||
|
|
||||||
> **Last updated:** 2026-02-12
|
> **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] Texture cleanup for multi-run support (tilemap + minimap textures removed before recreation)
|
||||||
- [x] Unit tests — 42 run-cycle + 14 escalation = 56 tests (349 total)
|
- [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
|
## 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)_
|
_(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) |
|
| 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) |
|
| 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) |
|
| 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
75
src/data/mycelium.json
Normal 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": "...Мицелий помнит всех, кто шёл этими тропами..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,3 +83,12 @@ export const LifeCycle = {
|
|||||||
stageTimer: [] as number[], // ms remaining in current stage
|
stageTimer: [] as number[], // ms remaining in current stage
|
||||||
age: [] as number[], // total age in ms
|
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
227
src/mycelium/graph.ts
Normal 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
13
src/mycelium/index.ts
Normal 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
158
src/mycelium/knowledge.ts
Normal 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 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;
|
||||||
|
}
|
||||||
83
src/mycelium/mycosis.ts
Normal file
83
src/mycelium/mycosis.ts
Normal 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
131
src/mycelium/nodes.ts
Normal 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 (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;
|
||||||
|
}
|
||||||
65
src/mycelium/shop.ts
Normal file
65
src/mycelium/shop.ts
Normal 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
165
src/mycelium/types.ts
Normal 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 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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function createMetaState(): MetaState {
|
|||||||
bestRunTime: 0,
|
bestRunTime: 0,
|
||||||
bestRunDiscoveries: 0,
|
bestRunDiscoveries: 0,
|
||||||
runHistory: [],
|
runHistory: [],
|
||||||
|
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Uses a simple key-value pattern with a single object store.
|
* 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';
|
import { createMetaState } from './meta';
|
||||||
|
|
||||||
const DB_NAME = 'synthesis-meta';
|
const DB_NAME = 'synthesis-meta';
|
||||||
@@ -23,6 +23,7 @@ interface SerializedMetaState {
|
|||||||
bestRunTime: number;
|
bestRunTime: number;
|
||||||
bestRunDiscoveries: number;
|
bestRunDiscoveries: number;
|
||||||
runHistory: RunSummary[];
|
runHistory: RunSummary[];
|
||||||
|
mycelium?: MyceliumGraphData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open (or create) the IndexedDB database */
|
/** Open (or create) the IndexedDB database */
|
||||||
@@ -53,9 +54,15 @@ function serialize(meta: MetaState): SerializedMetaState {
|
|||||||
bestRunTime: meta.bestRunTime,
|
bestRunTime: meta.bestRunTime,
|
||||||
bestRunDiscoveries: meta.bestRunDiscoveries,
|
bestRunDiscoveries: meta.bestRunDiscoveries,
|
||||||
runHistory: [...meta.runHistory],
|
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 */
|
/** Deserialize from plain object back to MetaState */
|
||||||
function deserialize(data: SerializedMetaState): MetaState {
|
function deserialize(data: SerializedMetaState): MetaState {
|
||||||
return {
|
return {
|
||||||
@@ -67,6 +74,7 @@ function deserialize(data: SerializedMetaState): MetaState {
|
|||||||
bestRunTime: data.bestRunTime ?? 0,
|
bestRunTime: data.bestRunTime ?? 0,
|
||||||
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
|
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
|
||||||
runHistory: data.runHistory ?? [],
|
runHistory: data.runHistory ?? [],
|
||||||
|
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,44 @@ export interface MetaState {
|
|||||||
bestRunDiscoveries: number;
|
bestRunDiscoveries: number;
|
||||||
/** Run history summaries */
|
/** Run history summaries */
|
||||||
runHistory: RunSummary[];
|
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 {
|
export interface RunSummary {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import schoolsData from '../data/schools.json';
|
|||||||
import type { SchoolData, MetaState } from '../run/types';
|
import type { SchoolData, MetaState } from '../run/types';
|
||||||
import { isSchoolUnlocked } from '../run/meta';
|
import { isSchoolUnlocked } from '../run/meta';
|
||||||
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
|
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[];
|
const schools = schoolsData as SchoolData[];
|
||||||
|
|
||||||
@@ -22,6 +25,9 @@ export class CradleScene extends Phaser.Scene {
|
|||||||
private particleGraphics!: Phaser.GameObjects.Graphics;
|
private particleGraphics!: Phaser.GameObjects.Graphics;
|
||||||
private introTimer = 0;
|
private introTimer = 0;
|
||||||
private introComplete = false;
|
private introComplete = false;
|
||||||
|
private purchasedEffects: BonusEffect[] = [];
|
||||||
|
private sporeCountText!: Phaser.GameObjects.Text;
|
||||||
|
private shopContainer!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'CradleScene' });
|
super({ key: 'CradleScene' });
|
||||||
@@ -33,6 +39,8 @@ export class CradleScene extends Phaser.Scene {
|
|||||||
this.schoolCards = [];
|
this.schoolCards = [];
|
||||||
this.introTimer = 0;
|
this.introTimer = 0;
|
||||||
this.introComplete = false;
|
this.introComplete = false;
|
||||||
|
this.purchasedEffects = [];
|
||||||
|
resetShopSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
@@ -91,16 +99,25 @@ export class CradleScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Meta info (top-right)
|
// Meta info (top-right)
|
||||||
|
const graphStats = getGraphStats(this.meta.mycelium);
|
||||||
const metaInfo = [
|
const metaInfo = [
|
||||||
`Споры: ${this.meta.spores}`,
|
|
||||||
`Раны: ${this.meta.totalRuns}`,
|
`Раны: ${this.meta.totalRuns}`,
|
||||||
`Кодекс: ${this.meta.codex.length}`,
|
`Кодекс: ${this.meta.codex.length}`,
|
||||||
|
`Мицелий: ${graphStats.nodeCount} узлов`,
|
||||||
].join(' | ');
|
].join(' | ');
|
||||||
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
|
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#334433',
|
color: '#334433',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
}).setOrigin(1, 0).setDepth(10);
|
}).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 {
|
private showSchoolSelection(): void {
|
||||||
@@ -226,6 +243,11 @@ export class CradleScene extends Phaser.Scene {
|
|||||||
this.schoolCards.push(container);
|
this.schoolCards.push(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spore shop (bottom area, only if player has spores)
|
||||||
|
if (this.meta.spores > 0) {
|
||||||
|
this.createSporeShop(cx);
|
||||||
|
}
|
||||||
|
|
||||||
// Start button hint
|
// Start button hint
|
||||||
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
|
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
|
||||||
fontSize: '13px',
|
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 {
|
private startRun(school: SchoolData): void {
|
||||||
// Flash effect
|
// Flash effect
|
||||||
this.cameras.main.flash(300, 0, 255, 136);
|
this.cameras.main.flash(300, 0, 255, 136);
|
||||||
@@ -254,6 +370,7 @@ export class CradleScene extends Phaser.Scene {
|
|||||||
meta: this.meta,
|
meta: this.meta,
|
||||||
schoolId: school.id,
|
schoolId: school.id,
|
||||||
runId: this.meta.totalRuns + 1,
|
runId: this.meta.totalRuns + 1,
|
||||||
|
purchasedEffects: this.purchasedEffects,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ import {
|
|||||||
} from '../run/crisis';
|
} from '../run/crisis';
|
||||||
import { getEscalationEffects } from '../run/escalation';
|
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 {
|
export class GameScene extends Phaser.Scene {
|
||||||
private gameWorld!: GameWorld;
|
private gameWorld!: GameWorld;
|
||||||
private bridge!: PhaserBridge;
|
private bridge!: PhaserBridge;
|
||||||
@@ -107,15 +116,31 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private playerDead = false;
|
private playerDead = false;
|
||||||
private crisisOverlay!: Phaser.GameObjects.Rectangle;
|
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() {
|
constructor() {
|
||||||
super({ key: 'GameScene' });
|
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.meta = data.meta;
|
||||||
this.runState = createRunState(data.runId, data.schoolId);
|
this.runState = createRunState(data.runId, data.schoolId);
|
||||||
this.crisisState = null;
|
this.crisisState = null;
|
||||||
this.playerDead = false;
|
this.playerDead = false;
|
||||||
|
this.mycosisState = createMycosisState();
|
||||||
|
this.hasDepositedThisRun = false;
|
||||||
|
this.memoryFlashTimer = 0;
|
||||||
|
this.purchasedEffects = data.purchasedEffects ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
@@ -142,6 +167,11 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
|
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
|
// 6. Initialize creature systems
|
||||||
const allSpecies = speciesDataArray as SpeciesData[];
|
const allSpecies = speciesDataArray as SpeciesData[];
|
||||||
this.speciesRegistry = new SpeciesRegistry(allSpecies);
|
this.speciesRegistry = new SpeciesRegistry(allSpecies);
|
||||||
@@ -163,8 +193,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.inventory = new Inventory(500, 20);
|
this.inventory = new Inventory(500, 20);
|
||||||
this.quickSlots = new QuickSlots();
|
this.quickSlots = new QuickSlots();
|
||||||
|
|
||||||
// Give starting elements from chosen school
|
// Give starting elements from chosen school + apply purchased bonuses
|
||||||
this.giveStartingKit();
|
this.giveStartingKit();
|
||||||
|
this.applyPurchasedEffects();
|
||||||
|
|
||||||
// 9. Camera — follow player, zoom via scroll wheel
|
// 9. Camera — follow player, zoom via scroll wheel
|
||||||
const worldPixelW = biome.mapWidth * biome.tileSize;
|
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||||
@@ -247,6 +278,36 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.crisisOverlay.setScrollFactor(0);
|
this.crisisOverlay.setScrollFactor(0);
|
||||||
this.crisisOverlay.setDepth(90);
|
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
|
// 11. Launch UIScene overlay
|
||||||
this.scene.launch('UIScene');
|
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 */
|
/** Give the player their school's starting elements */
|
||||||
private giveStartingKit(): void {
|
private giveStartingKit(): void {
|
||||||
const schools = schoolsData as SchoolData[];
|
const schools = schoolsData as SchoolData[];
|
||||||
@@ -323,23 +406,30 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.projectileData,
|
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 isEDown = this.keys.E.isDown;
|
||||||
const justPressedE = isEDown && !this.wasEDown;
|
const justPressedE = isEDown && !this.wasEDown;
|
||||||
this.wasEDown = isEDown;
|
this.wasEDown = isEDown;
|
||||||
const interaction = interactionSystem(
|
|
||||||
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
|
// Check if player is near a fungal node first
|
||||||
);
|
const nearbyFungalNode = this.getNearestFungalNode();
|
||||||
if (interaction) {
|
if (justPressedE && nearbyFungalNode !== null) {
|
||||||
// Auto-assign collected items to quick slots
|
// Fungal node interaction takes priority
|
||||||
if (interaction.type === 'collected' || interaction.type === 'depleted') {
|
this.interactWithFungalNode(nearbyFungalNode);
|
||||||
if (interaction.itemId) {
|
} else {
|
||||||
this.quickSlots.autoAssign(interaction.itemId);
|
// Normal resource interaction
|
||||||
// Record element discovery
|
const interaction = interactionSystem(
|
||||||
recordDiscovery(this.runState, 'element', interaction.itemId);
|
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)
|
// 8. Quick slot selection (1-4 keys)
|
||||||
@@ -396,7 +486,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// 9f. Creature attacks on player
|
// 9f. Creature attacks on player
|
||||||
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
|
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) {
|
if (this.crisisState?.active) {
|
||||||
applyCrisisDamage(this.crisisState, delta);
|
applyCrisisDamage(this.crisisState, delta);
|
||||||
const crisisDmg = getCrisisPlayerDamage(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);
|
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);
|
const escalationFx = getEscalationEffects(this.runState.escalation);
|
||||||
if (escalationFx.environmentalDamage > 0) {
|
if (escalationFx.environmentalDamage > 0) {
|
||||||
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
|
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 */
|
/** Manage run phase transitions and escalation */
|
||||||
private updateRunPhase(delta: number): void {
|
private updateRunPhase(delta: number): void {
|
||||||
const phase = this.runState.phase;
|
const phase = this.runState.phase;
|
||||||
@@ -576,6 +772,12 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.playerDead = true;
|
this.playerDead = true;
|
||||||
this.runState.alive = false;
|
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
|
// Stop UIScene
|
||||||
this.scene.stop('UIScene');
|
this.scene.stop('UIScene');
|
||||||
|
|
||||||
|
|||||||
537
tests/mycelium.test.ts
Normal file
537
tests/mycelium.test.ts
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type { MyceliumGraphData, MetaState, RunState } from '../src/run/types';
|
||||||
|
import { RunPhase } from '../src/run/types';
|
||||||
|
import { createMetaState } from '../src/run/meta';
|
||||||
|
import {
|
||||||
|
createMyceliumGraph,
|
||||||
|
depositKnowledge,
|
||||||
|
getNode,
|
||||||
|
getEdge,
|
||||||
|
getNodesByType,
|
||||||
|
getConnectedNodes,
|
||||||
|
getGraphStats,
|
||||||
|
} from '../src/mycelium/graph';
|
||||||
|
import {
|
||||||
|
extractMemoryFlashes,
|
||||||
|
generateFlashText,
|
||||||
|
} from '../src/mycelium/knowledge';
|
||||||
|
import {
|
||||||
|
createMycosisState,
|
||||||
|
updateMycosis,
|
||||||
|
getMycosisVisuals,
|
||||||
|
} from '../src/mycelium/mycosis';
|
||||||
|
import {
|
||||||
|
getAvailableBonuses,
|
||||||
|
purchaseBonus,
|
||||||
|
canAffordBonus,
|
||||||
|
} from '../src/mycelium/shop';
|
||||||
|
import {
|
||||||
|
spawnFungalNodes,
|
||||||
|
} from '../src/mycelium/nodes';
|
||||||
|
import type { MycosisState, MemoryFlash, FungalNodeInfo } from '../src/mycelium/types';
|
||||||
|
import { MYCOSIS_CONFIG } from '../src/mycelium/types';
|
||||||
|
|
||||||
|
// ─── Test helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createTestRunState(runId: number = 1): RunState {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
schoolId: 'alchemist',
|
||||||
|
phase: RunPhase.Exploration,
|
||||||
|
phaseTimer: 0,
|
||||||
|
elapsed: 60000,
|
||||||
|
escalation: 0,
|
||||||
|
crisisActive: false,
|
||||||
|
crisisResolved: false,
|
||||||
|
discoveries: {
|
||||||
|
elements: new Set(['Na', 'Cl', 'H', 'O']),
|
||||||
|
reactions: new Set(['Na+Cl']),
|
||||||
|
compounds: new Set(['NaCl']),
|
||||||
|
creatures: new Set(['crystallid']),
|
||||||
|
},
|
||||||
|
alive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestMeta(): MetaState {
|
||||||
|
const meta = createMetaState();
|
||||||
|
meta.totalRuns = 2;
|
||||||
|
meta.spores = 100;
|
||||||
|
meta.mycelium = createMyceliumGraph();
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mycelium Graph Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Mycelium Graph', () => {
|
||||||
|
let graph: MyceliumGraphData;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
graph = createMyceliumGraph();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an empty graph', () => {
|
||||||
|
expect(graph.nodes).toHaveLength(0);
|
||||||
|
expect(graph.edges).toHaveLength(0);
|
||||||
|
expect(graph.totalDeposits).toBe(0);
|
||||||
|
expect(graph.totalExtractions).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deposit element knowledge from a run', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
const result = depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
expect(result.newNodes).toBeGreaterThan(0);
|
||||||
|
// Should have nodes for elements + reactions + compounds + creatures
|
||||||
|
expect(graph.nodes.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(graph.totalDeposits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create nodes for all discovery types', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// 4 elements (Na, Cl, H, O) + 1 reaction + 1 compound + 1 creature = 7
|
||||||
|
expect(graph.nodes).toHaveLength(7);
|
||||||
|
|
||||||
|
const elementNodes = getNodesByType(graph, 'element');
|
||||||
|
expect(elementNodes).toHaveLength(4);
|
||||||
|
|
||||||
|
const reactionNodes = getNodesByType(graph, 'reaction');
|
||||||
|
expect(reactionNodes).toHaveLength(1);
|
||||||
|
|
||||||
|
const compoundNodes = getNodesByType(graph, 'compound');
|
||||||
|
expect(compoundNodes).toHaveLength(1);
|
||||||
|
|
||||||
|
const creatureNodes = getNodesByType(graph, 'creature');
|
||||||
|
expect(creatureNodes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create edges between related discoveries', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// Elements that form a reaction should be connected to the reaction
|
||||||
|
// Na + Cl → NaCl: edges Na→reaction, Cl→reaction, reaction→compound
|
||||||
|
expect(graph.edges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strengthen existing nodes on re-deposit', () => {
|
||||||
|
const run1 = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run1);
|
||||||
|
|
||||||
|
const naNode = getNode(graph, 'element:Na');
|
||||||
|
expect(naNode).toBeDefined();
|
||||||
|
const initialStrength = naNode!.strength;
|
||||||
|
|
||||||
|
// Second deposit with same discoveries should strengthen
|
||||||
|
const run2 = createTestRunState(2);
|
||||||
|
run2.discoveries.elements = new Set(['Na']); // Only Na
|
||||||
|
run2.discoveries.reactions = new Set();
|
||||||
|
run2.discoveries.compounds = new Set();
|
||||||
|
run2.discoveries.creatures = new Set();
|
||||||
|
const result = depositKnowledge(graph, run2);
|
||||||
|
|
||||||
|
expect(result.strengthened).toBe(1);
|
||||||
|
expect(result.newNodes).toBe(0);
|
||||||
|
|
||||||
|
const naNodeAfter = getNode(graph, 'element:Na');
|
||||||
|
expect(naNodeAfter!.strength).toBeGreaterThan(initialStrength);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap node strength at 1.0', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
run.discoveries.elements = new Set(['Na']);
|
||||||
|
run.discoveries.reactions = new Set();
|
||||||
|
run.discoveries.compounds = new Set();
|
||||||
|
run.discoveries.creatures = new Set();
|
||||||
|
|
||||||
|
// Deposit many times
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
const naNode = getNode(graph, 'element:Na');
|
||||||
|
expect(naNode!.strength).toBeLessThanOrEqual(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up nodes by ID', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
const node = getNode(graph, 'element:Na');
|
||||||
|
expect(node).toBeDefined();
|
||||||
|
expect(node!.type).toBe('element');
|
||||||
|
expect(node!.knowledgeId).toBe('Na');
|
||||||
|
|
||||||
|
const missing = getNode(graph, 'element:Au');
|
||||||
|
expect(missing).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should look up edges between nodes', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// There should be edges from Na and Cl to the Na+Cl reaction
|
||||||
|
const naEdge = getEdge(graph, 'element:Na', 'reaction:Na+Cl');
|
||||||
|
// Edge may exist in either direction — check both
|
||||||
|
const naEdgeReverse = getEdge(graph, 'reaction:Na+Cl', 'element:Na');
|
||||||
|
expect(naEdge ?? naEdgeReverse).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return connected nodes', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
// Na should be connected to the Na+Cl reaction
|
||||||
|
const connected = getConnectedNodes(graph, 'element:Na');
|
||||||
|
expect(connected.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report graph statistics', () => {
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
|
||||||
|
const stats = getGraphStats(graph);
|
||||||
|
expect(stats.nodeCount).toBe(7);
|
||||||
|
expect(stats.edgeCount).toBeGreaterThan(0);
|
||||||
|
expect(stats.totalDeposits).toBe(1);
|
||||||
|
expect(stats.averageStrength).toBeGreaterThan(0);
|
||||||
|
expect(stats.averageStrength).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Knowledge System Tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Knowledge System', () => {
|
||||||
|
let graph: MyceliumGraphData;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
graph = createMyceliumGraph();
|
||||||
|
const run = createTestRunState(1);
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract memory flashes from populated graph', () => {
|
||||||
|
const flashes = extractMemoryFlashes(graph, 3);
|
||||||
|
expect(flashes.length).toBeGreaterThan(0);
|
||||||
|
expect(flashes.length).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty flashes from empty graph', () => {
|
||||||
|
const emptyGraph = createMyceliumGraph();
|
||||||
|
const flashes = extractMemoryFlashes(emptyGraph, 3);
|
||||||
|
expect(flashes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate flash text for element hints', () => {
|
||||||
|
const flash = generateFlashText('element', 'Na');
|
||||||
|
expect(flash.type).toBe('element_hint');
|
||||||
|
expect(flash.text.length).toBeGreaterThan(0);
|
||||||
|
expect(flash.textRu.length).toBeGreaterThan(0);
|
||||||
|
// Should contain the element name somewhere
|
||||||
|
expect(flash.text).toContain('Na');
|
||||||
|
expect(flash.textRu).toContain('Na');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate flash text for reaction hints', () => {
|
||||||
|
const flash = generateFlashText('reaction', 'Na+Cl');
|
||||||
|
expect(flash.type).toBe('reaction_hint');
|
||||||
|
expect(flash.text.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate flash text for creature hints', () => {
|
||||||
|
const flash = generateFlashText('creature', 'crystallid');
|
||||||
|
expect(flash.type).toBe('creature_hint');
|
||||||
|
expect(flash.text.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment extraction counter', () => {
|
||||||
|
expect(graph.totalExtractions).toBe(0);
|
||||||
|
extractMemoryFlashes(graph, 3);
|
||||||
|
expect(graph.totalExtractions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer stronger nodes for flashes', () => {
|
||||||
|
// Strengthen Na by depositing multiple times
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const run = createTestRunState(i + 2);
|
||||||
|
run.discoveries.elements = new Set(['Na']);
|
||||||
|
run.discoveries.reactions = new Set();
|
||||||
|
run.discoveries.compounds = new Set();
|
||||||
|
run.discoveries.creatures = new Set();
|
||||||
|
depositKnowledge(graph, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract many flashes — Na should appear frequently
|
||||||
|
let naCount = 0;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const flashes = extractMemoryFlashes(graph, 3);
|
||||||
|
for (const f of flashes) {
|
||||||
|
if (f.text.includes('Na') || f.textRu.includes('Na')) naCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Na should appear at least once in 10 rounds of 3 flashes
|
||||||
|
expect(naCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mycosis Tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Mycosis', () => {
|
||||||
|
let state: MycosisState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = createMycosisState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create initial mycosis state at zero', () => {
|
||||||
|
expect(state.level).toBe(0);
|
||||||
|
expect(state.exposure).toBe(0);
|
||||||
|
expect(state.revealing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increase level when near a fungal node', () => {
|
||||||
|
updateMycosis(state, 1000, true); // 1 second near a node
|
||||||
|
expect(state.level).toBeGreaterThan(0);
|
||||||
|
expect(state.exposure).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should decrease level when away from nodes', () => {
|
||||||
|
// Build up some mycosis
|
||||||
|
updateMycosis(state, 5000, true);
|
||||||
|
const peakLevel = state.level;
|
||||||
|
|
||||||
|
// Move away
|
||||||
|
updateMycosis(state, 2000, false);
|
||||||
|
expect(state.level).toBeLessThan(peakLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not go below zero', () => {
|
||||||
|
updateMycosis(state, 10000, false);
|
||||||
|
expect(state.level).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exceed max level', () => {
|
||||||
|
updateMycosis(state, 100000, true); // Very long exposure
|
||||||
|
expect(state.level).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start revealing at threshold', () => {
|
||||||
|
// Build up to reveal threshold
|
||||||
|
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
|
||||||
|
updateMycosis(state, timeToThreshold + 1000, true);
|
||||||
|
expect(state.revealing).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop revealing when level drops below threshold', () => {
|
||||||
|
// Build up past threshold
|
||||||
|
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
|
||||||
|
updateMycosis(state, timeToThreshold + 2000, true);
|
||||||
|
expect(state.revealing).toBe(true);
|
||||||
|
|
||||||
|
// Let it decay
|
||||||
|
const decayTime = (state.level - MYCOSIS_CONFIG.revealThreshold + 0.1) / MYCOSIS_CONFIG.decayRate * 1000;
|
||||||
|
updateMycosis(state, decayTime + 1000, false);
|
||||||
|
expect(state.revealing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return visual parameters based on level', () => {
|
||||||
|
updateMycosis(state, 5000, true);
|
||||||
|
|
||||||
|
const visuals = getMycosisVisuals(state);
|
||||||
|
expect(visuals.tintColor).toBe(MYCOSIS_CONFIG.tintColor);
|
||||||
|
expect(visuals.tintAlpha).toBeGreaterThan(0);
|
||||||
|
expect(visuals.tintAlpha).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxTintAlpha);
|
||||||
|
expect(visuals.distortionStrength).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have zero visuals when mycosis is zero', () => {
|
||||||
|
const visuals = getMycosisVisuals(state);
|
||||||
|
expect(visuals.tintAlpha).toBe(0);
|
||||||
|
expect(visuals.distortionStrength).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Spore Shop Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Spore Shop', () => {
|
||||||
|
it('should list available bonuses', () => {
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
expect(bonuses.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Each bonus should have required fields
|
||||||
|
for (const bonus of bonuses) {
|
||||||
|
expect(bonus.id).toBeTruthy();
|
||||||
|
expect(bonus.nameRu).toBeTruthy();
|
||||||
|
expect(bonus.cost).toBeGreaterThan(0);
|
||||||
|
expect(bonus.effect).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check affordability correctly', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
meta.spores = 15;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const cheapBonus = bonuses.find(b => b.cost <= 15);
|
||||||
|
const expensiveBonus = bonuses.find(b => b.cost > 15);
|
||||||
|
|
||||||
|
if (cheapBonus) {
|
||||||
|
expect(canAffordBonus(meta, cheapBonus.id)).toBe(true);
|
||||||
|
}
|
||||||
|
if (expensiveBonus) {
|
||||||
|
expect(canAffordBonus(meta, expensiveBonus.id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduct spores on purchase', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
const initialSpores = meta.spores;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const bonus = bonuses[0];
|
||||||
|
const result = purchaseBonus(meta, bonus.id);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(meta.spores).toBe(initialSpores - bonus.cost);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the purchased effect', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const bonus = bonuses.find(b => b.effect.type === 'extra_health');
|
||||||
|
expect(bonus).toBeDefined();
|
||||||
|
|
||||||
|
const result = purchaseBonus(meta, bonus!.id);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.type).toBe('extra_health');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to purchase with insufficient spores', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
meta.spores = 0;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const result = purchaseBonus(meta, bonuses[0].id);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(meta.spores).toBe(0); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow non-repeatable bonuses to be purchased twice', () => {
|
||||||
|
const meta = createTestMeta();
|
||||||
|
meta.spores = 200;
|
||||||
|
|
||||||
|
const bonuses = getAvailableBonuses();
|
||||||
|
const nonRepeatable = bonuses.find(b => !b.repeatable);
|
||||||
|
if (!nonRepeatable) return; // skip if all are repeatable
|
||||||
|
|
||||||
|
const result1 = purchaseBonus(meta, nonRepeatable.id);
|
||||||
|
expect(result1).not.toBeNull();
|
||||||
|
|
||||||
|
const result2 = purchaseBonus(meta, nonRepeatable.id);
|
||||||
|
expect(result2).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Fungal Node Spawning Tests ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('Fungal Node Spawning', () => {
|
||||||
|
// Build a simple 20x20 grid with walkable ground
|
||||||
|
const biome = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
nameRu: 'Тест',
|
||||||
|
description: '',
|
||||||
|
descriptionRu: '',
|
||||||
|
tileSize: 16,
|
||||||
|
mapWidth: 20,
|
||||||
|
mapHeight: 20,
|
||||||
|
tiles: [
|
||||||
|
{ id: 0, name: 'ground', nameRu: 'земля', color: '#555', walkable: true, damage: 0, interactive: false, resource: false },
|
||||||
|
{ id: 1, name: 'acid-deep', nameRu: 'кислота', color: '#0f0', walkable: false, damage: 5, interactive: false, resource: false },
|
||||||
|
{ id: 2, name: 'scorched-earth', nameRu: 'выжженная', color: '#333', walkable: true, damage: 0, interactive: false, resource: false },
|
||||||
|
],
|
||||||
|
generation: {
|
||||||
|
elevationScale: 0.1,
|
||||||
|
detailScale: 0.2,
|
||||||
|
elevationRules: [{ below: 0.5, tileId: 0 }],
|
||||||
|
geyserThreshold: 0.9,
|
||||||
|
mineralThreshold: 0.8,
|
||||||
|
geyserOnTile: 0,
|
||||||
|
mineralOnTiles: [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTestGrid(): number[][] {
|
||||||
|
const grid: number[][] = [];
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let x = 0; x < 20; x++) {
|
||||||
|
// Mostly ground, some acid
|
||||||
|
row.push(x === 0 && y === 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
grid.push(row);
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should spawn fungal nodes on walkable tiles', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const world = createWorld();
|
||||||
|
const grid = createTestGrid();
|
||||||
|
|
||||||
|
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||||
|
expect(nodeData.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not spawn on non-walkable tiles', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const world = createWorld();
|
||||||
|
|
||||||
|
// Make grid entirely non-walkable
|
||||||
|
const grid: number[][] = [];
|
||||||
|
for (let y = 0; y < 20; y++) {
|
||||||
|
grid.push(Array(20).fill(1)); // all acid
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||||
|
expect(nodeData.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use deterministic placement based on seed', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const grid = createTestGrid();
|
||||||
|
|
||||||
|
const world1 = createWorld();
|
||||||
|
const data1 = spawnFungalNodes(world1, grid, biome, 42);
|
||||||
|
|
||||||
|
const world2 = createWorld();
|
||||||
|
const data2 = spawnFungalNodes(world2, grid, biome, 42);
|
||||||
|
|
||||||
|
// Same seed → same positions
|
||||||
|
const positions1 = [...data1.values()].map(d => `${d.tileX},${d.tileY}`).sort();
|
||||||
|
const positions2 = [...data2.values()].map(d => `${d.tileX},${d.tileY}`).sort();
|
||||||
|
expect(positions1).toEqual(positions2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain minimum spacing between nodes', async () => {
|
||||||
|
const { createWorld } = await import('bitecs');
|
||||||
|
const world = createWorld();
|
||||||
|
const grid = createTestGrid();
|
||||||
|
|
||||||
|
const nodeData = spawnFungalNodes(world, grid, biome, 42);
|
||||||
|
const positions = [...nodeData.values()].map(d => ({ x: d.tileX, y: d.tileY }));
|
||||||
|
|
||||||
|
// No two nodes should be closer than minSpacing
|
||||||
|
// (on a 20x20 grid with spacing 8, there can be only a few nodes)
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
for (let j = i + 1; j < positions.length; j++) {
|
||||||
|
const dx = positions[i].x - positions[j].x;
|
||||||
|
const dy = positions[i].y - positions[j].y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
expect(dist).toBeGreaterThanOrEqual(7); // slight tolerance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user