Compare commits

..

34 Commits

Author SHA1 Message Date
Денис Шкабатур
c92a07eeb9 docs: mark Phase 11 (Great Cycle) complete in PROGRESS.md + IMPLEMENTATION-PLAN.md
Phase 11 summary: 51 new tests (562 total), 7 new/modified files.
Core systems: GreatCycleState, RunTrace, WorldTrace ECS, RenewalScene,
cycle narrative, Mycelium maturation, CradleScene/UIScene/GameScene integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:54:04 +03:00
Денис Шкабатур
d9213b6be0 feat: CradleScene + UIScene + GameScene cycle integration
Scene integration for the Great Cycle system:
- CradleScene: shows "Великий Цикл N: Тема | Ран X/7", narrative quote
- UIScene: cycle info bar and run phase display below health
- GameScene: world trace spawning (ruins/markers from past runs),
  trace glow rendering, death position recording, cycle info to registry,
  biomeId + worldSeed passed to RunState
- Scene flow: Fractal → RenewalScene (on 7th run) → Cradle

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:51:19 +03:00
Денис Шкабатур
91d4e4d730 feat: RenewalScene — Great Renewal visual event between cycles
After every 7th run (Great Renewal), players see a special scene:
- Pulsing Mycelium particle animation (breathing spiral)
- Staged text reveals: title, flavor, cycle counter, theme, lore, maturation
- "Цикл N → Цикл N+1" transition with narrative theme name
- Lore fragment from the new cycle's theme (Russian)
- Mycelium maturation percentage display
- FractalScene routes to RenewalScene when renewal detected
- Scene flow: Fractal → Renewal → Cradle (on renewal) or Fractal → Cradle (normal)
- Registered in game config

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:48:12 +03:00
Денис Шкабатур
b295f3e1fd feat: world trace placement — ruins and markers from past runs
Law of Trace: "nothing disappears without a trace."
- WorldTrace ECS component (traceType, sourceRunId, glowPhase)
- Death site markers placed at exact death positions (dark red)
- Discovery site markers for runs with 3+ discoveries (blue-gray)
- Deterministic position derivation from world seed
- Traces from current AND previous great cycle included
- Biome filtering, map bounds clamping, glow animation
- 13 new tests (562 total), all passing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:46:26 +03:00
Денис Шкабатур
5f78aa1444 feat: Great Cycle system — 7-run macro cycles with traces and narrative
Core engine for the Great Cycle mechanic (GDD Section IV):
- GreatCycleState tracking: cycle number, run within cycle, theme
- RunTrace recording: death position, school, biome, discoveries per run
- Cycle advancement: auto-advance to next cycle after 7 runs
- Great Renewal: resets traces, advances theme, strengthens Mycelium
- 6 narrative themes (Awakening→Doubt→Realization→Attempt→Acceptance→Synthesis)
- Cycle world modifiers: terrain/resources/creatures scale with cycle
- Narrative data JSON with Russian/English lore fragments per theme
- MetaState + persistence + RunState extended with cycle fields
- 38 new tests (549 total), all passing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:44:45 +03:00
Денис Шкабатур
0cd995c817 phase 10: schools — Mechanic, Naturalist, Navigator with unlock system and bonuses
Add 3 new schools with real scientific principles (Lever & Moment, Photosynthesis,
Angular Measurement). Data-driven unlock conditions check codex elements, creature
discoveries, and completed runs. Each school provides passive gameplay bonuses
(projectile damage, movement speed, creature aggro range, reaction efficiency)
applied through system multiplier parameters. CradleScene shows all 4 schools with
locked ones grayed out and showing unlock hints. 24 new tests (511 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:35:15 +03:00
Денис Шкабатур
1b2cc0cd86 fix: HUD elements no longer shift when zooming in/out
setScrollFactor(0) prevents camera scroll but NOT zoom displacement.
Added fixToScreen() utility that compensates object positions and scale
each frame based on current camera zoom. Applied to all scrollFactor(0)
UI elements in GameScene, Minimap, and BossArenaScene.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 18:22:40 +03:00
Денис Шкабатур
6ba0746bb9 phase 9: biome expansion — 3 biomes, 40 elements, 119 reactions, 9 species
Expand beyond vertical slice with two new biomes and massive chemistry expansion:

Chemistry: +20 real elements (Li→U), +39 compounds (acids/salts/oxides/organics),
+85 reactions (Haber process, thermite variants, smelting, fermentation, etc.)

Biomes: Kinetic Mountains (physics/mechanics themed) and Verdant Forests
(biology/ecology themed), each with 8 tile types and unique generation rules.

Creatures: 6 new species — Pendulums/Mechanoids/Resonators (mountains),
Symbiotes/Mimics/Spore-bearers (forests). Species filtered by biome.

Infrastructure: CradleScene biome selector UI, generic world generator
(tile lookup by property instead of hardcoded names), actinide element category.

487 tests passing (32 new).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 17:27:15 +03:00
Денис Шкабатур
3c24205e72 update PROGRESS.md and IMPLEMENTATION-PLAN.md — Phase 8 complete, vertical slice done
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 16:13:29 +03:00
Денис Шкабатур
7d52d749a3 phase 8: Ouroboros boss fight — pattern puzzle with 3 victory paths
First Archon encounter: a cyclical pattern-recognition puzzle.

Boss AI: 4-phase cycle (Coil → Spray → Lash → Digest) with
escalating difficulty (10% faster per cycle, caps at 5 cycles).

Victory paths (all based on real chemistry):
- Chemical: NaOH during Spray phase (acid-base neutralization, 3x dmg)
- Direct: any projectile during Digest vulnerability window
- Catalytic: Hg poison stacks (mercury poisons catalytic sites, reduces
  regen+armor permanently)

New files: src/boss/ (types, ai, victory, arena, factory, reward),
src/data/bosses.json, src/scenes/BossArenaScene.ts, tests/boss.test.ts

Extended: ECS Boss component, CodexEntry 'boss' type, GameScene
triggers arena on Resolution phase completion.

70 new tests (455 total), all passing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 16:12:34 +03:00
Денис Шкабатур
0d35cdcc73 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>
2026-02-12 15:47:03 +03:00
Денис Шкабатур
35f8905921 phase 6 complete: update PROGRESS.md and mark plan as done
Full run cycle implemented: spawn → explore → escalate → crisis → die → rebirth
349 tests passing, all 9 Phase 6 tasks complete

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:26:08 +03:00
Денис Шкабатур
493748f2b0 phase 6: escalation effects + enhanced crisis system
- Add escalation effects module: creature speed/aggro/damage multipliers,
  reaction instability, environmental damage — all scale 0→1
- Add getCrisisPlayerDamage/getCrisisTint to crisis module
- Integrate escalation effects into GameScene (env damage at high entropy)
- Dynamic crisis overlay tint that intensifies with progress
- Phase indicator shows entropy %, aggression multiplier, crisis status
- 14 new tests for escalation + crisis damage/tint (349 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:24:48 +03:00
Денис Шкабатур
3d4f710cb0 fix texture reuse on run cycle restart + expose debug kill
- Remove existing tilemap/minimap textures before recreating (prevents
  "Texture key already in use" error on second run)
- Expose __game and __debugKill for dev console testing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:22:49 +03:00
Денис Шкабатур
56c96798e3 phase 6: scene flow — Cradle, Death, Fractal scenes + run integration
- Add CradleScene (Spore Cradle): school selection UI with spore particles
- Add DeathScene: body decomposes into real elements with labels
- Add FractalScene: WebGL Mandelbrot/Julia shader + canvas fallback
- Integrate RunState into GameScene: phase management, escalation, crisis
- Give starting kit from chosen school on run start
- Player death triggers DeathScene → FractalScene → CradleScene loop
- Track element/creature discoveries during gameplay
- Chemical Plague crisis: tinted overlay, player damage, neutralization
- BootScene loads meta from IndexedDB, goes to CradleScene
- Update version to v0.6.0

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:15:17 +03:00
Денис Шкабатур
5b7dbb4df3 phase 6: school data, run state, meta-progression, crisis system
- Add school data (Alchemist: H, O, C, Na, S, Fe starting kit)
- Add run cycle types: phases, escalation, crisis, body composition
- Implement run state management (create, advance phase, discoveries, spores)
- Implement meta-progression (codex, spore accumulation, run history)
- Implement crisis system (Chemical Plague with neutralization)
- Add IndexedDB persistence for meta state
- 42 new tests (335 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:10:05 +03:00
Денис Шкабатур
22e6c6bcee chore: complete Phase 5 — update version, progress, and plan
- BootScene version bumped to v0.5.0
- PROGRESS.md updated with Phase 5 completion details
- IMPLEMENTATION-PLAN.md Phase 5 marked 
- Ready for Phase 6: Run Cycle

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 14:21:35 +03:00
Денис Шкабатур
8dd69e8fd2 test(ecosystem): add ecosystem simulation tests
5 ecosystem tests verifying:
- Single species stabilizes with food
- Predator-prey dynamics reduce prey population
- Starvation without food leads to decline
- Mixed ecosystem runs 500 ticks without errors
- Lifecycle drives population renewal through egg hatching
293 total tests passing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 14:20:32 +03:00
Денис Шкабатур
9521b7951c feat(creatures): add interaction system and GameScene integration
- Projectile-creature collision with armor-based damage reduction
- Creature observation system (health/energy/stage when near player)
- Creature-to-player melee attacks with cooldown
- Full GameScene integration: AI, metabolism, lifecycle, reproduction, interaction
- Debug overlay shows creature count
- 16 new interaction tests (288 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 14:15:58 +03:00
Денис Шкабатур
324be5e643 feat(creatures): add creature types, data, ECS components, and core systems
Phase 5.1-5.5: Creatures & Ecology foundation
- 3 species data (Crystallid, Acidophile, Reagent) with real chemistry
- ECS components: Creature, AI, Metabolism, LifeCycle
- AI FSM system: idle → wander → feed → flee → attack
- Metabolism: energy drain, feeding from resources, starvation damage
- Life cycle: egg → youth → mature → aging → natural death
- Population dynamics: counting, reproduction, initial spawning
- Species registry with numeric/string ID lookup
- 51 tests passing (272 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 14:13:26 +03:00
Денис Шкабатур
7e46d1ed1d feat: HUD overlay via UIScene — health bar, quick slots, inventory info (Phase 4.7)
UIScene renders as overlay on top of GameScene:
- Health bar (top-left) with color transitions green→yellow→red
- 4 quick slots (bottom-center) with active highlight, item symbols and counts
- Inventory weight/slots info (bottom-right)
- Controls hint (top-right)

GameScene pushes state to Phaser registry each frame.
Phase 4 (Player Systems) complete — 222 tests passing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:36:42 +03:00
Денис Шкабатур
d173ada466 feat: quick slots with 1-4 hotkeys (Phase 4.6)
4 quick slots bound to keys 1-4. Active slot determines what F key
throws. Auto-assigns new items on collection. Clears slot when item
depleted. 15 new tests (213 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:31:12 +03:00
Денис Шкабатур
0396170303 feat: projectile system for throwing elements (Phase 4.5)
F key launches first inventory item toward mouse cursor as projectile.
Projectiles travel at 350px/s, expire after 2s, destroyed on hitting
non-walkable tiles. Color matches element's periodic table color.
13 new tests (198 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:29:38 +03:00
Денис Шкабатур
b097ce738f feat: crafting system with chemistry engine integration (Phase 4.4)
craftFromInventory checks reagent availability, runs ReactionEngine,
consumes reagents on success, adds products. Failed attempts return
educational reasons (noble gas inertness, missing conditions, etc.).
11 new tests (185 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:27:22 +03:00
Денис Шкабатур
e77b9df6e4 feat: resource collection with E-key interaction (Phase 4.3)
Mineral veins yield metals (Fe, Cu, Zn, Au, Sn), geysers yield S/H.
Resources spawn as ECS entities with gold/orange dot sprites on tiles.
E-key collects nearest resource in range into inventory. Resources
deplete after collection. Visual feedback text. 12 new tests (174 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:25:35 +03:00
Денис Шкабатур
cf36c0adce feat: weight-based inventory with element stacking (Phase 4.2)
Inventory uses real atomic/molecular masses (AMU). Same items auto-stack.
Respects weight limits and slot limits. Supports elements and compounds
via chemistry registries. 28 new tests (162 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:11:20 +03:00
Денис Шкабатур
0c0635c93b feat: player entity with WASD movement, tile collision, camera follow (Phase 4.1)
Player spawns at walkable tile near map center. WASD controls movement
(150px/s, normalized diagonal). Tile collision with wall-sliding prevents
walking through acid pools, crystals, geysers. Camera follows player with
smooth lerp. 39 new tests (134 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 13:09:01 +03:00
Денис Шкабатур
c4993e9eee Update workflow: screenshots go to screenshots/ dir
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:50:25 +03:00
Денис Шкабатур
f5898d30f7 Add screenshots/ and .playwright-mcp/ to gitignore
Screenshots from visual verification go to screenshots/ dir,
not tracked in git. Also ignore Playwright MCP console logs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:50:13 +03:00
Денис Шкабатур
5b26820e21 Add mandatory push step to agent workflow rules
Push must happen immediately after every commit — no accumulating
unpushed work. Updated both development order and task completion
checklists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:49:08 +03:00
Денис Шкабатур
bc472d0f77 Phase 3: World generation — procedural tilemap for Catalytic Wastes
- simplex-noise with seeded PRNG (mulberry32) for deterministic generation
- Biome data: 8 tile types (scorched earth, cracked ground, ash sand,
  acid pools, acid shallow, crystals, geysers, mineral veins)
- Elevation noise → base terrain; detail noise → geyser/mineral overlays
- Canvas-based tileset with per-pixel brightness variation
- Phaser tilemap with collision on non-walkable tiles
- Camera: WASD movement, mouse wheel zoom (0.5x–3x), world bounds
- Minimap: 160x160 canvas overview with viewport indicator
- 21 world generation tests passing (95 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:47:21 +03:00
Денис Шкабатур
ddbca12740 Phase 2: ECS foundation — world, components, systems, bridge
- bitECS world with time tracking (delta, elapsed, tick)
- 5 components: Position, Velocity, SpriteRef, Health, ChemicalComposition
- Movement system (velocity * delta) + bounce system (boundary reflection)
- Health system with damage, healing, death detection
- Entity factory (createGameEntity/removeGameEntity)
- Phaser bridge: polling sync creates/destroys/updates circle sprites
- GameScene: 20 colored circles bouncing at 60fps
- BootScene: click-to-start transition, version bump to v0.2.0
- 39 ECS unit tests passing (74 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:34:06 +03:00
Денис Шкабатур
58ebb11747 Update agent workflow: strict TDD order + commit after every step
Tests first → data structures → implementation → verify → commit

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:20:56 +03:00
Денис Шкабатур
7aabb8b4fc Phase 1: Chemistry engine — elements, compounds, reactions
- 20 real elements from periodic table (H through Hg) with accurate data
- 25 compounds with game effects (NaCl, H₂O, gunpowder, thermite, etc.)
- 34 reactions: synthesis, combustion, acid-base, redox, decomposition
- Reaction engine with O(1) lookup by sorted reactant key
- Educational failure reasons (noble gas, missing heat/catalyst, wrong proportions)
- Condition system: temperature, catalyst, energy requirements
- 35 unit tests passing, TypeScript strict, zero errors

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:16:08 +03:00
103 changed files with 20199 additions and 40 deletions

View File

@@ -1,27 +1,40 @@
--- ---
description: Agent development workflow — visual feedback, testing, progress tracking description: Agent development workflow — TDD, commit discipline, visual feedback
alwaysApply: true alwaysApply: true
--- ---
# Agent Workflow # Agent Workflow
## Development Order (STRICT)
For every task, follow this sequence:
1. **Tests first** — write failing tests that define expected behavior
2. **Data structures** — define types, interfaces, JSON schemas
3. **Implementation** — write code to make tests pass
4. **Verify** — `npm run test:run` for logic, Playwright screenshot for visuals
5. **Commit** — `git add -A && git commit` after each successful step
6. **Push** — `git push` after every commit (never accumulate unpushed commits)
## Commit & Push Discipline
- Commit after EVERY successful step (tests green or visual verified)
- Push immediately after every commit — code must always be on remote
- Small, focused commits — one concern per commit
- Never accumulate uncommitted or unpushed work across multiple tasks
- Commit message format: concise "what + why" in English
## Visual Verification ## Visual Verification
After ANY visual change, verify via Playwright browser tools: After ANY visual change:
1. Ensure dev server is running (`npm run dev`) 1. Ensure dev server is running (`npm run dev`)
2. Navigate to `http://localhost:5173` 2. Navigate to `http://localhost:5173`
3. Take screenshot to see result 3. Take screenshot to `screenshots/` folder (gitignored)
4. Check browser console for errors 4. Check browser console for errors
5. Fix issues before moving on 5. Fix issues before committing
## Testing Strategy
- **Chemistry, ecology, math** — write vitest tests FIRST, then implement
- **Visual/rendering** — verify via Playwright screenshots
- Run `npm run test:run` after logic changes
## After Completing a Task ## After Completing a Task
1. Verify it works (test or screenshot) 1. Verify it works (test or screenshot)
2. Update `PROGRESS.md` — mark task complete, add date 2. Commit immediately
3. Check `IMPLEMENTATION-PLAN.md` for what's next 3. Push immediately (`git push`)
4. Update `PROGRESS.md` — mark task complete
5. Check `IMPLEMENTATION-PLAN.md` for what's next
## Code Standards ## Code Standards
- TypeScript strict mode — no `any`, no unsafe `as` casts - TypeScript strict mode — no `any`, no unsafe `as` casts

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ dist/
.vite/ .vite/
*.local *.local
.DS_Store .DS_Store
screenshots/
.playwright-mcp/

View File

@@ -0,0 +1 @@
[ 7988ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:5173/favicon.ico:0

View File

@@ -122,7 +122,7 @@
--- ---
## Phase 5: Creatures & Ecology ## Phase 5: Creatures & Ecology
**Цель:** Живая экосистема в Каталитических Пустошах **Цель:** Живая экосистема в Каталитических Пустошах
**Зависимости:** Phase 2, Phase 3 **Зависимости:** Phase 2, Phase 3
@@ -145,7 +145,7 @@
--- ---
## Phase 6: Run Cycle ## Phase 6: Run Cycle
**Цель:** Полный рогалик-цикл: рождение → смерть → перерождение **Цель:** Полный рогалик-цикл: рождение → смерть → перерождение
**Зависимости:** Phase 3, Phase 4, Phase 1 **Зависимости:** Phase 3, Phase 4, Phase 1
@@ -172,7 +172,7 @@
--- ---
## Phase 7: Mycelium ## Phase 7: Mycelium
**Цель:** Подземная сеть, связывающая раны **Цель:** Подземная сеть, связывающая раны
**Зависимости:** Phase 6 **Зависимости:** Phase 6
@@ -195,7 +195,7 @@
--- ---
## Phase 8: First Archont — Ouroboros ## Phase 8: First Archont — Ouroboros
**Цель:** Первый босс-бой (головоломка, не рефлексы) **Цель:** Первый босс-бой (головоломка, не рефлексы)
**Зависимости:** Phase 5, Phase 6 **Зависимости:** Phase 5, Phase 6
@@ -238,9 +238,9 @@
| Фаза | Содержание | | Фаза | Содержание |
|-------|-----------| |-------|-----------|
| Phase 9 | +2 биома (Кинетические Горы, Вердантовые Леса), +20 элементов, +100 реакций | | Phase 9 | +2 биома (Кинетические Горы, Вердантовые Леса), +20 элементов, +100 реакций |
| Phase 10 | +3 школы (Механик, Натуралист, Навигатор), принципы физики и биологии | | Phase 10 | +3 школы (Механик, Натуралист, Навигатор), принципы физики и биологии |
| Phase 11 | Великий цикл (7 ранов): следы между ранами, Великое Обновление | | Phase 11 | Великий цикл (7 ранов): следы между ранами, Великое Обновление |
| Phase 12 | +3 Архонта (Спора Прайма, Энтропа, Когнитон) | | Phase 12 | +3 Архонта (Спора Прайма, Энтропа, Когнитон) |
| Phase 13 | Оставшиеся биомы + школы + существа | | Phase 13 | Оставшиеся биомы + школы + существа |
| Phase 14 | Сердце Синтеза (финальный регион), нарратив | | Phase 14 | Сердце Синтеза (финальный регион), нарратив |

View File

@@ -1,7 +1,7 @@
# Synthesis — Development Progress # Synthesis — Development Progress
> **Last updated:** 2026-02-12 > **Last updated:** 2026-02-12
> **Current phase:** Phase 0 ✅ → Ready for Phase 1 > **Current phase:** Phase 11 ✅ → Great Cycle Complete
--- ---
@@ -19,23 +19,148 @@
- [x] Implementation plan (`IMPLEMENTATION-PLAN.md`) - [x] Implementation plan (`IMPLEMENTATION-PLAN.md`)
- [x] Progress tracking (this file) - [x] Progress tracking (this file)
### Phase 1: Chemistry Engine ✅
- [x] 1.1 Types and interfaces (`src/chemistry/types.ts`)
- [x] 1.2 Element data — 20 real elements (`src/data/elements.json`)
- [x] 1.3 Element registry with lookup by symbol/number (`src/chemistry/elements.ts`)
- [x] 1.4 Reaction engine — O(1) lookup, condition checking, failure reasons (`src/chemistry/engine.ts`)
- [x] 1.5 Reaction data — 34 real reactions (`src/data/reactions.json`)
- [x] 1.6 Compound data — 25 compounds with game effects (`src/data/compounds.json`)
- [x] 1.7 Unit tests — 35 passing (`tests/chemistry.test.ts`)
### Phase 2: ECS Foundation ✅
- [x] 2.1 World setup — bitECS world + time tracking (`src/ecs/world.ts`)
- [x] 2.2 Core components — Position, Velocity, SpriteRef, Health, ChemicalComposition (`src/ecs/components.ts`)
- [x] 2.3 Movement system — velocity-based + bounce (`src/ecs/systems/movement.ts`)
- [x] 2.4 Phaser ↔ bitECS sync bridge — polling-based, creates/destroys/syncs sprites (`src/ecs/bridge.ts`)
- [x] 2.5 Entity factory — createGameEntity/removeGameEntity (`src/ecs/factory.ts`)
- [x] 2.6 Health/damage system — damage, healing, death detection (`src/ecs/systems/health.ts`)
- [x] 2.7 Visual test — 20 colored circles bouncing at 60fps, GameScene (`src/scenes/GameScene.ts`)
- [x] Unit tests — 39 passing (`tests/ecs.test.ts`)
### Phase 3: World Generation ✅
- [x] 3.1 Tilemap system — canvas tileset with per-pixel variation + Phaser tilemap (`src/world/tilemap.ts`)
- [x] 3.2 Biome data — Catalytic Wastes: 8 tile types (`src/data/biomes.json`)
- [x] 3.3 Noise generation — simplex-noise, seeded PRNG (mulberry32), deterministic (`src/world/noise.ts`)
- [x] 3.4 World generator — elevation noise → base terrain, detail noise → overlays (`src/world/generator.ts`)
- [x] 3.5 Resource placement — geysers on acid-shallow zones, mineral veins on ground (`src/world/generator.ts`)
- [x] 3.6 Camera — WASD movement, mouse wheel zoom (0.5x3x), bounds clamping (`src/world/camera.ts`)
- [x] 3.7 Minimap — canvas-based 160x160 overview, viewport indicator, border (`src/world/minimap.ts`)
- [x] Unit tests — 21 passing (`tests/world.test.ts`)
### Phase 4: Player Systems ✅
- [x] 4.1 Player entity + WASD controller (`src/player/input.ts`, `src/player/collision.ts`, `src/player/spawn.ts`, `src/player/factory.ts`)
- [x] 4.2 Inventory — weight-based, element stacking, AMU mass from registries (`src/player/inventory.ts`)
- [x] 4.3 Element collection from world objects — resource entities, proximity interaction, E key (`src/player/interaction.ts`, `src/world/resources.ts`)
- [x] 4.4 Crafting — chemistry engine integration, inventory consume/produce, condition checking (`src/player/crafting.ts`)
- [x] 4.5 Projectile system — throw elements toward mouse, lifetime + tile collision (`src/player/projectile.ts`)
- [x] 4.6 Quick slots — 4 hotkeys, auto-assign on pickup, active slot for throw (`src/player/quickslots.ts`)
- [x] 4.7 HUD — UIScene overlay: health bar, quick slots, inventory info, controls hint (`src/scenes/UIScene.ts`)
- [x] Unit tests — 39 player + 28 inventory + 12 interaction + 11 crafting + 13 projectile + 15 quickslots + 8 UI = 126 tests (222 total)
### Phase 5: Creatures & Ecology ✅
- [x] 5.1 Creature data — 3 species: Crystallids, Acidophiles, Reagents (`src/data/creatures.json`)
- [x] 5.2 AI behavior — FSM: idle → wander → feed → flee → attack (`src/creatures/ai.ts`)
- [x] 5.3 Metabolism — energy drain, feeding from resources, starvation damage (`src/creatures/metabolism.ts`)
- [x] 5.4 Population dynamics — counting, reproduction, initial spawning (`src/creatures/population.ts`)
- [x] 5.5 Life cycle — egg → youth → mature → aging → natural death (`src/creatures/lifecycle.ts`)
- [x] 5.6 Interaction — projectile collision (armor), observation, creature→player melee (`src/creatures/interaction.ts`)
- [x] 5.7 Ecosystem test — populations fluctuate, don't die out (`tests/ecosystem.test.ts`)
- [x] 5.8 GameScene integration — all creature systems in update loop, debug overlay
- [x] ECS components: Creature, AI, Metabolism, LifeCycle (`src/ecs/components.ts`)
- [x] Species registry with string/numeric ID lookup (`src/creatures/types.ts`)
- [x] Creature factory (`src/creatures/factory.ts`)
- [x] Unit tests — 51 creature + 16 interaction + 5 ecosystem = 72 tests (293 total)
### Phase 6: Run Cycle ✅
- [x] 6.1 Spore Cradle — CradleScene: school selection UI, floating spore particles, meta display (`src/scenes/CradleScene.ts`)
- [x] 6.2 Death trigger — player death → camera fade → DeathScene transition (`src/scenes/GameScene.ts`)
- [x] 6.3 Death animation — body decomposes into real elements (65% O, 18% C, 10% H...), element labels, Mycelium threads absorbing particles (`src/scenes/DeathScene.ts`)
- [x] 6.4 Moment Between — WebGL Mandelbrot/Julia hybrid shader with morphing + zoom, canvas fallback for non-WebGL (`src/scenes/FractalScene.ts`)
- [x] 6.5 Meta-progression — Codex (permanent knowledge), spores (currency), run history, IndexedDB persistence (`src/run/meta.ts`, `src/run/persistence.ts`)
- [x] 6.6 Run phases — state machine: Awakening → Exploration → Escalation → Crisis → Resolution, auto-advance on timers (`src/run/state.ts`)
- [x] 6.7 Escalation — creature speed/aggro/damage multipliers, reaction instability, environmental damage at high entropy (`src/run/escalation.ts`)
- [x] 6.8 Crisis: Chemical Plague — chain reaction poisons atmosphere, progressive player damage, toxic green tint overlay, CaO neutralization mechanics (`src/run/crisis.ts`)
- [x] 6.9 School: Alchemist — starting kit (H×5, O×5, C×3, Na×3, S×2, Fe×2), chemical equilibrium principle (`src/data/schools.json`)
- [x] Scene flow: BootScene → CradleScene → GameScene → DeathScene → FractalScene → CradleScene (full loop verified)
- [x] Texture cleanup for multi-run support (tilemap + minimap textures removed before recreation)
- [x] Unit tests — 42 run-cycle + 14 escalation = 56 tests (349 total)
### Phase 7: Mycelium ✅
- [x] 7.1 Mycelium Graph — persistent knowledge network (nodes, edges, deposit/strengthen/query) (`src/mycelium/graph.ts`)
- [x] 7.2 Fungal Nodes — ECS entities on world map, glowing bioluminescent spots, pulsing animation (`src/mycelium/nodes.ts`)
- [x] 7.3 Knowledge Recording — deposit run discoveries into Mycelium (auto on death + manual at nodes) (`src/mycelium/knowledge.ts`)
- [x] 7.4 Knowledge Extraction — memory flashes from past runs, weighted by node strength, Russian text templates (`src/mycelium/knowledge.ts`)
- [x] 7.5 Mycosis — visual distortion (tint overlay) on prolonged fungal node contact, reveals hidden info at threshold (`src/mycelium/mycosis.ts`)
- [x] 7.6 Spore Shop — Cradle integration: spend spores for starting bonuses (extra health, elements, knowledge boost) (`src/mycelium/shop.ts`)
- [x] ECS component: FungalNode (nodeIndex, glowPhase, interactRange) (`src/ecs/components.ts`)
- [x] Data: mycelium config, spore bonuses, memory templates (`src/data/mycelium.json`)
- [x] MetaState extended with MyceliumGraphData, IndexedDB persistence updated
- [x] GameScene integration: node spawning, glow rendering, E-key interaction, mycosis overlay, memory flash display
- [x] CradleScene integration: spore shop UI, Mycelium stats display, purchased effects passed to GameScene
- [x] Unit tests — 36 passing (`tests/mycelium.test.ts`) (385 total)
### Phase 8: First Archont — Ouroboros ✅
- [x] 8.1 Ouroboros entity — Boss data JSON, BossData type, Boss ECS component, entity factory (`src/boss/factory.ts`, `src/data/bosses.json`)
- [x] 8.2 Pattern mechanics — 4-phase cyclical AI: Coil → Spray → Lash → Digest, escalating difficulty (10% faster per cycle, caps at 5), regeneration + armor systems (`src/boss/ai.ts`)
- [x] 8.3 Arena — circular generated room (21×21 tiles), acid pools, crystal walls, 4 mineral deposits, boss center / player south spawn (`src/boss/arena.ts`)
- [x] 8.4 Multiple solutions — 3 real-chemistry victory paths: NaOH acid-base neutralization (3× dmg during Spray), direct damage during Digest vulnerability, Hg catalyst poisoning (permanently reduces regen+armor) (`src/boss/victory.ts`)
- [x] 8.5 Reward — Archont's Memory lore entry in Codex, spore reward (100 base, +50% chemical, +100% catalytic), CodexEntry type extended with 'boss' (`src/boss/reward.ts`)
- [x] BossArenaScene — full boss fight scene with phase-based attacks (Coil: chase, Spray: rotating acid projectiles, Lash: area sweep, Digest: immobile+vulnerable), boss health bar + phase indicator, victory/death handling (`src/scenes/BossArenaScene.ts`)
- [x] GameScene integration — Resolution phase completion triggers arena transition, inventory/quickslots/health carried over
- [x] Scene flow extended: GameScene → BossArenaScene → DeathScene (on death or victory)
- [x] Unit tests — 70 passing (`tests/boss.test.ts`) (455 total)
### Phase 9: Biome Expansion ✅
- [x] 9.1 New elements — 20 additional real elements (Li, B, F, Ne, Ar, Ti, Cr, Mn, Co, Ni, As, Br, Ag, I, Ba, W, Pt, Pb, Bi, U) → 40 total
- [x] 9.2 New compounds — 39 new compounds (acids, salts, oxides, organics) → 64 total
- [x] 9.3 New reactions — 85 new reactions (synthesis, combustion, acid-base, redox, decomposition, replacement) → 119 total
- [x] 9.4 Kinetic Mountains biome — physics/mechanics themed: chasms, gear floors, magnetic fields, steam vents, ore deposits
- [x] 9.5 Verdant Forests biome — biology/ecology themed: bogs, toxic blooms, ancient trees, mycelium carpet, herb patches
- [x] 9.6 Biome selection — CradleScene biome picker UI, biomeId passed to GameScene, generic world generator
- [x] 9.7 New creatures — 6 species: Pendulums/Mechanoids/Resonators (mountains), Symbiotes/Mimics/Spore-bearers (forests)
- [x] 9.8 World generator genericized — tile lookup by property (interactive/resource) instead of hardcoded names
- [x] Chemistry types extended — `actinide` element category for Uranium
- [x] Species types extended — `biome` field, `SpeciesId` enum expanded (08)
- [x] GameScene filters creatures by selected biome
- [x] Unit tests — 32 new tests (487 total)
### Phase 10: Schools ✅
- [x] 10.1 SchoolData type extended — `bonuses`, `unlockCondition`, `ResolvedSchoolBonuses` interface (`src/run/types.ts`)
- [x] 10.2 3 new schools — Mechanic, Naturalist, Navigator with real science principles (`src/data/schools.json`)
- [x] 10.3 School unlock system — data-driven conditions: elements_discovered, creatures_discovered, runs_completed (`src/run/meta.ts`)
- [x] 10.4 School bonuses resolver — `getSchoolBonuses()` returns complete multiplier set with defaults (`src/run/meta.ts`)
- [x] 10.5 Bonus integration — movement speed (Navigator), projectile damage (Mechanic), creature aggro range (Naturalist), reaction efficiency (Alchemist)
- [x] 10.6 CradleScene updated — all 4 schools displayed, locked schools grayed out with lock icon + unlock hint, bonus indicators shown
- [x] 10.7 System signatures updated — `playerInputSystem`, `aiSystem`, `creatureProjectileSystem` accept optional multiplier params
- [x] Unit tests — 24 new tests (511 total): school data, unlock conditions (cumulative, multi-school, persistence), bonuses per school
### Phase 11: Great Cycle ✅
- [x] 11.1 GreatCycleState types — CycleTheme enum, RunTrace, GreatCycleState, CycleWorldModifiers (`src/run/types.ts`)
- [x] 11.2 Great Cycle engine — cycle tracking, theme resolution, trace recording, renewal, world modifiers (`src/run/cycle.ts`)
- [x] 11.3 MetaState extended — `greatCycle` field, persistence updated, RunState extended with biomeId/worldSeed/deathPosition
- [x] 11.4 Run trace recording — death position, school, biome, discoveries saved per run via `applyRunResults`
- [x] 11.5 World trace placement — WorldTrace ECS component, death site markers (dark red), discovery markers (blue), glow animation (`src/world/traces.ts`)
- [x] 11.6 Cycle narrative data — 6 themes with Russian lore fragments, cradle quotes, renewal messages (`src/data/cycle-narrative.json`)
- [x] 11.7 RenewalScene — special scene between great cycles: Mycelium particles, staged text reveals, cycle transition, lore, maturation display (`src/scenes/RenewalScene.ts`)
- [x] 11.8 Mycelium maturation — strengthens all nodes on renewal, caps at 1.0, cumulative across cycles
- [x] 11.9 CradleScene integration — "Великий Цикл N: Тема | Ран X/7", narrative quote per theme
- [x] 11.10 UIScene integration — cycle info bar + run phase display below health
- [x] 11.11 GameScene integration — world trace spawning, trace glow rendering, death position recording, cycle info to registry
- [x] 11.12 Scene flow extended — Fractal → RenewalScene (on 7th run) → Cradle, or Fractal → Cradle (normal)
- [x] Unit tests — 51 new tests (562 total): cycle init, themes, traces, advancement, renewal, world modifiers, narrative data, full integration
--- ---
## In Progress ## In Progress
_None — ready to begin Phase 1_ _None — Phase 11 complete_
--- ---
## Up Next: Phase 1 — Chemistry Engine ## Up Next: Phase 12+
- [ ] 1.1 Types and interfaces (`Element`, `Reaction`, `Compound`) _(See IMPLEMENTATION-PLAN.md for Beyond Vertical Slice)_
- [ ] 1.2 Element data — 20 real elements (JSON)
- [ ] 1.3 Element registry with lookup
- [ ] 1.4 Reaction engine core
- [ ] 1.5 Reaction data — 50 real reactions (JSON)
- [ ] 1.6 Compound properties
- [ ] 1.7 Unit tests (vitest)
--- ---
@@ -49,4 +174,15 @@ None
| # | Date | Phase | Summary | | # | Date | Phase | Summary |
|---|------|-------|---------| |---|------|-------|---------|
| 1 | 2026-02-12 | Phase 0 | Project setup complete: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, implementation plan | | 1 | 2026-02-12 | Phase 0 | Project setup: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, plan |
| 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing |
| 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing |
| 4 | 2026-02-12 | Phase 3 | World generation: simplex noise (seeded), 80x80 tilemap with 8 tile types, Catalytic Wastes biome, camera WASD+zoom, minimap with viewport indicator, 21 tests passing (95 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) |
| 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) |
| 9 | 2026-02-12 | Phase 8 | Ouroboros boss fight: 4-phase cyclical AI (Coil/Spray/Lash/Digest) with escalating difficulty, 3 chemistry-based victory paths (NaOH neutralization, direct damage, Hg catalyst poison), circular arena generator, BossArenaScene with attacks+collision+UI, Archont's Memory lore reward, CodexEntry extended, GameScene Resolution→arena trigger, 70 new tests (455 total) |
| 10 | 2026-02-12 | Phase 9 | Biome expansion: +20 elements (40 total), +39 compounds (64 total), +85 reactions (119 total), 2 new biomes (Kinetic Mountains + Verdant Forests), biome selection in CradleScene, 6 new creature species (3 per new biome), generic world generator, 32 new tests (487 total) |
| 11 | 2026-02-12 | Phase 10 | Schools: +3 schools (Mechanic/Naturalist/Navigator), data-driven unlock system (elements/creatures/runs), school bonuses (projectile dmg, movement speed, aggro range, reaction efficiency), CradleScene shows locked schools with hints, system multiplier params, 24 new tests (511 total) |
| 12 | 2026-02-12 | Phase 11 | Great Cycle: 7-run macro cycles with traces between runs, GreatCycleState (cycle number/theme/traces), RunTrace recording (death position/school/biome/discoveries), WorldTrace ECS entities (death markers + discovery sites with glow), RenewalScene (Mycelium particles/staged text/cycle transition), 6 narrative themes (Awakening→Synthesis) with Russian lore, Mycelium maturation on renewal, CradleScene+UIScene+GameScene cycle display, 51 new tests (562 total) |

9
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bitecs": "^0.4.0", "bitecs": "^0.4.0",
"phaser": "^3.80.0" "phaser": "^3.80.0",
"simplex-noise": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
@@ -1269,6 +1270,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/simplex-noise": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -12,7 +12,8 @@
}, },
"dependencies": { "dependencies": {
"bitecs": "^0.4.0", "bitecs": "^0.4.0",
"phaser": "^3.80.0" "phaser": "^3.80.0",
"simplex-noise": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",

152
src/boss/ai.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Boss AI System — cyclical phase logic for Archon encounters
*
* The boss cycles through 4 phases (Coil → Spray → Lash → Digest),
* each cycle becoming faster. During Digest, the boss is vulnerable.
* Between cycles, the boss regenerates health (self-catalysis).
*/
import {
BossPhase,
BOSS_PHASE_COUNT,
type BossData,
type BossState,
type BossPhaseEvent,
} from './types';
/** Create initial boss state from boss data */
export function createBossState(boss: BossData): BossState {
const initialDuration = getEffectivePhaseDuration(
{ cycleCount: 0 } as BossState, boss, BossPhase.Coil,
);
return {
bossId: boss.id,
health: boss.health,
maxHealth: boss.health,
currentPhase: BossPhase.Coil,
phaseTimer: initialDuration,
cycleCount: 0,
catalystStacks: 0,
defeated: false,
victoryMethod: null,
totalDamageDealt: 0,
chemicalDamageDealt: 0,
directDamageDealt: 0,
catalystDamageDealt: 0,
};
}
/**
* Get the effective phase duration, accounting for cycle speedup.
* Each completed cycle multiplies duration by phaseSpeedupPerCycle.
* Caps at maxCycles to prevent impossibly short phases.
*/
export function getEffectivePhaseDuration(
state: Pick<BossState, 'cycleCount'>,
boss: BossData,
phase: BossPhase,
): number {
const baseDuration = boss.phaseDurations[phase];
const effectiveCycles = Math.min(state.cycleCount, boss.maxCycles);
return baseDuration * Math.pow(boss.phaseSpeedupPerCycle, effectiveCycles);
}
/**
* Get effective armor value, accounting for catalyst poison stacks.
* During vulnerable phases: uses armorVulnerable as base.
* During normal phases: uses armor as base.
* Catalyst stacks reduce armor further.
*/
export function getEffectiveArmor(
state: BossState,
boss: BossData,
): number {
const isVulnerable = boss.vulnerablePhases.includes(state.currentPhase);
const baseArmor = isVulnerable ? boss.armorVulnerable : boss.armor;
const reduction = state.catalystStacks * boss.catalystArmorReduction;
return Math.max(0, baseArmor - reduction);
}
/**
* Get effective regeneration rate, accounting for catalyst poison.
* Each catalyst stack permanently reduces regen.
*/
export function getEffectiveRegen(
state: BossState,
boss: BossData,
): number {
const reduction = state.catalystStacks * boss.catalystRegenReduction;
return Math.max(0, boss.regenPerSecond - reduction);
}
/**
* Check if the boss is currently in a vulnerable phase.
*/
export function isVulnerable(state: BossState, boss: BossData): boolean {
return boss.vulnerablePhases.includes(state.currentPhase);
}
/**
* Update boss phase timer and cycle through phases.
* Returns events for phase changes, cycle completions, and attacks.
*
* @param state - Mutable boss state
* @param boss - Immutable boss data
* @param delta - Time elapsed in ms
* @returns Array of events that occurred this tick
*/
export function updateBossPhase(
state: BossState,
boss: BossData,
delta: number,
): BossPhaseEvent[] {
if (state.defeated) return [];
const events: BossPhaseEvent[] = [];
// Apply regeneration
const regen = getEffectiveRegen(state, boss);
if (regen > 0) {
state.health = Math.min(state.maxHealth, state.health + regen * (delta / 1000));
}
// Count down phase timer
state.phaseTimer -= delta;
// Phase transition
while (state.phaseTimer <= 0) {
const nextPhase = ((state.currentPhase + 1) % BOSS_PHASE_COUNT) as BossPhase;
// Cycle completion: when Digest ends (wrapping back to Coil)
if (state.currentPhase === BossPhase.Digest) {
state.cycleCount += 1;
events.push({
type: 'cycle_complete',
phase: nextPhase,
cycleCount: state.cycleCount,
});
}
state.currentPhase = nextPhase;
const newDuration = getEffectivePhaseDuration(state, boss, nextPhase);
state.phaseTimer += newDuration;
events.push({
type: 'phase_change',
phase: nextPhase,
cycleCount: state.cycleCount,
});
}
// Boss attacks during Spray and Lash phases
if (state.currentPhase === BossPhase.Spray || state.currentPhase === BossPhase.Lash) {
events.push({
type: 'boss_attack',
phase: state.currentPhase,
cycleCount: state.cycleCount,
});
}
return events;
}

136
src/boss/arena.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* Boss Arena Generator — creates a circular arena for Archon encounters
*
* The arena is a special room with:
* - Circular walkable area surrounded by crystal walls
* - Acid pools near the edges (hazards)
* - 4 mineral deposits at cardinal positions (for ammo)
* - Boss spawns at center, player enters from south
*
* Uses the same tile index system as the main world generator.
*/
import type { BossData, ArenaData } from './types';
import type { BiomeData } from '../world/types';
/** Tile indices matching biomes.json tile order */
const TILE = {
GROUND: 0,
SCORCHED_EARTH: 1,
ACID_SHALLOW: 2,
ACID_DEEP: 3,
CRYSTAL_FORMATION: 4,
GEYSER: 5,
MINERAL_VEIN: 6,
BEDROCK: 7,
} as const;
/**
* Generate a circular boss arena.
*
* @param boss - Boss data (determines arena size)
* @param biome - Biome data (for tile size)
* @returns Arena data with grid, spawn positions, and resource locations
*/
export function generateArena(boss: BossData, biome: BiomeData): ArenaData {
const radius = boss.arenaRadius;
const diameter = radius * 2 + 1;
const centerTile = radius;
const tileSize = biome.tileSize;
// Initialize grid with bedrock (impassable)
const grid: number[][] = [];
for (let y = 0; y < diameter; y++) {
grid[y] = [];
for (let x = 0; x < diameter; x++) {
grid[y][x] = TILE.BEDROCK;
}
}
const resourcePositions: { x: number; y: number }[] = [];
// Carve circular arena
for (let y = 0; y < diameter; y++) {
for (let x = 0; x < diameter; x++) {
const dx = x - centerTile;
const dy = y - centerTile;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= radius - 1) {
// Inner arena: walkable ground
grid[y][x] = TILE.GROUND;
// Acid pools ring at 60-75% of radius
if (dist > radius * 0.6 && dist <= radius * 0.75) {
// Place acid in a pattern (not solid ring — alternating)
const angle = Math.atan2(dy, dx);
const sector = Math.floor((angle + Math.PI) / (Math.PI / 4));
if (sector % 2 === 0) {
grid[y][x] = TILE.ACID_SHALLOW;
}
}
// Scorched earth ring at 75-90% of radius
if (dist > radius * 0.75 && dist <= radius * 0.9) {
grid[y][x] = TILE.SCORCHED_EARTH;
}
} else if (dist <= radius) {
// Crystal wall border
grid[y][x] = TILE.CRYSTAL_FORMATION;
}
}
}
// Place 4 mineral deposits at cardinal positions (40% of radius from center)
const mineralDist = Math.round(radius * 0.4);
const cardinalOffsets = [
{ dx: 0, dy: -mineralDist }, // North
{ dx: mineralDist, dy: 0 }, // East
{ dx: 0, dy: mineralDist }, // South (near player spawn)
{ dx: -mineralDist, dy: 0 }, // West
];
for (const offset of cardinalOffsets) {
const mx = centerTile + offset.dx;
const my = centerTile + offset.dy;
if (mx >= 0 && mx < diameter && my >= 0 && my < diameter) {
grid[my][mx] = TILE.MINERAL_VEIN;
resourcePositions.push({
x: mx * tileSize + tileSize / 2,
y: my * tileSize + tileSize / 2,
});
}
}
// Boss spawns at center
const bossSpawnX = centerTile * tileSize + tileSize / 2;
const bossSpawnY = centerTile * tileSize + tileSize / 2;
// Player spawns at south edge (80% of radius from center)
const playerSpawnX = centerTile * tileSize + tileSize / 2;
const playerSpawnY = (centerTile + Math.round(radius * 0.8)) * tileSize + tileSize / 2;
return {
grid,
width: diameter,
height: diameter,
bossSpawnX,
bossSpawnY,
playerSpawnX,
playerSpawnY,
resourcePositions,
};
}
/**
* Build a set of walkable tile indices for the arena.
* Matches the pattern used in the main world's collision system.
*/
export function buildArenaWalkableSet(): Set<number> {
return new Set([
TILE.GROUND,
TILE.SCORCHED_EARTH,
TILE.MINERAL_VEIN,
TILE.GEYSER,
]);
}

59
src/boss/factory.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Boss Entity Factory — creates boss entities in the ECS world
*
* The boss gets: Position, Velocity, Health, SpriteRef, Boss components.
* Rich state (phase, cycle, stacks) is stored in BossState, not ECS.
*/
import { addEntity, addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import {
Position,
Velocity,
Health,
SpriteRef,
Boss,
} from '../ecs/components';
import type { BossData } from './types';
/**
* Create a boss entity at the given position.
*
* @returns Entity ID
*/
export function createBossEntity(
world: World,
boss: BossData,
x: number,
y: number,
): number {
const eid = addEntity(world);
// Position
addComponent(world, eid, Position);
Position.x[eid] = x;
Position.y[eid] = y;
// Velocity (boss controls its own movement via AI)
addComponent(world, eid, Velocity);
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
// Health
addComponent(world, eid, Health);
Health.current[eid] = boss.health;
Health.max[eid] = boss.health;
// Visual
addComponent(world, eid, SpriteRef);
SpriteRef.color[eid] = parseInt(boss.color.replace('#', ''), 16);
SpriteRef.radius[eid] = boss.radius;
// Boss tag
addComponent(world, eid, Boss);
Boss.dataIndex[eid] = 0; // Index in bosses.json array (only Ouroboros for now)
Boss.phase[eid] = 0; // BossPhase.Coil
Boss.cycleCount[eid] = 0;
return eid;
}

19
src/boss/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Boss module — Archon encounters
*/
export { BossPhase, VictoryMethod } from './types';
export type {
BossData,
BossState,
BossDamageResult,
BossPhaseEvent,
ArenaData,
BossReward,
} from './types';
export { createBossState, updateBossPhase, getEffectiveArmor, getEffectiveRegen, isVulnerable, getEffectivePhaseDuration } from './ai';
export { applyBossDamage, isBossDefeated } from './victory';
export { generateArena, buildArenaWalkableSet } from './arena';
export { calculateBossReward, applyBossReward } from './reward';
export { createBossEntity } from './factory';

86
src/boss/reward.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Boss Reward System — Archont's Memory and spore rewards
*
* Defeating a boss grants:
* 1. Spores (currency for meta-progression)
* 2. Archont's Memory — a lore entry added to the Codex
* 3. Victory flavor based on the method used
*/
import { VictoryMethod, type BossData, type BossState, type BossReward } from './types';
import type { MetaState, CodexEntry } from '../run/types';
/**
* Calculate the reward for defeating a boss.
*
* @param state - Boss state at time of defeat
* @param boss - Boss configuration data
* @returns Reward with spores, lore, and flavor
*/
export function calculateBossReward(
state: BossState,
boss: BossData,
): BossReward {
if (!state.defeated || !state.victoryMethod) {
return {
spores: 0,
loreId: boss.id,
loreText: boss.loreEntry,
loreTextRu: boss.loreEntryRu,
victoryMethod: VictoryMethod.Direct,
};
}
// Base reward + bonus for elegant victory methods
let spores = boss.sporeReward;
switch (state.victoryMethod) {
case VictoryMethod.Chemical:
// Chemical victory: 50% bonus (requires crafting NaOH + timing)
spores = Math.round(spores * 1.5);
break;
case VictoryMethod.Catalytic:
// Catalytic victory: 100% bonus (requires observation + Hg)
spores = Math.round(spores * 2.0);
break;
case VictoryMethod.Direct:
// Direct victory: base reward
break;
}
return {
spores,
loreId: boss.id,
loreText: boss.loreEntry,
loreTextRu: boss.loreEntryRu,
victoryMethod: state.victoryMethod,
};
}
/**
* Apply boss defeat to meta state: add codex entry and spores.
*
* @param meta - Mutable meta state
* @param reward - Boss reward to apply
* @param runId - Current run ID
*/
export function applyBossReward(
meta: MetaState,
reward: BossReward,
runId: number,
): void {
// Add spores
meta.spores += reward.spores;
// Add boss lore to codex (skip if already exists)
const key = `boss:${reward.loreId}`;
const exists = meta.codex.some(e => `${e.type}:${e.id}` === key);
if (!exists) {
const entry: CodexEntry = {
id: reward.loreId,
type: 'boss' as CodexEntry['type'],
discoveredOnRun: runId,
};
meta.codex.push(entry);
}
}

202
src/boss/types.ts Normal file
View File

@@ -0,0 +1,202 @@
/**
* Boss Fight Types — Archon encounters
*
* Ouroboros: The Archon of Cycles — a serpent consuming its own tail.
* The fight is a cyclical pattern-recognition puzzle with 3 victory paths:
* 1. Chemical — NaOH neutralization during acid phase (real acid-base chemistry)
* 2. Direct — projectile damage during vulnerability window
* 3. Catalytic — Hg catalyst poisoning (real: mercury poisons catalytic sites)
*/
// ─── Boss Phases ─────────────────────────────────────────────────
/** Cyclical boss attack phases (repeat with escalation) */
export enum BossPhase {
/** Arena constricts — boss coils, safe zone shrinks */
Coil = 0,
/** Acid projectile spray — rotating pattern */
Spray = 1,
/** Tail sweep — sector damage arcs */
Lash = 2,
/** Self-consumption pause — vulnerability window */
Digest = 3,
}
export const BOSS_PHASE_COUNT = 4;
export const BOSS_PHASE_NAMES: Record<BossPhase, string> = {
[BossPhase.Coil]: 'Coil',
[BossPhase.Spray]: 'Acid Spray',
[BossPhase.Lash]: 'Tail Lash',
[BossPhase.Digest]: 'Digest',
};
export const BOSS_PHASE_NAMES_RU: Record<BossPhase, string> = {
[BossPhase.Coil]: 'Сжатие',
[BossPhase.Spray]: 'Кислотный Залп',
[BossPhase.Lash]: 'Удар Хвостом',
[BossPhase.Digest]: 'Переваривание',
};
// ─── Victory Methods ─────────────────────────────────────────────
/** How the boss was defeated (determines reward flavor) */
export enum VictoryMethod {
/** NaOH acid-base neutralization during Spray phase */
Chemical = 'chemical',
/** Brute force projectile damage during Digest */
Direct = 'direct',
/** Hg catalyst poisoning — disrupts self-catalysis */
Catalytic = 'catalytic',
}
// ─── Boss Data (from JSON) ──────────────────────────────────────
/** Boss configuration loaded from bosses.json */
export interface BossData {
id: string;
name: string;
nameRu: string;
description: string;
descriptionRu: string;
/** Hex color string for rendering */
color: string;
/** Base radius in pixels */
radius: number;
/** Maximum health points */
health: number;
/** Damage reduction 01 during non-vulnerable phases */
armor: number;
/** Damage reduction 01 during vulnerable phases */
armorVulnerable: number;
/** HP regeneration per second (self-catalysis) */
regenPerSecond: number;
/** Base attack damage to player */
damage: number;
/** Phase durations in ms: [Coil, Spray, Lash, Digest] */
phaseDurations: [number, number, number, number];
/** Duration multiplier per completed cycle (< 1 = faster) */
phaseSpeedupPerCycle: number;
/** Difficulty caps after this many cycles */
maxCycles: number;
/** BossPhase values where boss takes full damage */
vulnerablePhases: number[];
/** Compound ID that deals bonus chemical damage */
chemicalWeakness: string;
/** Damage multiplier for chemical weakness */
chemicalDamageMultiplier: number;
/** Phases where chemical weakness applies (e.g. Spray for NaOH) */
chemicalEffectivePhases: number[];
/** Element symbol that acts as catalyst poison */
catalystPoison: string;
/** Regen reduction per catalyst stack (HP/s) */
catalystRegenReduction: number;
/** Armor reduction per catalyst stack (absolute) */
catalystArmorReduction: number;
/** Maximum catalyst stacks */
maxCatalystStacks: number;
/** Arena radius in tiles */
arenaRadius: number;
/** Lore text revealed on victory (English) */
loreEntry: string;
/** Lore text revealed on victory (Russian) */
loreEntryRu: string;
/** Spore reward for defeating the boss */
sporeReward: number;
}
// ─── Runtime Boss State ──────────────────────────────────────────
/** Runtime state of the boss fight (stored in scene, not ECS) */
export interface BossState {
/** Boss data ID */
bossId: string;
/** Current health */
health: number;
/** Maximum health */
maxHealth: number;
/** Current attack phase */
currentPhase: BossPhase;
/** Time remaining in current phase (ms) */
phaseTimer: number;
/** Number of completed full cycles (Coil→Spray→Lash→Digest) */
cycleCount: number;
/** Number of catalyst poison (Hg) applications */
catalystStacks: number;
/** Whether the boss has been defeated */
defeated: boolean;
/** How the boss was defeated (null if not yet) */
victoryMethod: VictoryMethod | null;
/** Running totals by damage type */
totalDamageDealt: number;
chemicalDamageDealt: number;
directDamageDealt: number;
catalystDamageDealt: number;
}
// ─── Damage Result ──────────────────────────────────────────────
/** Result of applying damage to the boss */
export interface BossDamageResult {
/** Actual damage dealt after armor */
damageDealt: number;
/** Type of damage */
damageType: VictoryMethod;
/** Whether this was a killing blow */
killingBlow: boolean;
/** Whether a catalyst stack was applied */
catalystApplied: boolean;
/** Feedback message for player */
message: string;
messageRu: string;
}
// ─── Boss Phase Event ───────────────────────────────────────────
/** Events emitted during boss phase updates */
export interface BossPhaseEvent {
type: 'phase_change' | 'cycle_complete' | 'boss_attack';
phase: BossPhase;
cycleCount: number;
}
// ─── Arena Data ─────────────────────────────────────────────────
/** Generated arena tile layout */
export interface ArenaData {
/** Tile grid (same format as WorldData.grid) */
grid: number[][];
/** Arena width in tiles */
width: number;
/** Arena height in tiles */
height: number;
/** Boss spawn position in pixels */
bossSpawnX: number;
bossSpawnY: number;
/** Player spawn position in pixels */
playerSpawnX: number;
playerSpawnY: number;
/** Resource deposit positions in pixels */
resourcePositions: { x: number; y: number }[];
}
// ─── Boss Reward ────────────────────────────────────────────────
/** Reward for defeating the boss */
export interface BossReward {
/** Spores earned */
spores: number;
/** Lore text for Codex */
loreId: string;
loreText: string;
loreTextRu: string;
/** Victory method determines flavor */
victoryMethod: VictoryMethod;
}

178
src/boss/victory.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* Boss Victory Conditions — 3 paths to defeating an Archon
*
* 1. Chemical: NaOH during Spray phase (acid-base neutralization)
* 2. Direct: Any projectile during Digest vulnerability window
* 3. Catalytic: Hg poison stacks (disrupts self-catalysis)
*
* Real science behind each method:
* - NaOH + HCl → NaCl + H₂O (neutralization of Ouroboros acid)
* - Mercury poisons catalytic sites (real catalysis chemistry)
* - Direct damage exploits the brief self-consumption pause
*/
import { VictoryMethod, type BossData, type BossState, type BossDamageResult } from './types';
import { getEffectiveArmor, isVulnerable } from './ai';
/** Base damage from a projectile hit */
const PROJECTILE_BASE_DAMAGE = 15;
/** Damage dealt by catalyst poison application */
const CATALYST_DAMAGE = 10;
/**
* Apply damage to the boss from a projectile hit.
* Determines damage type based on the item used and current boss phase.
*
* @param state - Mutable boss state
* @param boss - Immutable boss data
* @param itemId - The element/compound symbol used (e.g. "NaOH", "Hg", "Fe")
* @returns Damage result with feedback
*/
export function applyBossDamage(
state: BossState,
boss: BossData,
itemId: string,
): BossDamageResult {
if (state.defeated) {
return {
damageDealt: 0,
damageType: VictoryMethod.Direct,
killingBlow: false,
catalystApplied: false,
message: 'Boss already defeated',
messageRu: 'Босс уже побеждён',
};
}
const armor = getEffectiveArmor(state, boss);
// Path 3: Catalyst poison (Hg)
if (itemId === boss.catalystPoison) {
return applyCatalystDamage(state, boss, armor);
}
// Path 1: Chemical weakness (NaOH during Spray)
if (
itemId === boss.chemicalWeakness &&
boss.chemicalEffectivePhases.includes(state.currentPhase)
) {
return applyChemicalDamage(state, boss, armor);
}
// Path 2: Direct damage (any projectile during vulnerable phase)
if (isVulnerable(state, boss)) {
return applyDirectDamage(state, boss, armor);
}
// Non-vulnerable phase, non-special item → heavily reduced damage
const reducedDamage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)));
return dealDamage(state, reducedDamage, VictoryMethod.Direct, false, {
message: `Ouroboros absorbs the blow (${reducedDamage} dmg)`,
messageRu: `Уроборос поглощает удар (${reducedDamage} урона)`,
});
}
/** Apply chemical damage (NaOH neutralization) */
function applyChemicalDamage(
state: BossState,
boss: BossData,
armor: number,
): BossDamageResult {
const rawDamage = PROJECTILE_BASE_DAMAGE * boss.chemicalDamageMultiplier;
const damage = Math.max(1, Math.round(rawDamage * (1 - armor)));
return dealDamage(state, damage, VictoryMethod.Chemical, false, {
message: `Acid neutralized! NaOH + acid → salt + water (${damage} dmg)`,
messageRu: `Кислота нейтрализована! NaOH + кислота → соль + вода (${damage} урона)`,
});
}
/** Apply direct projectile damage during vulnerability */
function applyDirectDamage(
state: BossState,
boss: BossData,
armor: number,
): BossDamageResult {
const damage = Math.max(1, Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)));
return dealDamage(state, damage, VictoryMethod.Direct, false, {
message: `Hit during digestion! (${damage} dmg)`,
messageRu: `Попадание во время переваривания! (${damage} урона)`,
});
}
/** Apply catalyst poison (Hg) */
function applyCatalystDamage(
state: BossState,
boss: BossData,
armor: number,
): BossDamageResult {
let catalystApplied = false;
let message: string;
let messageRu: string;
if (state.catalystStacks < boss.maxCatalystStacks) {
state.catalystStacks += 1;
catalystApplied = true;
message = `Mercury poisons catalytic site! Stack ${state.catalystStacks}/${boss.maxCatalystStacks} — regen and armor permanently reduced`;
messageRu = `Ртуть отравляет каталитический центр! Стак ${state.catalystStacks}/${boss.maxCatalystStacks} — регенерация и броня снижены навсегда`;
} else {
message = `Catalyst fully poisoned (${CATALYST_DAMAGE} dmg)`;
messageRu = `Катализатор полностью отравлен (${CATALYST_DAMAGE} урона)`;
}
const damage = Math.max(1, Math.round(CATALYST_DAMAGE * (1 - armor)));
return dealDamage(state, damage, VictoryMethod.Catalytic, catalystApplied, {
message,
messageRu,
});
}
/** Common damage application logic */
function dealDamage(
state: BossState,
damage: number,
damageType: VictoryMethod,
catalystApplied: boolean,
messages: { message: string; messageRu: string },
): BossDamageResult {
state.health = Math.max(0, state.health - damage);
state.totalDamageDealt += damage;
// Track damage by type
switch (damageType) {
case VictoryMethod.Chemical:
state.chemicalDamageDealt += damage;
break;
case VictoryMethod.Direct:
state.directDamageDealt += damage;
break;
case VictoryMethod.Catalytic:
state.catalystDamageDealt += damage;
break;
}
// Check for killing blow
const killingBlow = state.health <= 0;
if (killingBlow) {
state.defeated = true;
state.victoryMethod = damageType;
}
return {
damageDealt: damage,
damageType,
killingBlow,
catalystApplied,
...messages,
};
}
/**
* Check if the boss is defeated.
*/
export function isBossDefeated(state: BossState): boolean {
return state.defeated;
}

View File

@@ -0,0 +1,35 @@
import type { CompoundData } from './types';
import compoundsRaw from '../data/compounds.json';
const compounds: CompoundData[] = compoundsRaw as CompoundData[];
const byId = new Map<string, CompoundData>();
for (const c of compounds) {
if (byId.has(c.id)) {
throw new Error(`Duplicate compound id: ${c.id}`);
}
byId.set(c.id, c);
}
export const CompoundRegistry = {
getById(id: string): CompoundData | undefined {
return byId.get(id);
},
getAll(): readonly CompoundData[] {
return compounds;
},
has(id: string): boolean {
return byId.has(id);
},
count(): number {
return compounds.length;
},
isCompound(id: string): boolean {
return byId.has(id);
},
} as const;

45
src/chemistry/elements.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { ElementData } from './types';
import elementsRaw from '../data/elements.json';
const elements: ElementData[] = elementsRaw as ElementData[];
const bySymbol = new Map<string, ElementData>();
const byNumber = new Map<number, ElementData>();
for (const el of elements) {
if (bySymbol.has(el.symbol)) {
throw new Error(`Duplicate element symbol: ${el.symbol}`);
}
if (byNumber.has(el.atomicNumber)) {
throw new Error(`Duplicate atomic number: ${el.atomicNumber}`);
}
bySymbol.set(el.symbol, el);
byNumber.set(el.atomicNumber, el);
}
export const ElementRegistry = {
getBySymbol(symbol: string): ElementData | undefined {
return bySymbol.get(symbol);
},
getByNumber(num: number): ElementData | undefined {
return byNumber.get(num);
},
getAll(): readonly ElementData[] {
return elements;
},
has(symbol: string): boolean {
return bySymbol.has(symbol);
},
count(): number {
return elements.length;
},
/** Check if a symbol is an element (vs compound) */
isElement(id: string): boolean {
return bySymbol.has(id);
},
} as const;

209
src/chemistry/engine.ts Normal file
View File

@@ -0,0 +1,209 @@
import type { Reactant, ReactionData, ReactionResult, ReactionConditions } from './types';
import { ElementRegistry } from './elements';
import { CompoundRegistry } from './compounds';
import reactionsRaw from '../data/reactions.json';
const reactions: ReactionData[] = reactionsRaw as ReactionData[];
// === Reaction Key: sorted "id:count" pairs joined by "+" ===
function makeReactionKey(reactants: Reactant[]): string {
return reactants
.map((r) => `${r.id}:${r.count}`)
.sort()
.join('+');
}
// Build index for O(1) lookup
const reactionIndex = new Map<string, ReactionData>();
for (const r of reactions) {
const key = makeReactionKey(r.reactants);
if (reactionIndex.has(key)) {
console.warn(`Duplicate reaction key: ${key} (${r.id} conflicts with ${reactionIndex.get(key)!.id})`);
}
reactionIndex.set(key, r);
}
// === Failure Reason Generation ===
function isNobleGas(id: string): boolean {
const el = ElementRegistry.getBySymbol(id);
return el?.category === 'noble-gas';
}
function generateFailureReason(
reactants: Reactant[],
conditions?: Partial<ReactionConditions>,
): { reason: string; reasonRu: string } {
const ids = reactants.map((r) => r.id);
// Check for noble gas
const nobleGas = ids.find((id) => isNobleGas(id));
if (nobleGas) {
const el = ElementRegistry.getBySymbol(nobleGas)!;
return {
reason: `${el.name} is a noble gas — it has a full electron shell and refuses to react with anything.`,
reasonRu: `${el.nameRu} — благородный газ с полной электронной оболочкой. Не реагирует ни с чем.`,
};
}
// Check if all inputs are the same element
const uniqueIds = new Set(ids);
if (uniqueIds.size === 1) {
return {
reason: `Cannot react an element with itself under these conditions. Try combining with a different element.`,
reasonRu: `Нельзя провести реакцию элемента с самим собой в этих условиях. Попробуйте другой элемент.`,
};
}
// Check for gold (extremely unreactive)
if (ids.includes('Au')) {
return {
reason: `Gold is extremely unreactive — it resists most chemical attacks. Only aqua regia (HNO₃ + HCl) can dissolve it.`,
reasonRu: `Золото крайне инертно — устойчиво к большинству реагентов. Только царская водка (HNO₃ + HCl) растворяет его.`,
};
}
// Check if a matching reaction exists but conditions aren't met
const key = makeReactionKey(reactants);
// Try nearby keys (different counts)
for (const [rKey, reaction] of reactionIndex) {
const rIds = new Set(reaction.reactants.map((r) => r.id));
const inputIds = new Set(ids);
if (setsEqual(rIds, inputIds) && rKey !== key) {
return {
reason: `These elements can react, but you need different proportions. Check the amounts carefully.`,
reasonRu: `Эти элементы могут реагировать, но нужны другие пропорции. Проверьте количества.`,
};
}
}
// Default
return {
reason: `No known reaction between these substances under current conditions. Try adding heat, a catalyst, or different elements.`,
reasonRu: `Нет известной реакции между этими веществами в текущих условиях. Попробуйте нагрев, катализатор или другие элементы.`,
};
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false;
for (const item of a) {
if (!b.has(item)) return false;
}
return true;
}
// === Condition Checking ===
function checkConditions(
required: ReactionConditions | undefined,
provided: Partial<ReactionConditions> | undefined,
): { met: boolean; reason?: string; reasonRu?: string } {
if (!required) return { met: true };
if (required.minTemp && (!provided?.minTemp || provided.minTemp < required.minTemp)) {
return {
met: false,
reason: `This reaction requires a temperature of at least ${required.minTemp}°. You need a heat source.`,
reasonRu: `Эта реакция требует температуру не менее ${required.minTemp}°. Нужен источник тепла.`,
};
}
if (required.catalyst) {
const catalystName =
ElementRegistry.getBySymbol(required.catalyst)?.name ??
CompoundRegistry.getById(required.catalyst)?.name ??
required.catalyst;
if (!provided?.catalyst || provided.catalyst !== required.catalyst) {
return {
met: false,
reason: `This reaction requires a catalyst: ${catalystName}. The catalyst is not consumed — it just enables the reaction.`,
reasonRu: `Эта реакция требует катализатор: ${catalystName}. Катализатор не расходуется — он лишь запускает реакцию.`,
};
}
}
if (required.requiresEnergy && !provided?.requiresEnergy) {
return {
met: false,
reason: `This reaction requires an external energy source (e.g., electricity). It's endothermic — it absorbs energy.`,
reasonRu: `Эта реакция требует внешний источник энергии (напр., электричество). Она эндотермическая — поглощает энергию.`,
};
}
return { met: true };
}
// === Public API ===
export const ReactionEngine = {
/**
* Attempt a reaction with given reactants and conditions.
* Returns success with products, or failure with educational reason.
*/
react(
reactants: Reactant[],
conditions?: Partial<ReactionConditions>,
): ReactionResult {
// Validate all inputs exist
for (const r of reactants) {
if (!ElementRegistry.isElement(r.id) && !CompoundRegistry.isCompound(r.id)) {
return {
success: false,
failureReason: `Unknown substance: "${r.id}". Check spelling.`,
failureReasonRu: `Неизвестное вещество: "${r.id}". Проверьте написание.`,
};
}
}
const key = makeReactionKey(reactants);
const reaction = reactionIndex.get(key);
if (!reaction) {
const { reason, reasonRu } = generateFailureReason(reactants, conditions);
return {
success: false,
failureReason: reason,
failureReasonRu: reasonRu,
};
}
// Check conditions
const condCheck = checkConditions(reaction.conditions, conditions);
if (!condCheck.met) {
return {
success: false,
failureReason: condCheck.reason,
failureReasonRu: condCheck.reasonRu,
};
}
return {
success: true,
products: reaction.products,
reaction,
};
},
/** Get a reaction by its ID */
getById(id: string): ReactionData | undefined {
return reactions.find((r) => r.id === id);
},
/** Get all known reactions */
getAll(): readonly ReactionData[] {
return reactions;
},
/** Get the number of registered reactions */
count(): number {
return reactions.length;
},
/** Build a reaction key from reactants (for testing/debugging) */
makeKey(reactants: Reactant[]): string {
return makeReactionKey(reactants);
},
} as const;

103
src/chemistry/types.ts Normal file
View File

@@ -0,0 +1,103 @@
// === Element Types ===
export type ElementCategory =
| 'alkali-metal'
| 'alkaline-earth'
| 'transition-metal'
| 'post-transition-metal'
| 'metalloid'
| 'nonmetal'
| 'halogen'
| 'noble-gas'
| 'actinide';
export type MatterState = 'solid' | 'liquid' | 'gas';
export interface ElementData {
symbol: string;
name: string;
nameRu: string;
atomicNumber: number;
atomicMass: number;
electronegativity: number; // 0 for noble gases
category: ElementCategory;
state: MatterState; // at room temperature
color: string; // hex for rendering
description: string;
descriptionRu: string;
}
// === Compound Types ===
export interface CompoundData {
id: string; // lookup key, e.g. "NaCl"
formula: string; // display formula with Unicode, e.g. "NaCl"
name: string;
nameRu: string;
mass: number; // molecular mass
state: MatterState;
color: string;
properties: CompoundProperties;
description: string;
descriptionRu: string;
gameEffects: string[];
}
export interface CompoundProperties {
flammable: boolean;
toxic: boolean;
explosive: boolean;
acidic: boolean;
basic: boolean;
oxidizer: boolean;
corrosive: boolean;
}
// === Reaction Types ===
export type ReactionType =
| 'synthesis'
| 'decomposition'
| 'combustion'
| 'single-replacement'
| 'double-replacement'
| 'acid-base'
| 'redox';
export interface Reactant {
id: string; // element symbol or compound id
count: number;
}
export interface Product {
id: string; // element symbol or compound id
count: number;
}
export interface ReactionConditions {
minTemp?: number; // 0=room, 100=boiling, 500=fire, 1000=furnace
catalyst?: string; // compound id required as catalyst
requiresEnergy?: boolean; // needs external energy (electricity)
}
export interface ReactionData {
id: string;
reactants: Reactant[];
products: Product[];
conditions?: ReactionConditions;
energyChange: number; // -100..+100, negative = exothermic
type: ReactionType;
description: string;
descriptionRu: string;
difficulty: number; // 1-5
}
// === Engine Result ===
export interface ReactionResult {
success: boolean;
products?: Product[];
reaction?: ReactionData;
failureReason?: string;
failureReasonRu?: string;
}

View File

@@ -1,5 +1,12 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene'; import { BootScene } from './scenes/BootScene';
import { CradleScene } from './scenes/CradleScene';
import { GameScene } from './scenes/GameScene';
import { UIScene } from './scenes/UIScene';
import { DeathScene } from './scenes/DeathScene';
import { FractalScene } from './scenes/FractalScene';
import { BossArenaScene } from './scenes/BossArenaScene';
import { RenewalScene } from './scenes/RenewalScene';
export const GAME_WIDTH = 1280; export const GAME_WIDTH = 1280;
export const GAME_HEIGHT = 720; export const GAME_HEIGHT = 720;
@@ -10,7 +17,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
height: GAME_HEIGHT, height: GAME_HEIGHT,
backgroundColor: '#0a0a0a', backgroundColor: '#0a0a0a',
parent: document.body, parent: document.body,
scene: [BootScene], scene: [BootScene, CradleScene, GameScene, UIScene, DeathScene, FractalScene, RenewalScene, BossArenaScene],
physics: { physics: {
default: 'arcade', default: 'arcade',
arcade: { arcade: {

350
src/creatures/ai.ts Normal file
View File

@@ -0,0 +1,350 @@
/**
* Creature AI System — Finite State Machine
*
* States: Idle → Wander → Feed → Flee → Attack
* Transitions based on hunger, threats, territory.
*
* Pure function system — reads/writes ECS components only.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import {
Position,
Velocity,
Health,
Creature,
AI,
Metabolism,
LifeCycle,
PlayerTag,
Resource,
} from '../ecs/components';
import { AIState, LifeStage, SpeciesId } from './types';
import type { SpeciesData } from './types';
/** Seeded pseudo-random for AI decisions (deterministic per frame) */
function aiRandom(eid: number, tick: number): number {
let h = ((eid * 2654435761) ^ (tick * 2246822507)) >>> 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
/** Calculate squared distance between two entities */
function distSq(ax: number, ay: number, bx: number, by: number): number {
const dx = ax - bx;
const dy = ay - by;
return dx * dx + dy * dy;
}
/** Set velocity toward a target position at given speed */
function moveToward(
eid: number,
targetX: number,
targetY: number,
speed: number,
): void {
const dx = targetX - Position.x[eid];
const dy = targetY - Position.y[eid];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) {
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
return;
}
Velocity.vx[eid] = (dx / dist) * speed;
Velocity.vy[eid] = (dy / dist) * speed;
}
/** Set velocity away from a position at given speed */
function moveAway(
eid: number,
fromX: number,
fromY: number,
speed: number,
): void {
const dx = Position.x[eid] - fromX;
const dy = Position.y[eid] - fromY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) {
// Random direction when exactly on top
Velocity.vx[eid] = speed;
Velocity.vy[eid] = 0;
return;
}
Velocity.vx[eid] = (dx / dist) * speed;
Velocity.vy[eid] = (dy / dist) * speed;
}
/**
* Find the nearest entity matching a predicate.
* @returns [eid, distanceSquared] or [-1, Infinity] if none found
*/
function findNearest(
world: World,
fromX: number,
fromY: number,
candidates: number[],
maxDistSq: number,
): [number, number] {
let bestEid = -1;
let bestDist = Infinity;
for (const eid of candidates) {
const d = distSq(fromX, fromY, Position.x[eid], Position.y[eid]);
if (d < bestDist && d <= maxDistSq) {
bestDist = d;
bestEid = eid;
}
}
return [bestEid, bestDist];
}
/**
* AI System — runs FSM transitions and actions for all creatures.
*
* @param speciesLookup - map from SpeciesId to species stats
* @param tick - current game tick (for deterministic randomness)
* @param aggroMultiplier - multiplier on flee/aggression radius (default 1.0, e.g. Naturalist bonus)
*/
export function aiSystem(
world: World,
deltaMs: number,
speciesLookup: Map<number, SpeciesData>,
tick: number,
aggroMultiplier = 1.0,
): void {
const dt = deltaMs / 1000;
const creatures = query(world, [Creature, AI, Position, Velocity, Metabolism, LifeCycle]);
// Gather player positions for flee calculations
const players = query(world, [PlayerTag, Position]);
const playerPositions = players.map(eid => ({
x: Position.x[eid],
y: Position.y[eid],
eid,
}));
// Gather resource positions for feeding
const resources = query(world, [Resource, Position]);
for (const eid of creatures) {
// Eggs don't act
if (LifeCycle.stage[eid] === LifeStage.Egg) {
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
continue;
}
const species = speciesLookup.get(Creature.speciesId[eid]);
if (!species) continue;
const speed = LifeCycle.stage[eid] === LifeStage.Youth
? species.speed * 0.7
: LifeCycle.stage[eid] === LifeStage.Aging
? species.speed * 0.5
: species.speed;
// Reduce attack cooldown
if (AI.attackCooldown[eid] > 0) {
AI.attackCooldown[eid] -= deltaMs;
}
// Reduce state timer
AI.stateTimer[eid] -= deltaMs;
// ─── Check for threats (player proximity) ───
let nearestThreatDist = Infinity;
let nearestThreatX = 0;
let nearestThreatY = 0;
for (const p of playerPositions) {
const d = distSq(Position.x[eid], Position.y[eid], p.x, p.y);
if (d < nearestThreatDist) {
nearestThreatDist = d;
nearestThreatX = p.x;
nearestThreatY = p.y;
}
}
const effectiveFleeRadius = species.fleeRadius * aggroMultiplier;
const fleeRadiusSq = effectiveFleeRadius * effectiveFleeRadius;
const shouldFlee = nearestThreatDist < fleeRadiusSq
&& species.speciesId !== SpeciesId.Reagent; // Reagents don't flee
// ─── Check hunger ───
const hungerFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
const isHungry = hungerFraction < Metabolism.hungerThreshold[eid];
// ─── Check for prey (Reagents only) ───
const effectiveAggrRadius = species.aggressionRadius * aggroMultiplier;
const aggrRadiusSq = effectiveAggrRadius * effectiveAggrRadius;
let nearestPreyEid = -1;
let nearestPreyDist = Infinity;
if (species.speciesId === SpeciesId.Reagent) {
// Reagents hunt other creatures (non-Reagent)
for (const other of creatures) {
if (other === eid) continue;
if (Creature.speciesId[other] === SpeciesId.Reagent) continue;
if (LifeCycle.stage[other] === LifeStage.Egg) continue;
const d = distSq(Position.x[eid], Position.y[eid], Position.x[other], Position.y[other]);
if (d < nearestPreyDist && d <= aggrRadiusSq) {
nearestPreyDist = d;
nearestPreyEid = other;
}
}
}
// ─── State transitions ───
const currentState = AI.state[eid];
if (shouldFlee && currentState !== AIState.Flee) {
// Threat detected → flee
AI.state[eid] = AIState.Flee;
AI.stateTimer[eid] = 2000;
} else if (nearestPreyEid >= 0 && isHungry && currentState !== AIState.Attack) {
// Prey spotted and hungry → attack
AI.state[eid] = AIState.Attack;
AI.targetEid[eid] = nearestPreyEid;
AI.stateTimer[eid] = 5000;
} else if (isHungry && currentState !== AIState.Feed && currentState !== AIState.Flee && currentState !== AIState.Attack) {
// Hungry → feed (but don't interrupt active attack)
AI.state[eid] = AIState.Feed;
AI.stateTimer[eid] = 8000;
} else if (AI.stateTimer[eid] <= 0) {
// Timer expired → cycle between idle and wander
if (currentState === AIState.Idle) {
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 3000 + aiRandom(eid, tick) * 4000;
} else if (currentState === AIState.Wander) {
AI.state[eid] = AIState.Idle;
AI.stateTimer[eid] = 1000 + aiRandom(eid, tick) * 2000;
} else if (currentState === AIState.Feed) {
// Couldn't find food → wander
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 3000;
} else if (currentState === AIState.Flee) {
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 2000;
} else if (currentState === AIState.Attack) {
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 2000;
}
}
// ─── Execute current state behavior ───
switch (AI.state[eid]) {
case AIState.Idle: {
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
break;
}
case AIState.Wander: {
// Random direction, clamped to wander radius from home
const homeDist = Math.sqrt(distSq(
Position.x[eid], Position.y[eid],
AI.homeX[eid], AI.homeY[eid],
));
if (homeDist > species.wanderRadius) {
// Too far from home → head back
moveToward(eid, AI.homeX[eid], AI.homeY[eid], speed * 0.6);
} else {
// Pick new random direction periodically
const phase = Math.floor(AI.stateTimer[eid] / 1000);
const angle = aiRandom(eid, tick + phase) * Math.PI * 2;
Velocity.vx[eid] = Math.cos(angle) * speed * 0.5;
Velocity.vy[eid] = Math.sin(angle) * speed * 0.5;
}
break;
}
case AIState.Feed: {
if (species.diet === 'mineral') {
// Find nearest resource entity
const [nearResEid, nearResDist] = findNearest(
world,
Position.x[eid], Position.y[eid],
[...resources],
(species.wanderRadius * 2) ** 2,
);
if (nearResEid >= 0) {
const feedRangeSq = 30 * 30;
if (nearResDist < feedRangeSq) {
// Close enough — feed (handled by metabolism system)
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
} else {
moveToward(eid, Position.x[nearResEid], Position.y[nearResEid], speed);
}
} else {
// No resources found → wander to look for food
const angle = aiRandom(eid, tick) * Math.PI * 2;
Velocity.vx[eid] = Math.cos(angle) * speed * 0.5;
Velocity.vy[eid] = Math.sin(angle) * speed * 0.5;
}
} else if (species.diet === 'creature') {
// Predator — same as attack but passive searching
const angle = aiRandom(eid, tick) * Math.PI * 2;
Velocity.vx[eid] = Math.cos(angle) * speed * 0.7;
Velocity.vy[eid] = Math.sin(angle) * speed * 0.7;
}
break;
}
case AIState.Flee: {
moveAway(eid, nearestThreatX, nearestThreatY, speed * 1.3);
break;
}
case AIState.Attack: {
const target = AI.targetEid[eid];
if (target < 0) {
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 2000;
break;
}
const targetDist = distSq(
Position.x[eid], Position.y[eid],
Position.x[target], Position.y[target],
);
const attackRangeSq = species.attackRange * species.attackRange;
if (targetDist <= attackRangeSq) {
// In range — deal damage if cooldown ready
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
if (AI.attackCooldown[eid] <= 0) {
const dmg = LifeCycle.stage[eid] === LifeStage.Youth
? species.damage * 0.5
: species.damage;
Health.current[target] -= dmg;
AI.attackCooldown[eid] = species.attackCooldown;
// Gain energy from kill if target dies
if (Health.current[target] <= 0) {
Metabolism.energy[eid] = Math.min(
Metabolism.energy[eid] + Metabolism.feedAmount[eid],
Metabolism.energyMax[eid],
);
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 3000;
AI.targetEid[eid] = -1;
}
}
} else {
// Chase target
moveToward(eid, Position.x[target], Position.y[target], speed);
}
break;
}
}
}
}

115
src/creatures/factory.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Creature Factory — creates creature entities in the ECS world
*
* Each creature gets: Position, Velocity, Health, SpriteRef,
* Creature, AI, Metabolism, LifeCycle components.
*/
import { addEntity, addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import {
Position,
Velocity,
Health,
SpriteRef,
Creature,
AI,
Metabolism,
LifeCycle,
} from '../ecs/components';
import { AIState, LifeStage } from './types';
import type { SpeciesData, CreatureInfo } from './types';
/**
* Create a creature entity from species data at a given position.
* Starts at the Egg stage (immobile).
*
* @returns entity ID
*/
export function createCreatureEntity(
world: World,
species: SpeciesData,
x: number,
y: number,
startStage: LifeStage = LifeStage.Egg,
): number {
const eid = addEntity(world);
// Position
addComponent(world, eid, Position);
Position.x[eid] = x;
Position.y[eid] = y;
// Velocity (starts at 0 — eggs don't move)
addComponent(world, eid, Velocity);
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 0;
// Health — scaled by life stage
addComponent(world, eid, Health);
const healthScale = startStage === LifeStage.Egg ? 0.3
: startStage === LifeStage.Youth ? 0.6
: startStage === LifeStage.Aging ? 0.7
: 1.0;
const hp = Math.round(species.health * healthScale);
Health.current[eid] = hp;
Health.max[eid] = hp;
// Sprite — color from species, radius by stage
addComponent(world, eid, SpriteRef);
SpriteRef.color[eid] = parseInt(species.color.replace('#', ''), 16);
SpriteRef.radius[eid] = startStage <= LifeStage.Youth
? species.radiusYouth
: species.radius;
// Creature tag
addComponent(world, eid, Creature);
Creature.speciesId[eid] = species.speciesId;
// AI — starts idle
addComponent(world, eid, AI);
AI.state[eid] = startStage === LifeStage.Egg ? AIState.Idle : AIState.Wander;
AI.stateTimer[eid] = 2000; // 2s initial timer
AI.targetEid[eid] = -1;
AI.homeX[eid] = x;
AI.homeY[eid] = y;
AI.attackCooldown[eid] = 0;
// Metabolism
addComponent(world, eid, Metabolism);
Metabolism.energy[eid] = species.energyMax * 0.7; // start 70% full
Metabolism.energyMax[eid] = species.energyMax;
Metabolism.drainRate[eid] = species.energyDrainPerSecond;
Metabolism.feedAmount[eid] = species.energyPerFeed;
Metabolism.hungerThreshold[eid] = species.hungerThreshold;
// Life Cycle
addComponent(world, eid, LifeCycle);
LifeCycle.stage[eid] = startStage;
LifeCycle.age[eid] = 0;
// Set stage timer based on starting stage
const stageTimers = [
species.eggDuration,
species.youthDuration,
species.matureDuration,
species.agingDuration,
];
LifeCycle.stageTimer[eid] = stageTimers[startStage];
return eid;
}
/**
* Get runtime creature info for string-based lookups.
* Used by the creature data map (similar to resourceData pattern).
*/
export function getCreatureInfo(
eid: number,
speciesDataId: string,
): CreatureInfo {
return {
speciesId: Creature.speciesId[eid],
speciesDataId,
};
}

21
src/creatures/index.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Creatures module — re-exports for clean imports
*/
export { SpeciesId, AIState, LifeStage, SpeciesRegistry } from './types';
export type { SpeciesData, CreatureInfo, DietType } from './types';
export { createCreatureEntity, getCreatureInfo } from './factory';
export { aiSystem } from './ai';
export { metabolismSystem, clearMetabolismTracking, resetMetabolismTracking } from './metabolism';
export { lifeCycleSystem } from './lifecycle';
export type { LifeCycleEvent } from './lifecycle';
export { countPopulations, spawnInitialCreatures, reproduce } from './population';
export {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from './interaction';
export type { CreatureObservation, ProjectileHit } from './interaction';

View File

@@ -0,0 +1,216 @@
/**
* Creature Interaction — projectile damage, player observation, basic taming
*
* Handles projectile-creature collision and player proximity detection.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import {
Position,
Health,
Creature,
AI,
LifeCycle,
Projectile,
PlayerTag,
Metabolism,
} from '../ecs/components';
import { AIState, LifeStage } from './types';
import type { SpeciesData } from './types';
import type { ProjectileData } from '../player/projectile';
import { removeGameEntity } from '../ecs/factory';
/** Projectile hit radius squared (projectile + creature overlap) */
const HIT_RADIUS_SQ = 20 * 20;
/** Observation range — how close player must be to observe a creature */
const OBSERVE_RANGE_SQ = 60 * 60;
/** Base projectile damage */
const PROJECTILE_BASE_DAMAGE = 15;
/** Result of a creature observation */
export interface CreatureObservation {
eid: number;
speciesId: number;
healthPercent: number;
energyPercent: number;
stage: LifeStage;
aiState: AIState;
}
/** Result of projectile-creature collision */
export interface ProjectileHit {
creatureEid: number;
projectileEid: number;
damage: number;
killed: boolean;
speciesId: number;
}
/**
* Check projectile-creature collisions.
* Removes hitting projectiles and deals damage to creatures.
*
* @param damageMultiplier — optional multiplier on projectile damage (default 1.0, e.g. Mechanic bonus)
* @returns Array of hits that occurred
*/
export function creatureProjectileSystem(
world: World,
projData: Map<number, ProjectileData>,
speciesLookup: Map<number, SpeciesData>,
damageMultiplier = 1.0,
): ProjectileHit[] {
const hits: ProjectileHit[] = [];
const projectiles = query(world, [Position, Projectile]);
const creatures = query(world, [Creature, Position, Health, LifeCycle]);
const projectilesToRemove: number[] = [];
for (const projEid of projectiles) {
const px = Position.x[projEid];
const py = Position.y[projEid];
for (const cEid of creatures) {
const dx = px - Position.x[cEid];
const dy = py - Position.y[cEid];
const dSq = dx * dx + dy * dy;
if (dSq <= HIT_RADIUS_SQ) {
// Hit! Calculate damage (with armor reduction and school bonus)
const species = speciesLookup.get(Creature.speciesId[cEid]);
const armor = species?.armor ?? 0;
const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor) * damageMultiplier);
Health.current[cEid] -= damage;
const killed = Health.current[cEid] <= 0;
hits.push({
creatureEid: cEid,
projectileEid: projEid,
damage,
killed,
speciesId: Creature.speciesId[cEid],
});
// Creature becomes aggressive or flees
if (!killed && species) {
if (species.aggressionRadius > 0) {
// Territorial/aggressive → attack the player
AI.state[cEid] = AIState.Attack;
AI.stateTimer[cEid] = 5000;
// Find nearest player to target
const players = query(world, [PlayerTag, Position]);
if (players.length > 0) {
AI.targetEid[cEid] = players[0];
}
} else {
// Passive → flee
AI.state[cEid] = AIState.Flee;
AI.stateTimer[cEid] = 3000;
}
}
projectilesToRemove.push(projEid);
break; // one hit per projectile
}
}
}
// Remove hit projectiles
for (const eid of projectilesToRemove) {
removeGameEntity(world, eid);
projData.delete(eid);
}
return hits;
}
/**
* Get observable creatures near the player.
* Returns info about creatures within observation range.
*/
export function getObservableCreatures(
world: World,
): CreatureObservation[] {
const observations: CreatureObservation[] = [];
const players = query(world, [PlayerTag, Position]);
if (players.length === 0) return observations;
const playerX = Position.x[players[0]];
const playerY = Position.y[players[0]];
const creatures = query(world, [Creature, Position, Health, LifeCycle, Metabolism]);
for (const eid of creatures) {
const dx = playerX - Position.x[eid];
const dy = playerY - Position.y[eid];
const dSq = dx * dx + dy * dy;
if (dSq <= OBSERVE_RANGE_SQ) {
observations.push({
eid,
speciesId: Creature.speciesId[eid],
healthPercent: Health.max[eid] > 0
? Math.round((Health.current[eid] / Health.max[eid]) * 100)
: 0,
energyPercent: Metabolism.energyMax[eid] > 0
? Math.round((Metabolism.energy[eid] / Metabolism.energyMax[eid]) * 100)
: 0,
stage: LifeCycle.stage[eid] as LifeStage,
aiState: AI.state[eid] as AIState,
});
}
}
return observations;
}
/**
* Check if player is near any creature for attack damage (creature → player).
* Returns total damage dealt to player this frame.
*/
export function creatureAttackPlayerSystem(
world: World,
speciesLookup: Map<number, SpeciesData>,
): number {
let totalDamage = 0;
const players = query(world, [PlayerTag, Position, Health]);
if (players.length === 0) return 0;
const playerEid = players[0];
const playerX = Position.x[playerEid];
const playerY = Position.y[playerEid];
const creatures = query(world, [Creature, AI, Position, LifeCycle]);
for (const eid of creatures) {
if (AI.state[eid] !== AIState.Attack) continue;
if (AI.targetEid[eid] !== playerEid) continue;
if (LifeCycle.stage[eid] === LifeStage.Egg) continue;
const species = speciesLookup.get(Creature.speciesId[eid]);
if (!species) continue;
const dx = playerX - Position.x[eid];
const dy = playerY - Position.y[eid];
const dSq = dx * dx + dy * dy;
const rangeSq = species.attackRange * species.attackRange;
if (dSq <= rangeSq && AI.attackCooldown[eid] <= 0) {
const dmg = LifeCycle.stage[eid] === LifeStage.Youth
? species.damage * 0.5
: species.damage;
totalDamage += dmg;
AI.attackCooldown[eid] = species.attackCooldown;
}
}
if (totalDamage > 0) {
Health.current[playerEid] -= totalDamage;
}
return totalDamage;
}

152
src/creatures/lifecycle.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Life Cycle System — manages creature aging through stages
*
* Egg → Youth → Mature → Aging → Death
*
* Each stage has a timer. When it expires, creature advances to next stage.
* Stats (health, speed, size) change with each stage.
* Death at end of Aging stage returns entity ID for removal + decomposition.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import {
Health,
SpriteRef,
Creature,
AI,
Metabolism,
LifeCycle,
} from '../ecs/components';
import { AIState, LifeStage } from './types';
import type { SpeciesData } from './types';
/** Result of life cycle processing */
export interface LifeCycleEvent {
type: 'stage_advance' | 'natural_death' | 'ready_to_reproduce';
eid: number;
speciesId: number;
newStage?: LifeStage;
}
/**
* Life cycle system — advances stages, adjusts stats, detects natural death.
*
* @returns Array of lifecycle events (stage advances, natural deaths, reproduction readiness)
*/
export function lifeCycleSystem(
world: World,
deltaMs: number,
speciesLookup: Map<number, SpeciesData>,
): LifeCycleEvent[] {
const events: LifeCycleEvent[] = [];
for (const eid of query(world, [Creature, LifeCycle])) {
const species = speciesLookup.get(Creature.speciesId[eid]);
if (!species) continue;
// Advance age
LifeCycle.age[eid] += deltaMs;
// Decrease stage timer
LifeCycle.stageTimer[eid] -= deltaMs;
// Check for reproduction readiness (mature with enough energy)
if (LifeCycle.stage[eid] === LifeStage.Mature) {
const energyFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
if (energyFraction > 0.8) {
events.push({
type: 'ready_to_reproduce',
eid,
speciesId: Creature.speciesId[eid],
});
}
}
// Stage transition when timer expires
if (LifeCycle.stageTimer[eid] <= 0) {
const currentStage = LifeCycle.stage[eid] as LifeStage;
switch (currentStage) {
case LifeStage.Egg: {
// Hatch → Youth
LifeCycle.stage[eid] = LifeStage.Youth;
LifeCycle.stageTimer[eid] = species.youthDuration;
// Update visuals — still small
SpriteRef.radius[eid] = species.radiusYouth;
// Enable AI
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 2000;
// Scale health up
const youthHp = Math.round(species.health * 0.6);
Health.current[eid] = youthHp;
Health.max[eid] = youthHp;
events.push({
type: 'stage_advance',
eid,
speciesId: Creature.speciesId[eid],
newStage: LifeStage.Youth,
});
break;
}
case LifeStage.Youth: {
// Grow → Mature
LifeCycle.stage[eid] = LifeStage.Mature;
LifeCycle.stageTimer[eid] = species.matureDuration;
// Full size and health
SpriteRef.radius[eid] = species.radius;
Health.current[eid] = species.health;
Health.max[eid] = species.health;
events.push({
type: 'stage_advance',
eid,
speciesId: Creature.speciesId[eid],
newStage: LifeStage.Mature,
});
break;
}
case LifeStage.Mature: {
// Age → Aging
LifeCycle.stage[eid] = LifeStage.Aging;
LifeCycle.stageTimer[eid] = species.agingDuration;
// Reduced stats
const agingHp = Math.round(species.health * 0.7);
Health.max[eid] = agingHp;
if (Health.current[eid] > agingHp) {
Health.current[eid] = agingHp;
}
events.push({
type: 'stage_advance',
eid,
speciesId: Creature.speciesId[eid],
newStage: LifeStage.Aging,
});
break;
}
case LifeStage.Aging: {
// Natural death
Health.current[eid] = 0;
events.push({
type: 'natural_death',
eid,
speciesId: Creature.speciesId[eid],
});
break;
}
}
}
}
return events;
}

124
src/creatures/metabolism.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* Metabolism System — energy drain, feeding, starvation
*
* Every creature loses energy over time. Feeding near resources restores energy.
* Starvation (energy = 0) deals damage over time.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import {
Position,
Health,
Creature,
AI,
Metabolism,
LifeCycle,
Resource,
} from '../ecs/components';
import { AIState, LifeStage } from './types';
import type { ResourceInfo } from '../player/interaction';
/** Damage per second when starving (energy = 0) */
const STARVATION_DAMAGE_PER_SEC = 5;
/** Feed range squared (pixels) — how close creature must be to resource */
const FEED_RANGE_SQ = 35 * 35;
/** Minimum time between feeds (ms) */
const FEED_COOLDOWN_MS = 2000;
/** Tracking last feed time per creature to prevent instant depletion */
const lastFeedTime = new Map<number, number>();
/**
* Metabolism system — drains energy, handles feeding from resources, starvation damage.
*
* @param resourceData - map of resource entity ID → info (for depletion tracking)
* @param elapsed - total elapsed time in ms (for feed cooldown)
* @returns Array of [creatureEid, elementNumber] pairs for excretion events
*/
export function metabolismSystem(
world: World,
deltaMs: number,
resourceData: Map<number, ResourceInfo>,
elapsed: number,
): Array<[number, number]> {
const dt = deltaMs / 1000;
const excretions: Array<[number, number]> = [];
const creatures = query(world, [Creature, Metabolism, Position, LifeCycle]);
const resources = query(world, [Resource, Position]);
for (const eid of creatures) {
// Eggs don't metabolize
if (LifeCycle.stage[eid] === LifeStage.Egg) continue;
// ─── Energy drain ───
const drainMultiplier = LifeCycle.stage[eid] === LifeStage.Youth ? 0.7
: LifeCycle.stage[eid] === LifeStage.Aging ? 1.3
: 1.0;
Metabolism.energy[eid] -= Metabolism.drainRate[eid] * dt * drainMultiplier;
// ─── Feeding (if in Feed state and near resource) ───
if (AI.state[eid] === AIState.Feed || AI.state[eid] === AIState.Idle) {
const lastFed = lastFeedTime.get(eid) ?? 0;
if (elapsed - lastFed >= FEED_COOLDOWN_MS) {
for (const resEid of resources) {
if (Resource.quantity[resEid] <= 0) continue;
const dx = Position.x[eid] - Position.x[resEid];
const dy = Position.y[eid] - Position.y[resEid];
const dSq = dx * dx + dy * dy;
if (dSq <= FEED_RANGE_SQ) {
// Feed!
Metabolism.energy[eid] = Math.min(
Metabolism.energy[eid] + Metabolism.feedAmount[eid],
Metabolism.energyMax[eid],
);
Resource.quantity[resEid] -= 1;
lastFeedTime.set(eid, elapsed);
// Excretion event (species excretes element after feeding)
const speciesId = Creature.speciesId[eid];
// Excretion element is stored in species data, we pass speciesId
// and let the caller resolve what element is excreted
excretions.push([eid, speciesId]);
// Exit Feed state if energy is above threshold
const fraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
if (fraction >= Metabolism.hungerThreshold[eid] * 1.5) {
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 3000;
}
break; // one feed per tick
}
}
}
}
// ─── Clamp energy ───
if (Metabolism.energy[eid] < 0) {
Metabolism.energy[eid] = 0;
}
// ─── Starvation damage ───
if (Metabolism.energy[eid] <= 0) {
Health.current[eid] -= STARVATION_DAMAGE_PER_SEC * dt;
}
}
return excretions;
}
/** Clear tracking data for a removed creature */
export function clearMetabolismTracking(eid: number): void {
lastFeedTime.delete(eid);
}
/** Reset all metabolism tracking (for tests) */
export function resetMetabolismTracking(): void {
lastFeedTime.clear();
}

170
src/creatures/population.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* Population Dynamics — spawning, reproduction, population caps
*
* Simplified Lotka-Volterra inspired system:
* - Reproduction when mature creatures have high energy
* - Population caps per species prevent explosion
* - Initial spawning on preferred tiles
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Creature, LifeCycle } from '../ecs/components';
import { LifeStage, SpeciesId } from './types';
import type { SpeciesData, CreatureInfo } from './types';
import { createCreatureEntity } from './factory';
import type { TileGrid, BiomeData } from '../world/types';
/** Simple seeded hash for deterministic spawn placement */
function spawnHash(x: number, y: number, seed: number, salt: number): number {
return (((x * 48611) ^ (y * 29423) ^ (seed * 61379) ^ (salt * 73757)) >>> 0) / 4294967296;
}
/** Count living creatures per species */
export function countPopulations(world: World): Map<number, number> {
const counts = new Map<number, number>();
for (const eid of query(world, [Creature])) {
const sid = Creature.speciesId[eid];
counts.set(sid, (counts.get(sid) ?? 0) + 1);
}
return counts;
}
/**
* Spawn initial creatures across the map.
* Places creatures on preferred tiles using seeded randomness.
*
* @returns Map of entity ID → CreatureInfo
*/
export function spawnInitialCreatures(
world: World,
grid: TileGrid,
biome: BiomeData,
seed: number,
allSpecies: SpeciesData[],
): Map<number, CreatureInfo> {
const creatureData = new Map<number, CreatureInfo>();
const tileSize = biome.tileSize;
// Build tile name → id map
const tileNameToId = new Map<string, number>();
for (const tile of biome.tiles) {
tileNameToId.set(tile.name, tile.id);
}
for (const species of allSpecies) {
// Find preferred tile IDs
const preferredIds = new Set<number>();
for (const name of species.preferredTiles) {
const id = tileNameToId.get(name);
if (id !== undefined) preferredIds.add(id);
}
// Collect candidate positions
const candidates: Array<{ x: number; y: number }> = [];
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
if (preferredIds.has(grid[y][x])) {
candidates.push({ x, y });
}
}
}
// Spawn based on weight — deterministic selection
const targetCount = Math.min(
Math.round(species.maxPopulation * species.spawnWeight),
species.maxPopulation,
);
let spawned = 0;
for (let i = 0; i < candidates.length && spawned < targetCount; i++) {
const { x, y } = candidates[i];
const roll = spawnHash(x, y, seed, species.speciesId);
// Probabilistic placement — spread creatures out
if (roll < species.spawnWeight * 0.05) {
const px = x * tileSize + tileSize / 2;
const py = y * tileSize + tileSize / 2;
// Start mature for initial population
const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature);
creatureData.set(eid, {
speciesId: species.speciesId,
speciesDataId: species.id,
});
spawned++;
}
}
// Fallback: if not enough spawned, place remaining at random candidates
if (spawned < targetCount && candidates.length > 0) {
for (let i = 0; spawned < targetCount && i < 1000; i++) {
const idx = Math.floor(spawnHash(i, spawned, seed, species.speciesId + 100) * candidates.length);
const { x, y } = candidates[idx];
const px = x * tileSize + tileSize / 2;
const py = y * tileSize + tileSize / 2;
const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature);
creatureData.set(eid, {
speciesId: species.speciesId,
speciesDataId: species.id,
});
spawned++;
}
}
}
return creatureData;
}
/**
* Handle reproduction — create offspring near parent.
* Called when lifecycle system reports 'ready_to_reproduce' events.
*
* @param parentEid - entity ID of the reproducing creature
* @param species - species data
* @param currentPopulation - current count of this species
* @returns Array of new creature entity IDs (empty if population cap reached)
*/
export function reproduce(
world: World,
parentEid: number,
species: SpeciesData,
currentPopulation: number,
creatureData: Map<number, CreatureInfo>,
): number[] {
if (currentPopulation >= species.maxPopulation) {
return [];
}
const newEids: number[] = [];
const maxOffspring = Math.min(
species.offspringCount,
species.maxPopulation - currentPopulation,
);
const parentX = Position.x[parentEid];
const parentY = Position.y[parentEid];
for (let i = 0; i < maxOffspring; i++) {
// Place egg near parent with slight offset
const angle = (i / maxOffspring) * Math.PI * 2;
const offsetX = Math.cos(angle) * 20;
const offsetY = Math.sin(angle) * 20;
const eid = createCreatureEntity(
world, species,
parentX + offsetX,
parentY + offsetY,
LifeStage.Egg,
);
creatureData.set(eid, {
speciesId: species.speciesId,
speciesDataId: species.id,
});
newEids.push(eid);
}
return newEids;
}

132
src/creatures/types.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Creature Types — data definitions for the creature/ecology system
*
* Catalytic Wastes: Crystallids, Acidophiles, Reagents
* Kinetic Mountains: Pendulums, Mechanoids, Resonators
* Verdant Forests: Symbiotes, Mimics, Spore-bearers
*/
/** Species identifier (stored as numeric ID in ECS components) */
export enum SpeciesId {
Crystallid = 0,
Acidophile = 1,
Reagent = 2,
Pendulum = 3,
Mechanoid = 4,
Resonator = 5,
Symbiote = 6,
Mimic = 7,
SporeBearer = 8,
}
/** AI behavior state (FSM) */
export enum AIState {
Idle = 0,
Wander = 1,
Feed = 2,
Flee = 3,
Attack = 4,
}
/** Life cycle stage */
export enum LifeStage {
Egg = 0,
Youth = 1,
Mature = 2,
Aging = 3,
}
/** Diet type — what a creature can feed on */
export type DietType = 'mineral' | 'acid' | 'creature';
/** Species definition loaded from creatures.json */
export interface SpeciesData {
id: string;
name: string;
nameRu: string;
speciesId: SpeciesId;
biome: string; // biome id this species belongs to
description: string;
descriptionRu: string;
/** Visual */
color: string; // hex color string
radius: number; // base radius in pixels
radiusYouth: number; // smaller when young
/** Stats */
health: number;
speed: number; // pixels per second
damage: number; // damage per attack
armor: number; // damage reduction (0-1)
/** Metabolism */
diet: DietType;
dietTiles: string[]; // tile names this species feeds on
excretionElement: number; // atomic number of excreted element (0 = none)
energyMax: number; // max energy capacity
energyPerFeed: number; // energy gained per feeding
energyDrainPerSecond: number; // passive energy loss rate
hungerThreshold: number; // below this → seek food (0-1 fraction of max)
/** Behavior */
aggressionRadius: number; // distance to detect threats
fleeRadius: number; // distance to start fleeing
wanderRadius: number; // max wander distance from home
attackRange: number; // melee attack range
attackCooldown: number; // ms between attacks
/** Life cycle durations (ms) */
eggDuration: number;
youthDuration: number;
matureDuration: number;
agingDuration: number;
/** Reproduction */
reproductionEnergy: number; // energy cost to reproduce
offspringCount: number; // eggs per reproduction event
/** Population */
maxPopulation: number; // per-species cap
spawnWeight: number; // relative spawn density (0-1)
preferredTiles: string[]; // tiles where this species spawns
}
/** Runtime creature state (non-ECS, for string data lookup) */
export interface CreatureInfo {
speciesId: SpeciesId;
speciesDataId: string; // key into species registry
}
/** Creature species registry */
export class SpeciesRegistry {
private byId = new Map<string, SpeciesData>();
private byNumericId = new Map<SpeciesId, SpeciesData>();
constructor(data: SpeciesData[]) {
for (const species of data) {
this.byId.set(species.id, species);
this.byNumericId.set(species.speciesId, species);
}
}
/** Get species by string ID */
get(id: string): SpeciesData | undefined {
return this.byId.get(id);
}
/** Get species by numeric ID (from ECS component) */
getByNumericId(id: SpeciesId): SpeciesData | undefined {
return this.byNumericId.get(id);
}
/** Get all species */
getAll(): SpeciesData[] {
return [...this.byId.values()];
}
/** Get species count */
get count(): number {
return this.byId.size;
}
}

110
src/data/biomes.json Normal file
View File

@@ -0,0 +1,110 @@
[
{
"id": "catalytic-wastes",
"name": "Catalytic Wastes",
"nameRu": "Каталитические Пустоши",
"description": "A blasted landscape of scorched earth, acid pools, and crystalline formations",
"descriptionRu": "Опалённый ландшафт из выжженной земли, кислотных озёр и кристаллических формаций",
"tileSize": 32,
"mapWidth": 80,
"mapHeight": 80,
"tiles": [
{ "id": 0, "name": "scorched-earth", "nameRu": "Выжженная земля", "color": "#2a1f0e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 1, "name": "cracked-ground", "nameRu": "Потрескавшаяся земля", "color": "#3d2b14", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 2, "name": "ash-sand", "nameRu": "Пепельный песок", "color": "#4a3d2e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 3, "name": "acid-pool", "nameRu": "Кислотное озеро", "color": "#1a6b0a", "walkable": false, "damage": 10, "interactive": false, "resource": false },
{ "id": 4, "name": "acid-shallow", "nameRu": "Кислотная отмель", "color": "#3a9420", "walkable": true, "damage": 3, "interactive": false, "resource": false },
{ "id": 5, "name": "crystal", "nameRu": "Кристаллическая формация", "color": "#7b5ea7", "walkable": false, "damage": 0, "interactive": false, "resource": false },
{ "id": 6, "name": "geyser", "nameRu": "Гейзер", "color": "#e85d10", "walkable": false, "damage": 0, "interactive": true, "resource": false },
{ "id": 7, "name": "mineral-vein", "nameRu": "Минеральная жила", "color": "#c0a030", "walkable": true, "damage": 0, "interactive": false, "resource": true }
],
"generation": {
"elevationScale": 0.06,
"detailScale": 0.15,
"elevationRules": [
{ "below": 0.22, "tileId": 3 },
{ "below": 0.30, "tileId": 4 },
{ "below": 0.52, "tileId": 0 },
{ "below": 0.70, "tileId": 1 },
{ "below": 0.84, "tileId": 2 },
{ "below": 1.00, "tileId": 5 }
],
"geyserThreshold": 0.93,
"mineralThreshold": 0.90,
"geyserOnTile": 4,
"mineralOnTiles": [0, 1, 2]
}
},
{
"id": "kinetic-mountains",
"name": "Kinetic Mountains",
"nameRu": "Кинетические Горы",
"description": "Towering cliffs embedded with ancient gears and pendulums. Anomalous gravity zones, avalanche-prone slopes, and whirring mechanical ruins.",
"descriptionRu": "Высокие скалы с вросшими древними шестернями и маятниками. Зоны аномальной гравитации, лавиноопасные склоны и гудящие механические руины.",
"tileSize": 32,
"mapWidth": 80,
"mapHeight": 80,
"tiles": [
{ "id": 0, "name": "bare-rock", "nameRu": "Голый камень", "color": "#5a5a5a", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 1, "name": "gravel-slope", "nameRu": "Гравийный склон", "color": "#7a7a6e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 2, "name": "iron-ridge", "nameRu": "Железный хребет", "color": "#4a3a2a", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 3, "name": "chasm", "nameRu": "Пропасть", "color": "#0a0a1a", "walkable": false, "damage": 15, "interactive": false, "resource": false },
{ "id": 4, "name": "gear-floor", "nameRu": "Шестерёночный пол", "color": "#8b7355", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 5, "name": "magnetic-field", "nameRu": "Магнитное поле", "color": "#3344aa", "walkable": true, "damage": 2, "interactive": false, "resource": false },
{ "id": 6, "name": "steam-vent", "nameRu": "Паровой клапан", "color": "#cccccc", "walkable": false, "damage": 0, "interactive": true, "resource": false },
{ "id": 7, "name": "ore-deposit", "nameRu": "Рудное месторождение", "color": "#b87333", "walkable": true, "damage": 0, "interactive": false, "resource": true }
],
"generation": {
"elevationScale": 0.05,
"detailScale": 0.18,
"elevationRules": [
{ "below": 0.15, "tileId": 3 },
{ "below": 0.25, "tileId": 5 },
{ "below": 0.50, "tileId": 0 },
{ "below": 0.68, "tileId": 1 },
{ "below": 0.82, "tileId": 2 },
{ "below": 1.00, "tileId": 4 }
],
"geyserThreshold": 0.92,
"mineralThreshold": 0.88,
"geyserOnTile": 5,
"mineralOnTiles": [0, 1, 2]
}
},
{
"id": "verdant-forests",
"name": "Verdant Forests",
"nameRu": "Вердантовые Леса",
"description": "A multi-layered living forest teeming with biodiversity. Underground mycorrhizal networks, towering canopies, and bioluminescent clearings.",
"descriptionRu": "Многоярусный живой лес, кишащий биоразнообразием. Подземные микоризные сети, высочайшие кроны и биолюминесцентные поляны.",
"tileSize": 32,
"mapWidth": 80,
"mapHeight": 80,
"tiles": [
{ "id": 0, "name": "forest-floor", "nameRu": "Лесная подстилка", "color": "#1a3a0e", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 1, "name": "dense-undergrowth", "nameRu": "Густой подлесок", "color": "#0d2b06", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 2, "name": "mycelium-carpet", "nameRu": "Мицелиевый ковёр", "color": "#4a2a5a", "walkable": true, "damage": 0, "interactive": false, "resource": false },
{ "id": 3, "name": "bog", "nameRu": "Трясина", "color": "#2a4a1a", "walkable": true, "damage": 4, "interactive": false, "resource": false },
{ "id": 4, "name": "toxic-bloom", "nameRu": "Ядовитый цвет", "color": "#9a3a6a", "walkable": false, "damage": 8, "interactive": false, "resource": false },
{ "id": 5, "name": "ancient-tree", "nameRu": "Древо-Великан", "color": "#1a2a0a", "walkable": false, "damage": 0, "interactive": false, "resource": false },
{ "id": 6, "name": "hollow-stump", "nameRu": "Полый пень", "color": "#5a4a2a", "walkable": true, "damage": 0, "interactive": true, "resource": false },
{ "id": 7, "name": "herb-patch", "nameRu": "Лекарственная поляна", "color": "#2a6a1a", "walkable": true, "damage": 0, "interactive": false, "resource": true }
],
"generation": {
"elevationScale": 0.07,
"detailScale": 0.12,
"elevationRules": [
{ "below": 0.18, "tileId": 3 },
{ "below": 0.28, "tileId": 4 },
{ "below": 0.55, "tileId": 0 },
{ "below": 0.72, "tileId": 1 },
{ "below": 0.86, "tileId": 2 },
{ "below": 1.00, "tileId": 5 }
],
"geyserThreshold": 0.91,
"mineralThreshold": 0.87,
"geyserOnTile": 2,
"mineralOnTiles": [0, 1, 2]
}
}
]

31
src/data/bosses.json Normal file
View File

@@ -0,0 +1,31 @@
[
{
"id": "ouroboros",
"name": "Ouroboros",
"nameRu": "Уроборос",
"description": "The Archon of Cycles — a serpent consuming its own tail in endless self-catalysis. Its body is both catalyst and substrate.",
"descriptionRu": "Архонт Циклов — змей, пожирающий собственный хвост в бесконечном самокатализе. Его тело — и катализатор, и субстрат.",
"color": "#cc44ff",
"radius": 20,
"health": 300,
"armor": 0.5,
"armorVulnerable": 0.1,
"regenPerSecond": 5,
"damage": 15,
"phaseDurations": [5000, 8000, 6000, 4000],
"phaseSpeedupPerCycle": 0.9,
"maxCycles": 5,
"vulnerablePhases": [3],
"chemicalWeakness": "NaOH",
"chemicalDamageMultiplier": 3.0,
"chemicalEffectivePhases": [1],
"catalystPoison": "Hg",
"catalystRegenReduction": 2.0,
"catalystArmorReduction": 0.1,
"maxCatalystStacks": 3,
"arenaRadius": 10,
"loreEntry": "The first Archon awakens. The Ouroboros — an eternal cycle of self-consumption. Its body is both catalyst and substrate, endlessly breaking itself down and rebuilding. To break its cycle is to understand catalysis itself: that which accelerates change without being consumed... until a poison finds the active site.",
"loreEntryRu": "Первый Архонт пробуждается. Уроборос — вечный цикл самопоглощения. Его тело — одновременно катализатор и субстрат, бесконечно разрушающее и восстанавливающее себя. Разорвать его цикл — значит постичь суть катализа: то, что ускоряет изменение, не расходуясь... пока яд не найдёт активный центр.",
"sporeReward": 100
}
]

516
src/data/compounds.json Normal file
View File

@@ -0,0 +1,516 @@
[
{
"id": "H2O", "formula": "H₂O", "name": "Water", "nameRu": "Вода",
"mass": 18.015, "state": "liquid", "color": "#4488cc",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Universal solvent. Essential for life and countless reactions.",
"descriptionRu": "Универсальный растворитель. Необходима для жизни и бесчисленных реакций.",
"gameEffects": ["hydration", "cooling", "solvent"]
},
{
"id": "NaCl", "formula": "NaCl", "name": "Sodium Chloride", "nameRu": "Поваренная соль",
"mass": 58.44, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Table salt. Preserves food via osmosis. Essential electrolyte.",
"descriptionRu": "Поваренная соль. Консервирует пищу через осмос. Необходимый электролит.",
"gameEffects": ["preservation", "trade", "slug_repellent"]
},
{
"id": "CO2", "formula": "CO₂", "name": "Carbon Dioxide", "nameRu": "Углекислый газ",
"mass": 44.01, "state": "gas", "color": "#aaaaaa",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Product of combustion and respiration. Denser than air — extinguishes fire.",
"descriptionRu": "Продукт горения и дыхания. Тяжелее воздуха — тушит огонь.",
"gameEffects": ["fire_extinguisher", "suffocant"]
},
{
"id": "CO", "formula": "CO", "name": "Carbon Monoxide", "nameRu": "Угарный газ",
"mass": 28.01, "state": "gas", "color": "#cccccc",
"properties": { "flammable": true, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Odorless, deadly poison. Binds to hemoglobin 200x stronger than oxygen.",
"descriptionRu": "Без запаха, смертельно ядовит. Связывается с гемоглобином в 200 раз сильнее кислорода.",
"gameEffects": ["poison_gas", "fuel"]
},
{
"id": "HCl", "formula": "HCl", "name": "Hydrochloric Acid", "nameRu": "Соляная кислота",
"mass": 36.46, "state": "liquid", "color": "#ccff00",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Strong acid. Dissolves metals and limestone. Present in stomach acid.",
"descriptionRu": "Сильная кислота. Растворяет металлы и известняк. Содержится в желудочном соке.",
"gameEffects": ["dissolve_metal", "dissolve_stone", "damage"]
},
{
"id": "NaOH", "formula": "NaOH", "name": "Sodium Hydroxide", "nameRu": "Гидроксид натрия",
"mass": 40.00, "state": "solid", "color": "#f5f5f5",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
"description": "Lye. Extremely caustic. Used for soap making and as a powerful cleaning agent.",
"descriptionRu": "Щёлочь. Крайне едкий. Используется для мыловарения и как мощное чистящее средство.",
"gameEffects": ["soap_making", "corrosive_weapon", "cleaning"]
},
{
"id": "KOH", "formula": "KOH", "name": "Potassium Hydroxide", "nameRu": "Гидроксид калия",
"mass": 56.11, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
"description": "Caustic potash. Even stronger base than NaOH. Key in fertilizer production.",
"descriptionRu": "Едкое кали. Ещё более сильное основание, чем NaOH. Ключ к производству удобрений.",
"gameEffects": ["corrosive_weapon", "fertilizer_base"]
},
{
"id": "CaO", "formula": "CaO", "name": "Calcium Oxide", "nameRu": "Негашёная известь",
"mass": 56.08, "state": "solid", "color": "#f5f5dc",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
"description": "Quickite. Reacts violently with water, generating intense heat. Building material.",
"descriptionRu": "Негашёная известь. Бурно реагирует с водой, выделяя сильное тепло. Строительный материал.",
"gameEffects": ["heat_source", "building", "disinfectant"]
},
{
"id": "CaOH2", "formula": "Ca(OH)₂", "name": "Calcium Hydroxide", "nameRu": "Гашёная известь",
"mass": 74.09, "state": "solid", "color": "#f5f5f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
"description": "Slaked lime. Used in mortar, plaster, and water purification.",
"descriptionRu": "Гашёная известь. Используется в строительном растворе, штукатурке и очистке воды.",
"gameEffects": ["building_mortar", "water_purification", "disinfectant"]
},
{
"id": "MgO", "formula": "MgO", "name": "Magnesium Oxide", "nameRu": "Оксид магния",
"mass": 40.30, "state": "solid", "color": "#ffffff",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
"description": "Produced by burning magnesium with a blinding white flash. Refractory material.",
"descriptionRu": "Образуется при горении магния с ослепительной белой вспышкой. Огнеупорный материал.",
"gameEffects": ["flash_blind", "refractory"]
},
{
"id": "Fe2O3", "formula": "Fe₂O₃", "name": "Iron(III) Oxide", "nameRu": "Оксид железа (ржавчина)",
"mass": 159.69, "state": "solid", "color": "#993300",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Rust. Indicator of aging metal. Key ingredient for thermite (with aluminum).",
"descriptionRu": "Ржавчина. Индикатор старения металла. Ключевой ингредиент термита (с алюминием).",
"gameEffects": ["thermite_ingredient", "pigment"]
},
{
"id": "SiO2", "formula": "SiO₂", "name": "Silicon Dioxide", "nameRu": "Диоксид кремния",
"mass": 60.08, "state": "solid", "color": "#e8e8e8",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Silica. Main component of sand, quartz, and glass. Extremely hard.",
"descriptionRu": "Кремнезём. Основной компонент песка, кварца и стекла. Крайне твёрдый.",
"gameEffects": ["glass_making", "abrasive", "building"]
},
{
"id": "FeS", "formula": "FeS", "name": "Iron Sulfide", "nameRu": "Сульфид железа",
"mass": 87.91, "state": "solid", "color": "#b8a000",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Fool's gold (pyrite). Sparks when struck — natural fire starter.",
"descriptionRu": "Пирит (золото дураков). Искрит при ударе — природное огниво.",
"gameEffects": ["fire_starter", "trade_decoy"]
},
{
"id": "ZnS", "formula": "ZnS", "name": "Zinc Sulfide", "nameRu": "Сульфид цинка",
"mass": 97.47, "state": "solid", "color": "#eeffdd",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Phosphorescent — glows in the dark after exposure to light. Natural night-light.",
"descriptionRu": "Фосфоресцирует — светится в темноте после облучения светом. Природный ночник.",
"gameEffects": ["glow", "cave_light", "marking"]
},
{
"id": "CuS", "formula": "CuS", "name": "Copper Sulfide", "nameRu": "Сульфид меди",
"mass": 95.61, "state": "solid", "color": "#1a1a2e",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Dark mineral. Copper ore found near volcanic activity.",
"descriptionRu": "Тёмный минерал. Медная руда, встречающаяся вблизи вулканической активности.",
"gameEffects": ["pigment", "copper_source"]
},
{
"id": "KCl", "formula": "KCl", "name": "Potassium Chloride", "nameRu": "Хлорид калия",
"mass": 74.55, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Potash salt. Essential plant fertilizer. Salt substitute for food.",
"descriptionRu": "Калийная соль. Необходимое удобрение для растений. Заменитель соли в пище.",
"gameEffects": ["fertilizer", "preservation"]
},
{
"id": "SO2", "formula": "SO₂", "name": "Sulfur Dioxide", "nameRu": "Диоксид серы",
"mass": 64.07, "state": "gas", "color": "#cccc00",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Pungent, suffocating gas. Fumigant and preservative. Causes acid rain.",
"descriptionRu": "Едкий, удушливый газ. Фумигант и консервант. Вызывает кислотные дожди.",
"gameEffects": ["fumigant", "area_denial", "preservation"]
},
{
"id": "CaCO3", "formula": "CaCO₃", "name": "Calcium Carbonate", "nameRu": "Карбонат кальция",
"mass": 100.09, "state": "solid", "color": "#f5f0e0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
"description": "Limestone, chalk, marble. Building material. Neutralizes acid soil and acid attacks.",
"descriptionRu": "Известняк, мел, мрамор. Строительный материал. Нейтрализует кислую почву и кислотные атаки.",
"gameEffects": ["building", "acid_neutralizer", "soil_amendment"]
},
{
"id": "NaHCO3", "formula": "NaHCO₃", "name": "Sodium Bicarbonate", "nameRu": "Пищевая сода",
"mass": 84.01, "state": "solid", "color": "#ffffff",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": false },
"description": "Baking soda. Neutralizes acids, extinguishes grease fires, gentle cleaning agent.",
"descriptionRu": "Пищевая сода. Нейтрализует кислоты, тушит жировые пожары, мягкое чистящее средство.",
"gameEffects": ["acid_neutralizer", "fire_extinguisher", "baking"]
},
{
"id": "KNO3", "formula": "KNO₃", "name": "Potassium Nitrate", "nameRu": "Калиевая селитра",
"mass": 101.10, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": true, "corrosive": false },
"description": "Saltpeter. Powerful oxidizer. Key component of black gunpowder (with sulfur and charcoal).",
"descriptionRu": "Селитра. Мощный окислитель. Ключевой компонент чёрного пороха (с серой и углём).",
"gameEffects": ["gunpowder_ingredient", "oxidizer", "fertilizer"]
},
{
"id": "ZnO", "formula": "ZnO", "name": "Zinc Oxide", "nameRu": "Оксид цинка",
"mass": 81.38, "state": "solid", "color": "#ffffff",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "White powder. UV protection, wound healing, anti-inflammatory. Used in sunscreen and ointments.",
"descriptionRu": "Белый порошок. Защита от УФ, заживление ран, противовоспалительное. Основа мазей.",
"gameEffects": ["healing_salve", "sun_protection"]
},
{
"id": "Al2O3", "formula": "Al₂O₃", "name": "Aluminum Oxide", "nameRu": "Оксид алюминия",
"mass": 101.96, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Corundum. Extremely hard (9 on Mohs scale). Rubies and sapphires are colored alumina.",
"descriptionRu": "Корунд. Крайне твёрдый (9 по шкале Мооса). Рубины и сапфиры — окрашенный корунд.",
"gameEffects": ["abrasive", "armor_material", "gemstone"]
},
{
"id": "CH4", "formula": "CH₄", "name": "Methane", "nameRu": "Метан",
"mass": 16.04, "state": "gas", "color": "#aaddff",
"properties": { "flammable": true, "toxic": false, "explosive": true, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Simplest hydrocarbon. Odorless, highly flammable. Main component of natural gas.",
"descriptionRu": "Простейший углеводород. Без запаха, легковоспламеняем. Основной компонент природного газа.",
"gameEffects": ["fuel", "explosive_gas", "lantern_fuel"]
},
{
"id": "C2H5OH", "formula": "C₂H₅OH", "name": "Ethanol", "nameRu": "Этанол",
"mass": 46.07, "state": "liquid", "color": "#eeeeff",
"properties": { "flammable": true, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Drinking alcohol. Disinfectant, fuel, solvent. Intoxicates NPCs.",
"descriptionRu": "Питьевой спирт. Дезинфектант, топливо, растворитель. Опьяняет NPC.",
"gameEffects": ["disinfectant", "fuel", "intoxicant", "solvent"]
},
{
"id": "GUNPOWDER", "formula": "KNO₃+S+C", "name": "Black Gunpowder", "nameRu": "Чёрный порох",
"mass": 165.17, "state": "solid", "color": "#333333",
"properties": { "flammable": true, "toxic": false, "explosive": true, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Explosive mixture of saltpeter, sulfur, and charcoal. The discovery that changed civilizations.",
"descriptionRu": "Взрывчатая смесь селитры, серы и угля. Открытие, изменившее цивилизации.",
"gameEffects": ["explosive", "propellant", "signal_flare"]
},
{
"id": "NH3", "formula": "NH₃", "name": "Ammonia", "nameRu": "Аммиак",
"mass": 17.031, "state": "gas", "color": "#aaccff",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Pungent gas. Key to fertilizer production (Haber process). Essential biological nitrogen carrier.",
"descriptionRu": "Едкий газ. Основа производства удобрений (процесс Габера). Важнейший биологический переносчик азота.",
"gameEffects": ["fertilizer", "cleaning", "refrigerant"]
},
{
"id": "HF", "formula": "HF", "name": "Hydrofluoric Acid", "nameRu": "Плавиковая кислота",
"mass": 20.006, "state": "liquid", "color": "#ccff66",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Etches glass! One of the most dangerous acids — penetrates skin and attacks bones.",
"descriptionRu": "Растворяет стекло! Одна из опаснейших кислот — проникает через кожу и разрушает кости.",
"gameEffects": ["glass_etching", "damage", "dissolve_stone"]
},
{
"id": "HBr", "formula": "HBr", "name": "Hydrobromic Acid", "nameRu": "Бромоводородная кислота",
"mass": 80.912, "state": "liquid", "color": "#cc6633",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Strong acid formed from hydrogen and bromine. Stronger than HCl.",
"descriptionRu": "Сильная кислота из водорода и брома. Сильнее соляной кислоты.",
"gameEffects": ["dissolve_metal", "damage"]
},
{
"id": "HI", "formula": "HI", "name": "Hydroiodic Acid", "nameRu": "Йодоводородная кислота",
"mass": 127.912, "state": "liquid", "color": "#993399",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Strongest of the hydrohalic acids. Powerful reducing agent.",
"descriptionRu": "Самая сильная из галогеноводородных кислот. Мощный восстановитель.",
"gameEffects": ["reducing_agent", "dissolve_metal"]
},
{
"id": "LiOH", "formula": "LiOH", "name": "Lithium Hydroxide", "nameRu": "Гидроксид лития",
"mass": 23.948, "state": "solid", "color": "#e8e8ff",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
"description": "Lightest alkali hydroxide. Used in spacecraft CO₂ scrubbers and lithium batteries.",
"descriptionRu": "Легчайший гидроксид щелочного металла. Используется для очистки воздуха на космических кораблях и в литиевых батареях.",
"gameEffects": ["co2_scrubber", "battery_material"]
},
{
"id": "LiCl", "formula": "LiCl", "name": "Lithium Chloride", "nameRu": "Хлорид лития",
"mass": 42.394, "state": "solid", "color": "#e0e0e0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Extremely hygroscopic salt. Absorbs moisture from air. Used as desiccant.",
"descriptionRu": "Крайне гигроскопичная соль. Поглощает влагу из воздуха. Используется как осушитель.",
"gameEffects": ["desiccant", "dehumidifier"]
},
{
"id": "TiO2", "formula": "TiO₂", "name": "Titanium Dioxide", "nameRu": "Диоксид титана",
"mass": 79.866, "state": "solid", "color": "#ffffff",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Brilliant white pigment. UV blocker. Photocatalyst that breaks down pollutants in sunlight.",
"descriptionRu": "Ярко-белый пигмент. УФ-блокатор. Фотокатализатор, расщепляющий загрязнители на свету.",
"gameEffects": ["pigment", "sun_protection", "photocatalyst"]
},
{
"id": "Cr2O3", "formula": "Cr₂O₃", "name": "Chromium(III) Oxide", "nameRu": "Оксид хрома(III)",
"mass": 151.99, "state": "solid", "color": "#336633",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Deep green pigment. Extremely hard and heat-resistant. Used in armor and abrasives.",
"descriptionRu": "Насыщенный зелёный пигмент. Крайне твёрд и термостоек. Используется в броне и абразивах.",
"gameEffects": ["pigment", "armor_material", "abrasive"]
},
{
"id": "MnO2", "formula": "MnO₂", "name": "Manganese Dioxide", "nameRu": "Диоксид марганца",
"mass": 86.937, "state": "solid", "color": "#333333",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": true, "corrosive": false },
"description": "Natural catalyst and oxidizer. Key component of dry cell batteries. Accelerates many reactions.",
"descriptionRu": "Природный катализатор и окислитель. Ключевой компонент батареек. Ускоряет многие реакции.",
"gameEffects": ["catalyst", "battery_material", "oxidizer"]
},
{
"id": "CoO", "formula": "CoO", "name": "Cobalt(II) Oxide", "nameRu": "Оксид кобальта(II)",
"mass": 74.932, "state": "solid", "color": "#336699",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Deep blue compound. Used in pottery glazes since ancient Egypt. Magnetic.",
"descriptionRu": "Тёмно-синее соединение. Используется в керамических глазурях со времён Древнего Египта. Магнитно.",
"gameEffects": ["pigment", "magnetic"]
},
{
"id": "NiCl2", "formula": "NiCl₂", "name": "Nickel(II) Chloride", "nameRu": "Хлорид никеля(II)",
"mass": 129.60, "state": "solid", "color": "#00cc66",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Bright green salt. Key reagent in electroplating. Catalyst for organic reactions.",
"descriptionRu": "Ярко-зелёная соль. Ключевой реагент для гальванопокрытий. Катализатор органических реакций.",
"gameEffects": ["electroplating", "catalyst"]
},
{
"id": "As2O3", "formula": "As₂O₃", "name": "Arsenic Trioxide", "nameRu": "Оксид мышьяка(III)",
"mass": 197.841, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "The 'King of Poisons'. Odorless, tasteless, lethal. Used as a rat poison for centuries.",
"descriptionRu": "«Король ядов». Без запаха, вкуса, смертелен. Веками использовался как крысиный яд.",
"gameEffects": ["deadly_poison", "rat_poison", "glass_clarifier"]
},
{
"id": "AgCl", "formula": "AgCl", "name": "Silver Chloride", "nameRu": "Хлорид серебра",
"mass": 143.321, "state": "solid", "color": "#e0e0e0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "White solid that darkens in light (basis of photography). Antimicrobial wound dressing.",
"descriptionRu": "Белое вещество, темнеющее на свету (основа фотографии). Антимикробная раневая повязка.",
"gameEffects": ["photography", "antimicrobial", "light_sensor"]
},
{
"id": "BaO", "formula": "BaO", "name": "Barium Oxide", "nameRu": "Оксид бария",
"mass": 153.326, "state": "solid", "color": "#f5f5cc",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
"description": "Powerful desiccant and getter. Reacts violently with water. Produces green flame.",
"descriptionRu": "Мощный осушитель и геттер. Бурно реагирует с водой. Даёт зелёное пламя.",
"gameEffects": ["desiccant", "green_flame"]
},
{
"id": "WO3", "formula": "WO₃", "name": "Tungsten Trioxide", "nameRu": "Оксид вольфрама(VI)",
"mass": 231.84, "state": "solid", "color": "#cccc00",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Yellow powder. Electrochromic — changes color with voltage. Smart glass technology.",
"descriptionRu": "Жёлтый порошок. Электрохромный — меняет цвет при напряжении. Технология умного стекла.",
"gameEffects": ["smart_glass", "electrochromic", "pigment"]
},
{
"id": "PbO", "formula": "PbO", "name": "Lead(II) Oxide", "nameRu": "Оксид свинца(II)",
"mass": 223.2, "state": "solid", "color": "#cc9933",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Yellow-orange powder (litharge). Used in lead-acid batteries and ancient pottery glazes.",
"descriptionRu": "Жёлто-оранжевый порошок (глёт). Используется в свинцовых аккумуляторах и древних керамических глазурях.",
"gameEffects": ["battery_material", "pigment", "radiation_shield"]
},
{
"id": "PbS", "formula": "PbS", "name": "Lead(II) Sulfide", "nameRu": "Сульфид свинца (галенит)",
"mass": 239.27, "state": "solid", "color": "#333344",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Galena — the principal ore of lead. First semiconductor discovered. Used in early radio crystal detectors.",
"descriptionRu": "Галенит — основная руда свинца. Первый обнаруженный полупроводник. Применялся в ранних радиоприёмниках.",
"gameEffects": ["semiconductor", "radio_detector", "lead_source"]
},
{
"id": "Bi2O3", "formula": "Bi₂O₃", "name": "Bismuth Trioxide", "nameRu": "Оксид висмута(III)",
"mass": 465.959, "state": "solid", "color": "#ffffcc",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Yellow powder with medicinal properties. Active ingredient in stomach remedies. Low toxicity.",
"descriptionRu": "Жёлтый порошок с лечебными свойствами. Действующее вещество желудочных средств. Малотоксичен.",
"gameEffects": ["medicine", "stomach_remedy", "pigment"]
},
{
"id": "UO2", "formula": "UO₂", "name": "Uranium Dioxide", "nameRu": "Диоксид урана",
"mass": 270.028, "state": "solid", "color": "#333300",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Nuclear fuel. Incredibly dense. A single pellet contains energy equivalent to a tonne of coal.",
"descriptionRu": "Ядерное топливо. Невероятно плотный. Один стержень содержит энергию, эквивалентную тонне угля.",
"gameEffects": ["nuclear_fuel", "energy_source", "radiation"]
},
{
"id": "CH3COOH", "formula": "CH₃COOH", "name": "Acetic Acid", "nameRu": "Уксусная кислота",
"mass": 60.052, "state": "liquid", "color": "#eeeedd",
"properties": { "flammable": true, "toxic": false, "explosive": false, "acidic": true, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Vinegar. Natural product of fermentation. Solvent, preservative, and creature bait.",
"descriptionRu": "Уксус. Природный продукт брожения. Растворитель, консервант и приманка для существ.",
"gameEffects": ["solvent", "preservative", "creature_bait", "descaler"]
},
{
"id": "C6H12O6", "formula": "C₆H₁₂O₆", "name": "Glucose", "nameRu": "Глюкоза",
"mass": 180.156, "state": "solid", "color": "#ffffee",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Simple sugar. Universal biological fuel. Feed to microorganisms or use as fast energy source.",
"descriptionRu": "Простой сахар. Универсальное биологическое топливо. Корм для микроорганизмов или быстрый источник энергии.",
"gameEffects": ["energy_food", "microorganism_feed", "fermentation_base"]
},
{
"id": "H2S", "formula": "H₂S", "name": "Hydrogen Sulfide", "nameRu": "Сероводород",
"mass": 34.08, "state": "gas", "color": "#cccc66",
"properties": { "flammable": true, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Rotten egg smell. Toxic in high concentrations. Natural hot spring emission.",
"descriptionRu": "Запах тухлых яиц. Токсичен в высоких концентрациях. Газ горячих источников.",
"gameEffects": ["poison_gas", "indicator", "hot_spring"]
},
{
"id": "H2SO4", "formula": "H₂SO₄", "name": "Sulfuric Acid", "nameRu": "Серная кислота",
"mass": 98.079, "state": "liquid", "color": "#ffee00",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": true, "corrosive": true },
"description": "King of chemicals. Most produced chemical worldwide. Violently exothermic with water.",
"descriptionRu": "Царь химикатов. Самое производимое химическое вещество в мире. Бурно реагирует с водой.",
"gameEffects": ["dissolve_metal", "dissolve_organic", "battery_acid", "damage"]
},
{
"id": "HNO3", "formula": "HNO₃", "name": "Nitric Acid", "nameRu": "Азотная кислота",
"mass": 63.012, "state": "liquid", "color": "#ff6600",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": true, "basic": false, "oxidizer": true, "corrosive": true },
"description": "Fuming corrosive acid. Dissolves most metals. Component of aqua regia (with HCl) to dissolve gold.",
"descriptionRu": "Дымящая кислота. Растворяет большинство металлов. Компонент царской водки (с HCl) для растворения золота.",
"gameEffects": ["dissolve_metal", "oxidizer", "damage", "aqua_regia_component"]
},
{
"id": "CuO", "formula": "CuO", "name": "Copper(II) Oxide", "nameRu": "Оксид меди(II)",
"mass": 79.545, "state": "solid", "color": "#1a1a1a",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Black powder. Reducible by carbon or hydrogen — ancient copper smelting.",
"descriptionRu": "Чёрный порошок. Восстанавливается углеродом или водородом — древняя медеплавка.",
"gameEffects": ["pigment", "smelting_ore", "catalyst"]
},
{
"id": "NaF", "formula": "NaF", "name": "Sodium Fluoride", "nameRu": "Фторид натрия",
"mass": 41.988, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Active ingredient in toothpaste. Strengthens tooth enamel. Toxic in large doses.",
"descriptionRu": "Активный компонент зубной пасты. Укрепляет зубную эмаль. Токсичен в больших дозах.",
"gameEffects": ["dental_protection", "water_treatment"]
},
{
"id": "NaBr", "formula": "NaBr", "name": "Sodium Bromide", "nameRu": "Бромид натрия",
"mass": 102.894, "state": "solid", "color": "#e8e8e8",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "White crystalline salt. Historical sedative and anticonvulsant. Photography reagent.",
"descriptionRu": "Белая кристаллическая соль. Исторический седативный и противосудорожный препарат.",
"gameEffects": ["sedative", "photography"]
},
{
"id": "KF", "formula": "KF", "name": "Potassium Fluoride", "nameRu": "Фторид калия",
"mass": 58.097, "state": "solid", "color": "#e0e0e0",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Strong fluorinating agent. Used in organic synthesis. Corrosive.",
"descriptionRu": "Сильный фторирующий агент. Используется в органическом синтезе. Едкий.",
"gameEffects": ["fluorinating_agent"]
},
{
"id": "KBr", "formula": "KBr", "name": "Potassium Bromide", "nameRu": "Бромид калия",
"mass": 119.002, "state": "solid", "color": "#e0e0e0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Photography essential. Historical anticonvulsant medicine. Transparent to infrared.",
"descriptionRu": "Необходим для фотографии. Исторический противосудорожный препарат. Прозрачен для инфракрасного.",
"gameEffects": ["photography", "medicine", "optics"]
},
{
"id": "AgBr", "formula": "AgBr", "name": "Silver Bromide", "nameRu": "Бромид серебра",
"mass": 187.772, "state": "solid", "color": "#e8e8cc",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Pale yellow, darkens in light. The basis of photographic film. More light-sensitive than AgCl.",
"descriptionRu": "Бледно-жёлтый, темнеет на свету. Основа фотоплёнки. Светочувствительнее AgCl.",
"gameEffects": ["photography", "light_sensor"]
},
{
"id": "NH4Cl", "formula": "NH₄Cl", "name": "Ammonium Chloride", "nameRu": "Хлорид аммония",
"mass": 53.491, "state": "solid", "color": "#f5f5f5",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Sal ammoniac. White smoke when heated. Used in batteries, flux, and cough medicine.",
"descriptionRu": "Нашатырь. Белый дым при нагревании. Используется в батарейках, флюсе и от кашля.",
"gameEffects": ["flux", "battery_material", "smoke_screen"]
},
{
"id": "MgCl2", "formula": "MgCl₂", "name": "Magnesium Chloride", "nameRu": "Хлорид магния",
"mass": 95.211, "state": "solid", "color": "#e0e0e0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "De-icing salt. Dust suppressant. Source of magnesium metal via electrolysis.",
"descriptionRu": "Противогололёдная соль. Подавитель пыли. Источник металлического магния через электролиз.",
"gameEffects": ["de_icing", "dust_control", "magnesium_source"]
},
{
"id": "CaCl2", "formula": "CaCl₂", "name": "Calcium Chloride", "nameRu": "Хлорид кальция",
"mass": 110.984, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Powerful desiccant and de-icer. Exothermic when dissolved — can melt ice below -50°C.",
"descriptionRu": "Мощный осушитель и антигололёдный реагент. Экзотермичен при растворении — плавит лёд ниже -50°C.",
"gameEffects": ["desiccant", "de_icing", "heat_source"]
},
{
"id": "BaCl2", "formula": "BaCl₂", "name": "Barium Chloride", "nameRu": "Хлорид бария",
"mass": 208.233, "state": "solid", "color": "#f0f0f0",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Toxic barium salt. Burns with characteristic green flame. Chemical analysis reagent.",
"descriptionRu": "Токсичная соль бария. Горит характерным зелёным пламенем. Реагент для химического анализа.",
"gameEffects": ["green_flame", "analysis_reagent"]
},
{
"id": "FeCl3", "formula": "FeCl₃", "name": "Iron(III) Chloride", "nameRu": "Хлорид железа(III)",
"mass": 162.204, "state": "solid", "color": "#663300",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Dark brown, corrosive. Etches copper (circuit boards). Water purification agent.",
"descriptionRu": "Тёмно-коричневый, едкий. Травит медь (печатные платы). Реагент для очистки воды.",
"gameEffects": ["copper_etching", "water_purification", "coagulant"]
},
{
"id": "CuCl2", "formula": "CuCl₂", "name": "Copper(II) Chloride", "nameRu": "Хлорид меди(II)",
"mass": 134.452, "state": "solid", "color": "#006633",
"properties": { "flammable": false, "toxic": true, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Bright green crystals. Turns blue in water. Catalyst. Gives green-blue flame.",
"descriptionRu": "Ярко-зелёные кристаллы. Синеет в воде. Катализатор. Даёт зелёно-голубое пламя.",
"gameEffects": ["catalyst", "pigment", "blue_green_flame"]
},
{
"id": "FeCl2", "formula": "FeCl₂", "name": "Iron(II) Chloride", "nameRu": "Хлорид железа(II)",
"mass": 126.751, "state": "solid", "color": "#669966",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": false },
"description": "Greenish solid. Product of iron dissolved in HCl. Reducing agent.",
"descriptionRu": "Зеленоватое твёрдое вещество. Продукт растворения железа в HCl. Восстановитель.",
"gameEffects": ["reducing_agent", "water_treatment"]
},
{
"id": "ZnCl2", "formula": "ZnCl₂", "name": "Zinc Chloride", "nameRu": "Хлорид цинка",
"mass": 136.286, "state": "solid", "color": "#e0e0e0",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": false, "oxidizer": false, "corrosive": true },
"description": "Powerful flux for soldering. Dehydrating agent. Used in fireproofing fabric.",
"descriptionRu": "Мощный флюс для пайки. Обезвоживающий агент. Огнезащита тканей.",
"gameEffects": ["flux", "desiccant", "fireproofing"]
},
{
"id": "Na2O", "formula": "Na₂O", "name": "Sodium Oxide", "nameRu": "Оксид натрия",
"mass": 61.979, "state": "solid", "color": "#f5f5f5",
"properties": { "flammable": false, "toxic": false, "explosive": false, "acidic": false, "basic": true, "oxidizer": false, "corrosive": true },
"description": "White powder. Reacts violently with water to form NaOH. Key component in glass making.",
"descriptionRu": "Белый порошок. Бурно реагирует с водой, образуя NaOH. Ключевой компонент стекловарения.",
"gameEffects": ["glass_making", "caustic"]
}
]

335
src/data/creatures.json Normal file
View File

@@ -0,0 +1,335 @@
[
{
"id": "crystallid",
"name": "Crystallid",
"nameRu": "Кристаллид",
"speciesId": 0,
"biome": "catalytic-wastes",
"description": "Slow, armored creature that feeds on mineral deposits. Its silicon-rich body refracts light into prismatic patterns. When killed, it shatters into valuable crystalline fragments.",
"descriptionRu": "Медленное бронированное существо, питающееся минеральными отложениями. Его кремниевое тело преломляет свет в призматические узоры. При гибели рассыпается на ценные кристаллические осколки.",
"color": "#88ccff",
"radius": 10,
"radiusYouth": 6,
"health": 120,
"speed": 30,
"damage": 8,
"armor": 0.3,
"diet": "mineral",
"dietTiles": ["mineral-vein", "crystal-formation"],
"excretionElement": 14,
"energyMax": 100,
"energyPerFeed": 25,
"energyDrainPerSecond": 1.5,
"hungerThreshold": 0.4,
"aggressionRadius": 0,
"fleeRadius": 80,
"wanderRadius": 200,
"attackRange": 20,
"attackCooldown": 2000,
"eggDuration": 8000,
"youthDuration": 15000,
"matureDuration": 60000,
"agingDuration": 20000,
"reproductionEnergy": 60,
"offspringCount": 2,
"maxPopulation": 12,
"spawnWeight": 0.4,
"preferredTiles": ["ground", "scorched-earth"]
},
{
"id": "acidophile",
"name": "Acidophile",
"nameRu": "Ацидофил",
"speciesId": 1,
"biome": "catalytic-wastes",
"description": "Mid-sized creature that thrives in acidic environments. Consumes mineral deposits and excretes hydrochloric acid, gradually reshaping the landscape. Territorial but not predatory.",
"descriptionRu": "Среднеразмерное существо, процветающее в кислотной среде. Поглощает минералы и выделяет соляную кислоту, постепенно изменяя ландшафт. Территориальное, но не хищное.",
"color": "#44ff44",
"radius": 8,
"radiusYouth": 5,
"health": 80,
"speed": 50,
"damage": 12,
"armor": 0.1,
"diet": "mineral",
"dietTiles": ["mineral-vein", "geyser"],
"excretionElement": 17,
"energyMax": 80,
"energyPerFeed": 20,
"energyDrainPerSecond": 2.0,
"hungerThreshold": 0.5,
"aggressionRadius": 100,
"fleeRadius": 120,
"wanderRadius": 250,
"attackRange": 24,
"attackCooldown": 1500,
"eggDuration": 6000,
"youthDuration": 12000,
"matureDuration": 45000,
"agingDuration": 15000,
"reproductionEnergy": 50,
"offspringCount": 3,
"maxPopulation": 15,
"spawnWeight": 0.35,
"preferredTiles": ["acid-shallow", "ground"]
},
{
"id": "reagent",
"name": "Reagent",
"nameRu": "Реагент",
"speciesId": 2,
"biome": "catalytic-wastes",
"description": "Fast, aggressive predator that hunts in pairs. Contains unstable chemical compounds — when two Reagents collide, they trigger an exothermic reaction. Feeds on other creatures.",
"descriptionRu": "Быстрый агрессивный хищник, охотящийся парами. Содержит нестабильные химические соединения — при столкновении двух Реагентов происходит экзотермическая реакция. Питается другими существами.",
"color": "#ff4444",
"radius": 7,
"radiusYouth": 4,
"health": 60,
"speed": 80,
"damage": 20,
"armor": 0.0,
"diet": "creature",
"dietTiles": [],
"excretionElement": 0,
"energyMax": 60,
"energyPerFeed": 30,
"energyDrainPerSecond": 3.0,
"hungerThreshold": 0.6,
"aggressionRadius": 150,
"fleeRadius": 60,
"wanderRadius": 300,
"attackRange": 18,
"attackCooldown": 1000,
"eggDuration": 5000,
"youthDuration": 10000,
"matureDuration": 35000,
"agingDuration": 12000,
"reproductionEnergy": 40,
"offspringCount": 1,
"maxPopulation": 8,
"spawnWeight": 0.25,
"preferredTiles": ["scorched-earth", "ground"]
},
{
"id": "pendulum",
"name": "Pendulum",
"nameRu": "Маятник",
"speciesId": 3,
"biome": "kinetic-mountains",
"description": "Flying creature with strictly periodic motion. Swings back and forth on invisible pivot points. Can be used as a clock or platform. Docile unless struck mid-swing.",
"descriptionRu": "Летающее существо со строго периодичным движением. Качается взад-вперёд на невидимых опорных точках. Можно использовать как часы или платформу. Мирное, если не ударить в полёте.",
"color": "#77aadd",
"radius": 9,
"radiusYouth": 5,
"health": 90,
"speed": 60,
"damage": 10,
"armor": 0.15,
"diet": "mineral",
"dietTiles": ["ore-deposit", "gear-floor"],
"excretionElement": 26,
"energyMax": 90,
"energyPerFeed": 22,
"energyDrainPerSecond": 1.8,
"hungerThreshold": 0.45,
"aggressionRadius": 0,
"fleeRadius": 100,
"wanderRadius": 220,
"attackRange": 22,
"attackCooldown": 1800,
"eggDuration": 7000,
"youthDuration": 14000,
"matureDuration": 55000,
"agingDuration": 18000,
"reproductionEnergy": 55,
"offspringCount": 2,
"maxPopulation": 10,
"spawnWeight": 0.4,
"preferredTiles": ["bare-rock", "gear-floor"]
},
{
"id": "mechanoid",
"name": "Mechanoid",
"nameRu": "Механоид",
"speciesId": 4,
"biome": "kinetic-mountains",
"description": "Half-living automaton. Can be repaired, reprogrammed, or scavenged for metal parts. Patrols ancient machinery and attacks intruders near gear mechanisms.",
"descriptionRu": "Полуживой автомат. Можно починить, перепрограммировать или разобрать на металлические детали. Патрулирует древние механизмы и атакует чужаков у шестерёнок.",
"color": "#aaaacc",
"radius": 11,
"radiusYouth": 7,
"health": 150,
"speed": 35,
"damage": 18,
"armor": 0.4,
"diet": "mineral",
"dietTiles": ["ore-deposit"],
"excretionElement": 29,
"energyMax": 120,
"energyPerFeed": 30,
"energyDrainPerSecond": 1.0,
"hungerThreshold": 0.3,
"aggressionRadius": 120,
"fleeRadius": 0,
"wanderRadius": 150,
"attackRange": 26,
"attackCooldown": 2200,
"eggDuration": 10000,
"youthDuration": 18000,
"matureDuration": 80000,
"agingDuration": 25000,
"reproductionEnergy": 70,
"offspringCount": 1,
"maxPopulation": 8,
"spawnWeight": 0.3,
"preferredTiles": ["gear-floor", "iron-ridge"]
},
{
"id": "resonator",
"name": "Resonator",
"nameRu": "Резонатор",
"speciesId": 5,
"biome": "kinetic-mountains",
"description": "Vibrates at fixed frequencies. Dangerous if its frequency matches your gear — resonance shatters equipment. Can be used to break barriers or stun other creatures.",
"descriptionRu": "Вибрирует на фиксированных частотах. Опасен, если его частота совпадает с вашим снаряжением — резонанс разрушает оборудование. Может ломать барьеры или оглушать существ.",
"color": "#ff88ff",
"radius": 7,
"radiusYouth": 4,
"health": 50,
"speed": 70,
"damage": 25,
"armor": 0.0,
"diet": "creature",
"dietTiles": [],
"excretionElement": 0,
"energyMax": 55,
"energyPerFeed": 25,
"energyDrainPerSecond": 2.8,
"hungerThreshold": 0.55,
"aggressionRadius": 130,
"fleeRadius": 70,
"wanderRadius": 280,
"attackRange": 20,
"attackCooldown": 1200,
"eggDuration": 5000,
"youthDuration": 10000,
"matureDuration": 40000,
"agingDuration": 13000,
"reproductionEnergy": 35,
"offspringCount": 1,
"maxPopulation": 6,
"spawnWeight": 0.3,
"preferredTiles": ["magnetic-field", "bare-rock"]
},
{
"id": "symbiote",
"name": "Symbiote",
"nameRu": "Симбионт",
"speciesId": 6,
"biome": "verdant-forests",
"description": "Always appears in bonded pairs. Separating them weakens both — one may die without the other. Feeds on forest floor decomposition. Peaceful but resilient in pairs.",
"descriptionRu": "Всегда появляется связанными парами. Разделение ослабляет обоих — один может погибнуть без другого. Питается лесной подстилкой. Мирные, но в паре стойкие.",
"color": "#44ddaa",
"radius": 8,
"radiusYouth": 5,
"health": 70,
"speed": 45,
"damage": 6,
"armor": 0.2,
"diet": "mineral",
"dietTiles": ["herb-patch", "mycelium-carpet"],
"excretionElement": 7,
"energyMax": 85,
"energyPerFeed": 20,
"energyDrainPerSecond": 1.6,
"hungerThreshold": 0.4,
"aggressionRadius": 0,
"fleeRadius": 90,
"wanderRadius": 180,
"attackRange": 18,
"attackCooldown": 2000,
"eggDuration": 7000,
"youthDuration": 13000,
"matureDuration": 50000,
"agingDuration": 17000,
"reproductionEnergy": 45,
"offspringCount": 2,
"maxPopulation": 14,
"spawnWeight": 0.4,
"preferredTiles": ["forest-floor", "dense-undergrowth"]
},
{
"id": "mimic",
"name": "Mimic",
"nameRu": "Мимикр",
"speciesId": 7,
"biome": "verdant-forests",
"description": "Master of disguise. Can appear as a plant, resource, or even a fungal node. Only reveals itself when approached too closely. Ambush predator with potent venom.",
"descriptionRu": "Мастер маскировки. Может выглядеть как растение, ресурс или грибной узел. Раскрывает себя только при близком приближении. Засадный хищник с мощным ядом.",
"color": "#33cc33",
"radius": 8,
"radiusYouth": 5,
"health": 65,
"speed": 55,
"damage": 22,
"armor": 0.05,
"diet": "creature",
"dietTiles": [],
"excretionElement": 0,
"energyMax": 65,
"energyPerFeed": 28,
"energyDrainPerSecond": 2.2,
"hungerThreshold": 0.5,
"aggressionRadius": 60,
"fleeRadius": 80,
"wanderRadius": 200,
"attackRange": 16,
"attackCooldown": 1400,
"eggDuration": 6000,
"youthDuration": 11000,
"matureDuration": 42000,
"agingDuration": 14000,
"reproductionEnergy": 42,
"offspringCount": 1,
"maxPopulation": 7,
"spawnWeight": 0.25,
"preferredTiles": ["forest-floor", "mycelium-carpet"]
},
{
"id": "spore-bearer",
"name": "Spore-bearer",
"nameRu": "Споровик",
"speciesId": 8,
"biome": "verdant-forests",
"description": "Fungal creature, avatar of the Mycelium. Peaceful and slow. If harmed, the entire forest turns hostile. Deposits knowledge into fungal nodes when passing near them.",
"descriptionRu": "Грибное существо, аватар Мицелия. Мирное и медленное. Если обидеть — весь лес становится враждебным. Откладывает знания в грибные узлы, проходя мимо них.",
"color": "#9966cc",
"radius": 12,
"radiusYouth": 7,
"health": 200,
"speed": 20,
"damage": 5,
"armor": 0.25,
"diet": "mineral",
"dietTiles": ["mycelium-carpet", "herb-patch"],
"excretionElement": 15,
"energyMax": 150,
"energyPerFeed": 35,
"energyDrainPerSecond": 0.8,
"hungerThreshold": 0.3,
"aggressionRadius": 0,
"fleeRadius": 60,
"wanderRadius": 160,
"attackRange": 24,
"attackCooldown": 3000,
"eggDuration": 12000,
"youthDuration": 20000,
"matureDuration": 90000,
"agingDuration": 30000,
"reproductionEnergy": 80,
"offspringCount": 1,
"maxPopulation": 5,
"spawnWeight": 0.35,
"preferredTiles": ["mycelium-carpet", "forest-floor"]
}
]

View File

@@ -0,0 +1,97 @@
{
"themes": {
"awakening": {
"name": "Awakening",
"nameRu": "Пробуждение",
"description": "You are learning the world. Everything is new, mysterious, dangerous.",
"descriptionRu": "Ты познаёшь мир. Всё ново, загадочно, опасно.",
"cradleQuote": "...the first breath of a new cycle...",
"cradleQuoteRu": "...первый вдох нового цикла...",
"loreFrag": [
{ "text": "The world breathes. You are its newest thought.", "textRu": "Мир дышит. Ты — его новейшая мысль." },
{ "text": "Each atom you touch remembers being touched before.", "textRu": "Каждый атом, которого ты касаешься, помнит прежние прикосновения." },
{ "text": "The cycle begins. As it always does. As it always will.", "textRu": "Цикл начинается. Как всегда. Как будет всегда." }
]
},
"doubt": {
"name": "Doubt",
"nameRu": "Сомнение",
"description": "You find traces of past Adepts. Were they... you?",
"descriptionRu": "Ты находишь следы прошлых Адептов. Были ли они... тобой?",
"cradleQuote": "...something stirs in the mycelium. Familiar...",
"cradleQuoteRu": "...что-то шевелится в мицелии. Знакомое...",
"loreFrag": [
{ "text": "Ruins that look like they were built by your hands.", "textRu": "Руины, словно построенные твоими руками." },
{ "text": "The handwriting in this journal... is it yours?", "textRu": "Почерк в этом дневнике... он твой?" },
{ "text": "I have been here before. Or someone exactly like me.", "textRu": "Я уже был здесь. Или кто-то точно такой же, как я." }
]
},
"realization": {
"name": "Realization",
"nameRu": "Осознание",
"description": "You begin to understand the nature of cycles.",
"descriptionRu": "Ты начинаешь понимать природу циклов.",
"cradleQuote": "...the pattern becomes visible...",
"cradleQuoteRu": "...паттерн становится видимым...",
"loreFrag": [
{ "text": "The cycle is not a prison. It is a spiral.", "textRu": "Цикл — не тюрьма. Это спираль." },
{ "text": "Each death is a transformation, not an ending.", "textRu": "Каждая смерть — трансформация, не конец." },
{ "text": "The Mycelium knew all along. It was waiting for you to see.", "textRu": "Мицелий знал с самого начала. Он ждал, пока ты увидишь." }
]
},
"attempt": {
"name": "Attempt",
"nameRu": "Попытка",
"description": "First attempts to transcend the cycle.",
"descriptionRu": "Первые попытки трансцендировать цикл.",
"cradleQuote": "...can the cycle be broken? Or only understood?...",
"cradleQuoteRu": "...можно ли разорвать цикл? Или только понять?...",
"loreFrag": [
{ "text": "The Synthetics tried to break free. They shattered reality instead.", "textRu": "Синтетики пытались вырваться. Вместо этого разбили реальность." },
{ "text": "To transcend is not to escape. It is to encompass.", "textRu": "Трансцендировать — не значит сбежать. Это значит объять." },
{ "text": "The harder you push against the cycle, the tighter it holds.", "textRu": "Чем сильнее давишь на цикл, тем крепче он держит." }
]
},
"acceptance": {
"name": "Acceptance",
"nameRu": "Принятие",
"description": "The cycle is not a prison but a form of being.",
"descriptionRu": "Цикл — не тюрьма, а форма бытия.",
"cradleQuote": "...you are not stuck IN the cycle. You ARE the cycle...",
"cradleQuoteRu": "...ты не застрял В цикле. Ты И ЕСТЬ цикл...",
"loreFrag": [
{ "text": "An atom does not resent its orbit. A wave does not fight its shore.", "textRu": "Атом не злится на свою орбиту. Волна не борется со своим берегом." },
{ "text": "The only way out is through. And through. And through.", "textRu": "Единственный выход — насквозь. И насквозь. И насквозь." },
{ "text": "Every cycle slightly different. Every cycle slightly closer.", "textRu": "Каждый цикл чуть другой. Каждый цикл чуть ближе." }
]
},
"synthesis": {
"name": "Synthesis",
"nameRu": "Синтез",
"description": "All knowledge unifies. The cycle does not break — it transcends.",
"descriptionRu": "Всё знание объединяется. Цикл не ломается — трансцендирует.",
"cradleQuote": "...when all fragments connect, something new emerges...",
"cradleQuoteRu": "...когда все фрагменты соединяются, рождается новое...",
"loreFrag": [
{ "text": "Synthesis is not creation. It is transformation.", "textRu": "Синтез — не создание. Это трансформация." },
{ "text": "You have become what the Synthetics could not: a living cycle.", "textRu": "Ты стал тем, чем Синтетики не смогли: живым циклом." },
{ "text": "The Law of Synthesis: when all fragments connect, the cycle transcends.", "textRu": "Закон Синтеза: когда все фрагменты знания соединяются, цикл трансцендирует." }
]
}
},
"renewalMessages": [
{ "text": "The Great Renewal washes over the world...", "textRu": "Великое Обновление окатывает мир..." },
{ "text": "Seven lives. Seven deaths. The cycle turns.", "textRu": "Семь жизней. Семь смертей. Цикл поворачивается." },
{ "text": "The Mycelium pulses. A new age begins.", "textRu": "Мицелий пульсирует. Начинается новая эпоха." }
],
"traceMessages": {
"death_site": [
{ "text": "Here an adept fell... elements returning to the earth.", "textRu": "Здесь пал адепт... элементы возвращаются в землю." },
{ "text": "A mark of dissolution. Someone ended their cycle here.", "textRu": "Знак растворения. Кто-то завершил свой цикл здесь." }
],
"discovery_site": [
{ "text": "Traces of experimentation linger in this place.", "textRu": "Следы экспериментов витают в этом месте." },
{ "text": "The soil remembers a reaction that happened here.", "textRu": "Почва помнит реакцию, произошедшую здесь." }
]
}
}

522
src/data/elements.json Normal file
View File

@@ -0,0 +1,522 @@
[
{
"symbol": "H",
"name": "Hydrogen",
"nameRu": "Водород",
"atomicNumber": 1,
"atomicMass": 1.008,
"electronegativity": 2.20,
"category": "nonmetal",
"state": "gas",
"color": "#ffffff",
"description": "Lightest element. Highly flammable gas. The most abundant element in the universe.",
"descriptionRu": "Легчайший элемент. Легковоспламеняемый газ. Самый распространённый элемент во вселенной."
},
{
"symbol": "He",
"name": "Helium",
"nameRu": "Гелий",
"atomicNumber": 2,
"atomicMass": 4.003,
"electronegativity": 0,
"category": "noble-gas",
"state": "gas",
"color": "#d9ffff",
"description": "Noble gas. Completely inert — refuses to react with anything. Lighter than air.",
"descriptionRu": "Благородный газ. Абсолютно инертен — не реагирует ни с чем. Легче воздуха."
},
{
"symbol": "Li",
"name": "Lithium",
"nameRu": "Литий",
"atomicNumber": 3,
"atomicMass": 6.941,
"electronegativity": 0.98,
"category": "alkali-metal",
"state": "solid",
"color": "#cc80ff",
"description": "Lightest metal. Soft enough to cut with a knife. Key component of modern batteries. Burns crimson red.",
"descriptionRu": "Легчайший металл. Настолько мягкий, что режется ножом. Ключевой компонент современных аккумуляторов. Горит малиновым пламенем."
},
{
"symbol": "B",
"name": "Boron",
"nameRu": "Бор",
"atomicNumber": 5,
"atomicMass": 10.811,
"electronegativity": 2.04,
"category": "metalloid",
"state": "solid",
"color": "#ffb5b5",
"description": "Hard metalloid. Essential for borosilicate glass (heat-resistant). Boron compounds are used as neutron absorbers.",
"descriptionRu": "Твёрдый металлоид. Основа боросиликатного стекла (термостойкого). Соединения бора поглощают нейтроны."
},
{
"symbol": "C",
"name": "Carbon",
"nameRu": "Углерод",
"atomicNumber": 6,
"atomicMass": 12.011,
"electronegativity": 2.55,
"category": "nonmetal",
"state": "solid",
"color": "#333333",
"description": "Basis of all organic chemistry. Forms more compounds than any other element. Burns in oxygen.",
"descriptionRu": "Основа всей органической химии. Образует больше соединений, чем любой другой элемент. Горит в кислороде."
},
{
"symbol": "N",
"name": "Nitrogen",
"nameRu": "Азот",
"atomicNumber": 7,
"atomicMass": 14.007,
"electronegativity": 3.04,
"category": "nonmetal",
"state": "gas",
"color": "#3050f8",
"description": "Makes up 78% of air. Very stable — triple bond is hard to break. Essential for life (proteins, DNA).",
"descriptionRu": "Составляет 78% воздуха. Очень стабилен — тройную связь трудно разорвать. Необходим для жизни (белки, ДНК)."
},
{
"symbol": "O",
"name": "Oxygen",
"nameRu": "Кислород",
"atomicNumber": 8,
"atomicMass": 15.999,
"electronegativity": 3.44,
"category": "nonmetal",
"state": "gas",
"color": "#ff0d0d",
"description": "Essential for combustion and respiration. Highly reactive oxidizer. 21% of air.",
"descriptionRu": "Необходим для горения и дыхания. Сильный окислитель. 21% воздуха."
},
{
"symbol": "F",
"name": "Fluorine",
"nameRu": "Фтор",
"atomicNumber": 9,
"atomicMass": 18.998,
"electronegativity": 3.98,
"category": "halogen",
"state": "gas",
"color": "#90e050",
"description": "Most reactive element in existence. Attacks almost everything, even glass. Handle with extreme caution.",
"descriptionRu": "Самый реактивный элемент в природе. Атакует почти всё, даже стекло. Обращаться с предельной осторожностью."
},
{
"symbol": "Ne",
"name": "Neon",
"nameRu": "Неон",
"atomicNumber": 10,
"atomicMass": 20.180,
"electronegativity": 0,
"category": "noble-gas",
"state": "gas",
"color": "#b3e3f5",
"description": "Noble gas. Produces iconic red-orange glow in discharge tubes. Completely inert.",
"descriptionRu": "Благородный газ. Даёт культовое красно-оранжевое свечение в газоразрядных трубках. Полностью инертен."
},
{
"symbol": "Na",
"name": "Sodium",
"nameRu": "Натрий",
"atomicNumber": 11,
"atomicMass": 22.990,
"electronegativity": 0.93,
"category": "alkali-metal",
"state": "solid",
"color": "#c8c8c8",
"description": "Soft alkali metal. Reacts violently with water — explosion and flame! Store away from moisture.",
"descriptionRu": "Мягкий щелочной металл. Бурно реагирует с водой — взрыв и пламя! Хранить вдали от влаги."
},
{
"symbol": "Mg",
"name": "Magnesium",
"nameRu": "Магний",
"atomicNumber": 12,
"atomicMass": 24.305,
"electronegativity": 1.31,
"category": "alkaline-earth",
"state": "solid",
"color": "#8aff00",
"description": "Burns with an intensely bright white flame. Used in flares and incendiary devices.",
"descriptionRu": "Горит ослепительно ярким белым пламенем. Используется в сигнальных ракетах и зажигательных устройствах."
},
{
"symbol": "Al",
"name": "Aluminum",
"nameRu": "Алюминий",
"atomicNumber": 13,
"atomicMass": 26.982,
"electronegativity": 1.61,
"category": "post-transition-metal",
"state": "solid",
"color": "#bfa6a6",
"description": "Light, strong metal. Oxide layer protects from corrosion. Key ingredient in thermite.",
"descriptionRu": "Лёгкий, прочный металл. Оксидная плёнка защищает от коррозии. Ключевой ингредиент термита."
},
{
"symbol": "Si",
"name": "Silicon",
"nameRu": "Кремний",
"atomicNumber": 14,
"atomicMass": 28.086,
"electronegativity": 1.90,
"category": "metalloid",
"state": "solid",
"color": "#f0c8a0",
"description": "Semiconductor. Basis of glass, ceramics, and electronics. Second most abundant in Earth's crust.",
"descriptionRu": "Полупроводник. Основа стекла, керамики и электроники. Второй по распространённости в земной коре."
},
{
"symbol": "P",
"name": "Phosphorus",
"nameRu": "Фосфор",
"atomicNumber": 15,
"atomicMass": 30.974,
"electronegativity": 2.19,
"category": "nonmetal",
"state": "solid",
"color": "#ff8000",
"description": "White phosphorus glows in the dark and ignites spontaneously. Essential for ATP (biological energy).",
"descriptionRu": "Белый фосфор светится в темноте и самовоспламеняется. Необходим для АТФ (биологическая энергия)."
},
{
"symbol": "S",
"name": "Sulfur",
"nameRu": "Сера",
"atomicNumber": 16,
"atomicMass": 32.065,
"electronegativity": 2.58,
"category": "nonmetal",
"state": "solid",
"color": "#ffff30",
"description": "Yellow solid with a distinctive smell. Burns with blue flame. Component of gunpowder.",
"descriptionRu": "Жёлтое твёрдое вещество с характерным запахом. Горит синим пламенем. Компонент пороха."
},
{
"symbol": "Cl",
"name": "Chlorine",
"nameRu": "Хлор",
"atomicNumber": 17,
"atomicMass": 35.453,
"electronegativity": 3.16,
"category": "halogen",
"state": "gas",
"color": "#1ff01f",
"description": "Toxic yellow-green gas. Powerful disinfectant. Combines readily with metals to form salts.",
"descriptionRu": "Ядовитый жёлто-зелёный газ. Мощный дезинфектант. Легко соединяется с металлами, образуя соли."
},
{
"symbol": "Ar",
"name": "Argon",
"nameRu": "Аргон",
"atomicNumber": 18,
"atomicMass": 39.948,
"electronegativity": 0,
"category": "noble-gas",
"state": "gas",
"color": "#80d1e3",
"description": "Third most abundant gas in atmosphere (0.93%). Used as shielding gas in welding. Completely inert.",
"descriptionRu": "Третий по распространённости газ в атмосфере (0,93%). Защитный газ при сварке. Полностью инертен."
},
{
"symbol": "K",
"name": "Potassium",
"nameRu": "Калий",
"atomicNumber": 19,
"atomicMass": 39.098,
"electronegativity": 0.82,
"category": "alkali-metal",
"state": "solid",
"color": "#8f40d4",
"description": "Even more reactive than sodium with water — violent purple-flame explosion. Essential nutrient for plants.",
"descriptionRu": "Ещё активнее натрия при контакте с водой — бурный взрыв с фиолетовым пламенем. Необходим растениям."
},
{
"symbol": "Ca",
"name": "Calcium",
"nameRu": "Кальций",
"atomicNumber": 20,
"atomicMass": 40.078,
"electronegativity": 1.00,
"category": "alkaline-earth",
"state": "solid",
"color": "#e0e0e0",
"description": "Essential for bones and shells. Reacts with water, but less violently than sodium. Component of limestone and cement.",
"descriptionRu": "Необходим для костей и раковин. Реагирует с водой, но менее бурно, чем натрий. Компонент известняка и цемента."
},
{
"symbol": "Ti",
"name": "Titanium",
"nameRu": "Титан",
"atomicNumber": 22,
"atomicMass": 47.867,
"electronegativity": 1.54,
"category": "transition-metal",
"state": "solid",
"color": "#bfc2c7",
"description": "Strong as steel but 45% lighter. Corrosion-resistant. Used in aerospace, implants, and armor.",
"descriptionRu": "Прочен как сталь, но на 45% легче. Коррозионностойкий. Применяется в авиации, имплантах и броне."
},
{
"symbol": "Cr",
"name": "Chromium",
"nameRu": "Хром",
"atomicNumber": 24,
"atomicMass": 51.996,
"electronegativity": 1.66,
"category": "transition-metal",
"state": "solid",
"color": "#8a99c7",
"description": "Hardest pure metal. Chrome plating resists corrosion. Stainless steel contains 10-20% chromium.",
"descriptionRu": "Самый твёрдый чистый металл. Хромирование защищает от коррозии. Нержавеющая сталь содержит 10-20% хрома."
},
{
"symbol": "Mn",
"name": "Manganese",
"nameRu": "Марганец",
"atomicNumber": 25,
"atomicMass": 54.938,
"electronegativity": 1.55,
"category": "transition-metal",
"state": "solid",
"color": "#9c7ac7",
"description": "Essential for steel production. MnO₂ is a natural catalyst and battery material. Biological enzyme cofactor.",
"descriptionRu": "Необходим для производства стали. MnO₂ — природный катализатор и материал батарей. Кофактор биологических ферментов."
},
{
"symbol": "Fe",
"name": "Iron",
"nameRu": "Железо",
"atomicNumber": 26,
"atomicMass": 55.845,
"electronegativity": 1.83,
"category": "transition-metal",
"state": "solid",
"color": "#a0a0a0",
"description": "Strong, abundant metal. Rusts in moist air. Core of Earth is mostly iron. Magnetic.",
"descriptionRu": "Прочный, распространённый металл. Ржавеет на влажном воздухе. Ядро Земли в основном из железа. Магнитен."
},
{
"symbol": "Co",
"name": "Cobalt",
"nameRu": "Кобальт",
"atomicNumber": 27,
"atomicMass": 58.933,
"electronegativity": 1.88,
"category": "transition-metal",
"state": "solid",
"color": "#f090a0",
"description": "Source of brilliant blue pigment since antiquity. Magnetic. Essential in vitamin B12.",
"descriptionRu": "Источник насыщенного синего пигмента с древности. Магнитен. Входит в состав витамина B12."
},
{
"symbol": "Ni",
"name": "Nickel",
"nameRu": "Никель",
"atomicNumber": 28,
"atomicMass": 58.693,
"electronegativity": 1.91,
"category": "transition-metal",
"state": "solid",
"color": "#50d050",
"description": "Corrosion-resistant metal. Key alloy component. Excellent catalyst for hydrogenation reactions.",
"descriptionRu": "Коррозионностойкий металл. Важный компонент сплавов. Превосходный катализатор гидрирования."
},
{
"symbol": "Cu",
"name": "Copper",
"nameRu": "Медь",
"atomicNumber": 29,
"atomicMass": 63.546,
"electronegativity": 1.90,
"category": "transition-metal",
"state": "solid",
"color": "#c88033",
"description": "Excellent conductor of electricity and heat. Turns green (patina) over time. Antibacterial properties.",
"descriptionRu": "Отличный проводник электричества и тепла. Зеленеет (патина) со временем. Антибактериальные свойства."
},
{
"symbol": "Zn",
"name": "Zinc",
"nameRu": "Цинк",
"atomicNumber": 30,
"atomicMass": 65.38,
"electronegativity": 1.65,
"category": "transition-metal",
"state": "solid",
"color": "#7d80b0",
"description": "Protects iron from rusting (galvanization). Zinc sulfide glows under UV light. Essential trace nutrient.",
"descriptionRu": "Защищает железо от ржавчины (гальванизация). Сульфид цинка светится в УФ-свете. Необходимый микроэлемент."
},
{
"symbol": "As",
"name": "Arsenic",
"nameRu": "Мышьяк",
"atomicNumber": 33,
"atomicMass": 74.922,
"electronegativity": 2.18,
"category": "metalloid",
"state": "solid",
"color": "#bd80e3",
"description": "Infamous poison. 'Inheritance powder' of the Borgias. Also a semiconductor used in LEDs and lasers.",
"descriptionRu": "Печально известный яд. «Порошок наследников» Борджиа. Также полупроводник для светодиодов и лазеров."
},
{
"symbol": "Br",
"name": "Bromine",
"nameRu": "Бром",
"atomicNumber": 35,
"atomicMass": 79.904,
"electronegativity": 2.96,
"category": "halogen",
"state": "liquid",
"color": "#a62929",
"description": "Only non-metallic element that is liquid at room temperature. Dark red, fuming, and corrosive.",
"descriptionRu": "Единственный неметалл, жидкий при комнатной температуре. Тёмно-красный, дымящий, едкий."
},
{
"symbol": "Ag",
"name": "Silver",
"nameRu": "Серебро",
"atomicNumber": 47,
"atomicMass": 107.868,
"electronegativity": 1.93,
"category": "transition-metal",
"state": "solid",
"color": "#c0c0c0",
"description": "Best electrical and thermal conductor of all metals. Natural antimicrobial. Tarnishes in sulfur-containing air.",
"descriptionRu": "Лучший проводник электричества и тепла среди металлов. Природный антисептик. Темнеет в воздухе с серой."
},
{
"symbol": "Sn",
"name": "Tin",
"nameRu": "Олово",
"atomicNumber": 50,
"atomicMass": 118.71,
"electronegativity": 1.96,
"category": "post-transition-metal",
"state": "solid",
"color": "#668080",
"description": "Soft, malleable metal. Resists corrosion. Used for solder and tin plating. Alloy with copper makes bronze.",
"descriptionRu": "Мягкий, ковкий металл. Устойчив к коррозии. Используется для пайки и лужения. Сплав с медью — бронза."
},
{
"symbol": "I",
"name": "Iodine",
"nameRu": "Йод",
"atomicNumber": 53,
"atomicMass": 126.904,
"electronegativity": 2.66,
"category": "halogen",
"state": "solid",
"color": "#940094",
"description": "Purple-black crystals that sublimate into violet vapor. Essential for thyroid function. Powerful disinfectant.",
"descriptionRu": "Пурпурно-чёрные кристаллы, сублимирующие в фиолетовый пар. Необходим для щитовидной железы. Мощный антисептик."
},
{
"symbol": "Ba",
"name": "Barium",
"nameRu": "Барий",
"atomicNumber": 56,
"atomicMass": 137.327,
"electronegativity": 0.89,
"category": "alkaline-earth",
"state": "solid",
"color": "#00c900",
"description": "Alkaline earth metal. Burns with bright green flame (fireworks!). BaSO₄ used in X-ray contrast imaging.",
"descriptionRu": "Щёлочноземельный металл. Горит ярко-зелёным пламенем (фейерверки!). BaSO₄ используется при рентгеновской диагностике."
},
{
"symbol": "W",
"name": "Tungsten",
"nameRu": "Вольфрам",
"atomicNumber": 74,
"atomicMass": 183.84,
"electronegativity": 2.36,
"category": "transition-metal",
"state": "solid",
"color": "#2194d6",
"description": "Highest melting point of any element (3422°C). Extremely hard and dense. Light bulb filaments.",
"descriptionRu": "Самая высокая температура плавления среди элементов (3422°C). Крайне твёрд и плотен. Нити накаливания ламп."
},
{
"symbol": "Pt",
"name": "Platinum",
"nameRu": "Платина",
"atomicNumber": 78,
"atomicMass": 195.084,
"electronegativity": 2.28,
"category": "transition-metal",
"state": "solid",
"color": "#d0d0e0",
"description": "Noble metal and supreme catalyst. Resists corrosion. Catalytic converters, lab equipment, jewelry.",
"descriptionRu": "Благородный металл и превосходный катализатор. Не поддаётся коррозии. Каталитические нейтрализаторы, лаборатории, ювелирные изделия."
},
{
"symbol": "Au",
"name": "Gold",
"nameRu": "Золото",
"atomicNumber": 79,
"atomicMass": 196.967,
"electronegativity": 2.54,
"category": "transition-metal",
"state": "solid",
"color": "#ffd123",
"description": "Extremely unreactive noble metal. Does not corrode or tarnish. Excellent conductor. Very rare.",
"descriptionRu": "Крайне инертный благородный металл. Не корродирует и не тускнеет. Отличный проводник. Очень редок."
},
{
"symbol": "Hg",
"name": "Mercury",
"nameRu": "Ртуть",
"atomicNumber": 80,
"atomicMass": 200.592,
"electronegativity": 2.00,
"category": "transition-metal",
"state": "liquid",
"color": "#b8b8d0",
"description": "Only metal that is liquid at room temperature. Extremely toxic — damages brain and kidneys. Handle with extreme care.",
"descriptionRu": "Единственный металл, жидкий при комнатной температуре. Крайне токсичен — поражает мозг и почки. Обращаться с предельной осторожностью."
},
{
"symbol": "Pb",
"name": "Lead",
"nameRu": "Свинец",
"atomicNumber": 82,
"atomicMass": 207.2,
"electronegativity": 2.33,
"category": "post-transition-metal",
"state": "solid",
"color": "#575961",
"description": "Dense, soft, toxic metal. Shields against radiation. Used in batteries. Cumulative neurotoxin.",
"descriptionRu": "Плотный, мягкий, токсичный металл. Экранирует от радиации. Используется в аккумуляторах. Кумулятивный нейротоксин."
},
{
"symbol": "Bi",
"name": "Bismuth",
"nameRu": "Висмут",
"atomicNumber": 83,
"atomicMass": 208.980,
"electronegativity": 2.02,
"category": "post-transition-metal",
"state": "solid",
"color": "#9e4fb5",
"description": "Least toxic heavy metal. Forms beautiful iridescent crystals. Used in Pepto-Bismol and cosmetics.",
"descriptionRu": "Наименее токсичный тяжёлый металл. Образует красивые радужные кристаллы. Применяется в медицине и косметике."
},
{
"symbol": "U",
"name": "Uranium",
"nameRu": "Уран",
"atomicNumber": 92,
"atomicMass": 238.029,
"electronegativity": 1.38,
"category": "actinide",
"state": "solid",
"color": "#008fff",
"description": "Radioactive element. Enormous energy density — 1 kg equals 3000 tonnes of coal. Powers nuclear reactors.",
"descriptionRu": "Радиоактивный элемент. Колоссальная плотность энергии — 1 кг равен 3000 тоннам угля. Питает ядерные реакторы."
}
]

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

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

1143
src/data/reactions.json Normal file

File diff suppressed because it is too large Load Diff

113
src/data/schools.json Normal file
View File

@@ -0,0 +1,113 @@
[
{
"id": "alchemist",
"name": "Alchemist",
"nameRu": "Алхимик",
"description": "Master of chemical transformations. Starts with reactive elements and knowledge of chemical equilibrium.",
"descriptionRu": "Мастер химических превращений. Начинает с реактивными элементами и знанием химического равновесия.",
"startingElements": ["H", "O", "C", "Na", "S", "Fe"],
"startingQuantities": {
"H": 5,
"O": 5,
"C": 3,
"Na": 3,
"S": 2,
"Fe": 2
},
"principle": "Chemical Equilibrium",
"principleRu": "Химическое равновесие",
"playstyle": "Potions, explosives, poisons. Combine elements for powerful chemical effects.",
"playstyleRu": "Зельеварение, взрывчатка, яды. Комбинируй элементы для мощных химических эффектов.",
"color": "#00ff88",
"bonuses": {
"reactionEfficiency": 1.25
}
},
{
"id": "mechanic",
"name": "Mechanic",
"nameRu": "Механик",
"description": "Builder of mechanisms and contraptions. Uses leverage and momentum to amplify force.",
"descriptionRu": "Строитель механизмов и устройств. Использует рычаг и момент силы для усиления воздействия.",
"startingElements": ["Fe", "Cu", "Sn", "Si", "C"],
"startingQuantities": {
"Fe": 5,
"Cu": 4,
"Sn": 3,
"Si": 4,
"C": 4
},
"principle": "Lever & Moment of Force",
"principleRu": "Рычаг и момент силы",
"playstyle": "Craft mechanisms, traps, automatons. Metals are your tools.",
"playstyleRu": "Крафт механизмов, ловушки, автоматоны. Металлы — твои инструменты.",
"color": "#ff8800",
"bonuses": {
"projectileDamage": 1.3
},
"unlockCondition": {
"type": "elements_discovered",
"threshold": 10,
"hint": "Discover 10 elements in the Codex",
"hintRu": "Открой 10 элементов в Кодексе"
}
},
{
"id": "naturalist",
"name": "Naturalist",
"nameRu": "Натуралист",
"description": "Observer of living systems. Understands photosynthesis and ecosystem balance.",
"descriptionRu": "Наблюдатель живых систем. Понимает фотосинтез и баланс экосистем.",
"startingElements": ["C", "N", "O", "P", "K"],
"startingQuantities": {
"C": 5,
"N": 4,
"O": 5,
"P": 3,
"K": 3
},
"principle": "Photosynthesis",
"principleRu": "Фотосинтез",
"playstyle": "Taming, cultivation, ecosystem control. Work with nature, not against it.",
"playstyleRu": "Приручение, выращивание, экосистемный контроль. Работай с природой, а не против неё.",
"color": "#44cc44",
"bonuses": {
"creatureAggroRange": 0.6
},
"unlockCondition": {
"type": "creatures_discovered",
"threshold": 3,
"hint": "Discover 3 creature species",
"hintRu": "Открой 3 вида существ"
}
},
{
"id": "navigator",
"name": "Navigator",
"nameRu": "Навигатор",
"description": "Master of angles and distances. Charts the unknown with precision and moves with unmatched speed.",
"descriptionRu": "Мастер углов и расстояний. Исследует неизведанное с точностью и двигается с непревзойдённой скоростью.",
"startingElements": ["Si", "Fe", "C", "H", "O"],
"startingQuantities": {
"Si": 4,
"Fe": 3,
"C": 4,
"H": 5,
"O": 4
},
"principle": "Angular Measurement",
"principleRu": "Угловое измерение",
"playstyle": "Cartography, scouting, mobility. See further, move faster.",
"playstyleRu": "Картография, разведка, мобильность. Видь дальше, двигайся быстрее.",
"color": "#4488ff",
"bonuses": {
"movementSpeed": 1.2
},
"unlockCondition": {
"type": "runs_completed",
"threshold": 3,
"hint": "Complete 3 runs",
"hintRu": "Заверши 3 рана"
}
}
]

85
src/ecs/bridge.ts Normal file
View File

@@ -0,0 +1,85 @@
import Phaser from 'phaser';
import { query } from 'bitecs';
import type { World } from './world';
import { Position, SpriteRef } from './components';
/**
* Phaser ↔ bitECS sync bridge
*
* Manages Phaser GameObjects based on ECS entity state:
* - Creates circles for new entities with Position + SpriteRef
* - Destroys circles for entities that no longer exist
* - Syncs ECS Position → Phaser sprite coordinates every frame
*/
export class PhaserBridge {
private scene: Phaser.Scene;
private spriteMap = new Map<number, Phaser.GameObjects.Arc>();
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
/**
* Sync ECS state to Phaser — call once per frame
* Handles creation, destruction, and position updates
*/
sync(world: World): void {
const entities = query(world, [Position, SpriteRef]);
const activeSet = new Set<number>();
for (const eid of entities) {
activeSet.add(eid);
}
// Remove sprites for entities that no longer exist
const toRemove: number[] = [];
for (const eid of this.spriteMap.keys()) {
if (!activeSet.has(eid)) {
toRemove.push(eid);
}
}
for (const eid of toRemove) {
const sprite = this.spriteMap.get(eid);
if (sprite) {
sprite.destroy();
}
this.spriteMap.delete(eid);
}
// Create sprites for new entities + update positions for all
for (const eid of entities) {
let sprite = this.spriteMap.get(eid);
if (!sprite) {
sprite = this.scene.add.circle(
Position.x[eid],
Position.y[eid],
SpriteRef.radius[eid],
SpriteRef.color[eid],
);
this.spriteMap.set(eid, sprite);
}
sprite.x = Position.x[eid];
sprite.y = Position.y[eid];
}
}
/** Get the Phaser sprite for an entity (undefined if not yet rendered) */
getSprite(eid: number): Phaser.GameObjects.Arc | undefined {
return this.spriteMap.get(eid);
}
/** Current number of rendered entities */
get entityCount(): number {
return this.spriteMap.size;
}
/** Clean up all sprites */
destroy(): void {
for (const sprite of this.spriteMap.values()) {
sprite.destroy();
}
this.spriteMap.clear();
}
}

119
src/ecs/components.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* ECS Components — plain objects with number arrays (bitECS 0.4 pattern)
*
* Components define the data schema for entities.
* Systems read/write component data.
* Bridge syncs component data to Phaser rendering.
*/
/** World position in pixels */
export const Position = {
x: [] as number[],
y: [] as number[],
};
/** Movement velocity in pixels per second */
export const Velocity = {
vx: [] as number[],
vy: [] as number[],
};
/** Visual representation — used by bridge to create/update Phaser objects */
export const SpriteRef = {
color: [] as number[], // hex color (e.g. 0x00ff88)
radius: [] as number[], // circle radius in pixels
};
/** Entity health — damage, healing, death */
export const Health = {
current: [] as number[],
max: [] as number[],
};
/** Link to chemistry system — stores atomic number of primary element */
export const ChemicalComposition = {
primaryElement: [] as number[], // atomic number (e.g. 11 for Na)
};
/** Tag component — marks entity as the player (no data, identity only) */
export const PlayerTag = {
_tag: [] as number[],
};
/** Harvestable resource — mineral veins, geysers, etc. */
export const Resource = {
quantity: [] as number[], // remaining items to collect
interactRange: [] as number[], // max interaction distance in pixels
};
/** Thrown element/compound projectile */
export const Projectile = {
lifetime: [] as number[], // remaining lifetime in ms (removed at 0)
};
// ─── Creature Components ─────────────────────────────────────────
/** Tag + species identifier for creatures */
export const Creature = {
speciesId: [] as number[], // SpeciesId enum value (0=Crystallid, 1=Acidophile, 2=Reagent)
};
/** AI finite state machine */
export const AI = {
state: [] as number[], // AIState enum value
stateTimer: [] as number[], // ms remaining in current state
targetEid: [] as number[], // entity being pursued/fled (-1 = none)
homeX: [] as number[], // spawn/home position X
homeY: [] as number[], // spawn/home position Y
attackCooldown: [] as number[], // ms until next attack allowed
};
/** Creature metabolism — energy, hunger, feeding */
export const Metabolism = {
energy: [] as number[], // current energy (0 = starving)
energyMax: [] as number[], // maximum energy capacity
drainRate: [] as number[], // energy lost per second
feedAmount: [] as number[], // energy gained per feeding action
hungerThreshold: [] as number[], // fraction (0-1) below which creature seeks food
};
/** Life cycle stage tracking */
export const LifeCycle = {
stage: [] as number[], // LifeStage enum value (0=egg, 1=youth, 2=mature, 3=aging)
stageTimer: [] as number[], // ms remaining in current stage
age: [] as number[], // total age in ms
};
// ─── Boss Components ─────────────────────────────────────────────
/** Boss entity tag + phase info for bridge rendering */
export const Boss = {
dataIndex: [] as number[], // index in bosses.json array
phase: [] as number[], // BossPhase enum value (synced from BossState)
cycleCount: [] as number[], // completed cycles (synced from BossState)
};
// ─── World Trace Components (Great Cycle) ────────────────────────
/** A trace left by a previous run — ruin, death marker, discovery site */
export const WorldTrace = {
traceType: [] as number[], // TraceType enum (0=death_site, 1=discovery_site)
sourceRunId: [] as number[], // which run left this trace
glowPhase: [] as number[], // animation phase for pulsing glow
interactRange: [] as number[], // interaction range in pixels
};
/** TraceType enum values */
export const TraceType = {
DeathSite: 0,
DiscoverySite: 1,
} as const;
// ─── 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
};

62
src/ecs/factory.ts Normal file
View File

@@ -0,0 +1,62 @@
import { addEntity, addComponent, removeEntity } from 'bitecs';
import type { World } from './world';
import {
Position,
Velocity,
Health,
SpriteRef,
ChemicalComposition,
} from './components';
/** Configuration for creating a game entity */
export interface EntityConfig {
position?: { x: number; y: number };
velocity?: { vx: number; vy: number };
health?: { current: number; max: number };
sprite?: { color: number; radius: number };
chemicalElement?: number;
}
/**
* Create a game entity with specified components
* @returns entity ID (eid)
*/
export function createGameEntity(world: World, config: EntityConfig): number {
const eid = addEntity(world);
if (config.position !== undefined) {
addComponent(world, eid, Position);
Position.x[eid] = config.position.x;
Position.y[eid] = config.position.y;
}
if (config.velocity !== undefined) {
addComponent(world, eid, Velocity);
Velocity.vx[eid] = config.velocity.vx;
Velocity.vy[eid] = config.velocity.vy;
}
if (config.health !== undefined) {
addComponent(world, eid, Health);
Health.current[eid] = config.health.current;
Health.max[eid] = config.health.max;
}
if (config.sprite !== undefined) {
addComponent(world, eid, SpriteRef);
SpriteRef.color[eid] = config.sprite.color;
SpriteRef.radius[eid] = config.sprite.radius;
}
if (config.chemicalElement !== undefined) {
addComponent(world, eid, ChemicalComposition);
ChemicalComposition.primaryElement[eid] = config.chemicalElement;
}
return eid;
}
/** Remove a game entity and all its components from the world */
export function removeGameEntity(world: World, eid: number): void {
removeEntity(world, eid);
}

30
src/ecs/systems/health.ts Normal file
View File

@@ -0,0 +1,30 @@
import { query } from 'bitecs';
import type { World } from '../world';
import { Health } from '../components';
/**
* Health system — detects entities with health ≤ 0
* @returns array of entity IDs that should be removed (dead)
*/
export function healthSystem(world: World): number[] {
const deadEntities: number[] = [];
for (const eid of query(world, [Health])) {
if (Health.current[eid] <= 0) {
deadEntities.push(eid);
}
}
return deadEntities;
}
/** Apply damage to entity — reduces current health */
export function applyDamage(eid: number, amount: number): void {
Health.current[eid] -= amount;
}
/** Apply healing to entity — increases current health, capped at max */
export function applyHealing(eid: number, amount: number): void {
Health.current[eid] = Math.min(
Health.current[eid] + amount,
Health.max[eid],
);
}

View File

@@ -0,0 +1,39 @@
import { query } from 'bitecs';
import type { World } from '../world';
import { Position, Velocity } from '../components';
/**
* Movement system — updates Position by Velocity * delta
* Velocities are in pixels/second, delta is in milliseconds
*/
export function movementSystem(world: World, deltaMs: number): void {
const dt = deltaMs / 1000;
for (const eid of query(world, [Position, Velocity])) {
Position.x[eid] += Velocity.vx[eid] * dt;
Position.y[eid] += Velocity.vy[eid] * dt;
}
}
/**
* Bounce system — reverses velocity when entity hits screen bounds
* Ensures velocity always points away from boundary
*/
export function bounceSystem(world: World, width: number, height: number): void {
for (const eid of query(world, [Position, Velocity])) {
if (Position.x[eid] < 0) {
Velocity.vx[eid] = Math.abs(Velocity.vx[eid]);
Position.x[eid] = 0;
} else if (Position.x[eid] > width) {
Velocity.vx[eid] = -Math.abs(Velocity.vx[eid]);
Position.x[eid] = width;
}
if (Position.y[eid] < 0) {
Velocity.vy[eid] = Math.abs(Velocity.vy[eid]);
Position.y[eid] = 0;
} else if (Position.y[eid] > height) {
Velocity.vy[eid] = -Math.abs(Velocity.vy[eid]);
Position.y[eid] = height;
}
}
}

39
src/ecs/world.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createWorld } from 'bitecs';
/** bitECS world type */
export type World = ReturnType<typeof createWorld>;
/** Time tracking for game loop */
export interface GameTime {
/** Milliseconds since last frame */
delta: number;
/** Total milliseconds elapsed */
elapsed: number;
/** Frame counter */
tick: number;
}
/** Game world = bitECS world + time tracking */
export interface GameWorld {
world: World;
time: GameTime;
}
/** Create a new game world with zeroed time */
export function createGameWorld(): GameWorld {
return {
world: createWorld(),
time: {
delta: 0,
elapsed: 0,
tick: 0,
},
};
}
/** Update time tracking — call once per frame with Phaser's delta (ms) */
export function updateTime(gameWorld: GameWorld, deltaMs: number): void {
gameWorld.time.delta = deltaMs;
gameWorld.time.elapsed += deltaMs;
gameWorld.time.tick += 1;
}

View File

@@ -1,4 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { gameConfig } from './config'; import { gameConfig } from './config';
new Phaser.Game(gameConfig); const game = new Phaser.Game(gameConfig);
// Expose for debug console access
(window as unknown as Record<string, unknown>).__game = game;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

128
src/player/collision.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Tile Collision System
*
* Checks player position against tile grid after movement.
* Supports wall-sliding: if full movement is blocked, tries
* X-only or Y-only to let player slide along walls.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Velocity, PlayerTag } from '../ecs/components';
import type { TileGrid } from '../world/types';
import { PLAYER_COLLISION_RADIUS } from './types';
/**
* Check if a pixel position falls on a walkable tile.
* Returns false for out-of-bounds positions.
*/
export function isTileWalkable(
px: number,
py: number,
grid: TileGrid,
tileSize: number,
walkable: Set<number>,
): boolean {
const tx = Math.floor(px / tileSize);
const ty = Math.floor(py / tileSize);
if (ty < 0 || ty >= grid.length || tx < 0 || tx >= (grid[0]?.length ?? 0)) {
return false;
}
return walkable.has(grid[ty][tx]);
}
/**
* Check if a circular area (4 corners of bounding box) is on walkable tiles.
* Uses AABB corners, not true circle — good enough for small radii.
*/
export function isAreaWalkable(
x: number,
y: number,
radius: number,
grid: TileGrid,
tileSize: number,
walkable: Set<number>,
): boolean {
return (
isTileWalkable(x - radius, y - radius, grid, tileSize, walkable) &&
isTileWalkable(x + radius, y - radius, grid, tileSize, walkable) &&
isTileWalkable(x - radius, y + radius, grid, tileSize, walkable) &&
isTileWalkable(x + radius, y + radius, grid, tileSize, walkable)
);
}
/**
* Resolve position after movement with wall-sliding.
*
* Priority: full move → X-slide → Y-slide → stay put.
* This gives natural wall-sliding behavior.
*/
export function resolveCollision(
newX: number,
newY: number,
prevX: number,
prevY: number,
radius: number,
grid: TileGrid,
tileSize: number,
walkable: Set<number>,
): { x: number; y: number } {
// Full movement valid
if (isAreaWalkable(newX, newY, radius, grid, tileSize, walkable)) {
return { x: newX, y: newY };
}
// X-only: slide along Y wall
if (isAreaWalkable(newX, prevY, radius, grid, tileSize, walkable)) {
return { x: newX, y: prevY };
}
// Y-only: slide along X wall
if (isAreaWalkable(prevX, newY, radius, grid, tileSize, walkable)) {
return { x: prevX, y: newY };
}
// Can't move at all
return { x: prevX, y: prevY };
}
/**
* Build a Set of walkable tile IDs from tile data.
* Used to quickly check if a tile allows movement.
*/
export function buildWalkableSet(
tiles: ReadonlyArray<{ id: number; walkable: boolean }>,
): Set<number> {
const set = new Set<number>();
for (const tile of tiles) {
if (tile.walkable) set.add(tile.id);
}
return set;
}
/**
* ECS system: resolve tile collisions for player entities.
*
* Runs AFTER movementSystem. Reconstructs pre-movement position
* from current position and velocity, then resolves collision.
*/
export function tileCollisionSystem(
world: World,
deltaMs: number,
grid: TileGrid,
tileSize: number,
walkable: Set<number>,
): void {
const dt = deltaMs / 1000;
for (const eid of query(world, [Position, Velocity, PlayerTag])) {
const curX = Position.x[eid];
const curY = Position.y[eid];
// Reconstruct pre-movement position (movementSystem did: pos += vel * dt)
const prevX = curX - Velocity.vx[eid] * dt;
const prevY = curY - Velocity.vy[eid] * dt;
const resolved = resolveCollision(
curX, curY, prevX, prevY,
PLAYER_COLLISION_RADIUS, grid, tileSize, walkable,
);
Position.x[eid] = resolved.x;
Position.y[eid] = resolved.y;
}
}

102
src/player/crafting.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Crafting System — Chemistry Engine Integration
*
* Combines items from inventory using the reaction engine.
* On success: consumes reagents, adds products.
* On failure: returns educational explanation of why it didn't work.
*/
import { ReactionEngine } from '../chemistry/engine';
import type { ReactionConditions, ReactionData } from '../chemistry/types';
import type { Inventory } from './inventory';
/** What the player wants to combine */
export interface CraftInput {
id: string; // element symbol or compound id
count: number; // quantity to use
}
/** Result of a crafting attempt */
export interface CraftResult {
success: boolean;
/** Products added to inventory (count may differ from reaction output if inventory full) */
products?: Array<{ id: string; count: number }>;
/** What was consumed from inventory */
consumedReactants?: CraftInput[];
/** Reaction data (for codex, description display) */
reaction?: ReactionData;
/** Why it failed (English) */
failureReason?: string;
/** Why it failed (Russian) */
failureReasonRu?: string;
}
/**
* Attempt to craft from inventory items.
*
* 1. Checks inventory has all reagents
* 2. Runs reaction engine (O(1) lookup + condition check)
* 3. On success: consumes reagents, adds products
* 4. On failure: returns educational reason
*
* Reagents are only consumed on success — failed attempts are free.
*/
export function craftFromInventory(
inventory: Inventory,
reactants: CraftInput[],
conditions?: Partial<ReactionConditions>,
): CraftResult {
// Empty input
if (reactants.length === 0) {
return {
success: false,
failureReason: 'Select at least one substance to combine.',
failureReasonRu: 'Выберите хотя бы одно вещество для комбинирования.',
};
}
// 1. Check inventory has all reagents
for (const r of reactants) {
const have = inventory.getCount(r.id);
if (have < r.count) {
return {
success: false,
failureReason: `Not enough ${r.id} — have ${have}, need ${r.count}.`,
failureReasonRu: `Недостаточно ${r.id} — есть ${have}, нужно ${r.count}.`,
};
}
}
// 2. Run reaction engine
const result = ReactionEngine.react(
reactants.map(r => ({ id: r.id, count: r.count })),
conditions,
);
if (!result.success) {
return {
success: false,
failureReason: result.failureReason,
failureReasonRu: result.failureReasonRu,
};
}
// 3. Consume reagents
for (const r of reactants) {
inventory.removeItem(r.id, r.count);
}
// 4. Add products to inventory
const addedProducts: Array<{ id: string; count: number }> = [];
for (const p of result.products!) {
const added = inventory.addItem(p.id, p.count);
addedProducts.push({ id: p.id, count: added });
}
return {
success: true,
products: addedProducts,
consumedReactants: reactants,
reaction: result.reaction,
};
}

31
src/player/factory.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Player Entity Factory
*
* Creates the player entity with all required components:
* Position, Velocity, SpriteRef, Health, PlayerTag.
*/
import { addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import { PlayerTag } from '../ecs/components';
import { createGameEntity } from '../ecs/factory';
import { PLAYER_COLOR, PLAYER_RADIUS, PLAYER_HEALTH } from './types';
/**
* Create the player entity at the given position.
* @returns entity ID (eid)
*/
export function createPlayerEntity(
world: World,
x: number,
y: number,
): number {
const eid = createGameEntity(world, {
position: { x, y },
velocity: { vx: 0, vy: 0 },
health: { current: PLAYER_HEALTH, max: PLAYER_HEALTH },
sprite: { color: PLAYER_COLOR, radius: PLAYER_RADIUS },
});
addComponent(world, eid, PlayerTag);
return eid;
}

50
src/player/input.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Player Input System
*
* Reads InputState → sets player entity Velocity.
* Diagonal movement is normalized to prevent faster diagonal speed.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import { Velocity, PlayerTag } from '../ecs/components';
import type { InputState } from './types';
import { PLAYER_SPEED } from './types';
/**
* Calculate velocity vector from input state (pure function).
* Diagonal movement is normalized so magnitude = speed.
*/
export function calculatePlayerVelocity(
input: InputState,
speed: number,
): { vx: number; vy: number } {
const { moveX, moveY } = input;
if (moveX === 0 && moveY === 0) {
return { vx: 0, vy: 0 };
}
// Normalize diagonal so total speed stays constant
const isDiagonal = moveX !== 0 && moveY !== 0;
const factor = isDiagonal ? 1 / Math.SQRT2 : 1;
return {
vx: moveX * speed * factor,
vy: moveY * speed * factor,
};
}
/**
* ECS system: set player entity velocity from input.
* Only affects entities with PlayerTag + Velocity.
*
* @param speedMultiplier — optional multiplier on base PLAYER_SPEED (default 1.0)
*/
export function playerInputSystem(world: World, input: InputState, speedMultiplier = 1.0): void {
const vel = calculatePlayerVelocity(input, PLAYER_SPEED * speedMultiplier);
for (const eid of query(world, [Velocity, PlayerTag])) {
Velocity.vx[eid] = vel.vx;
Velocity.vy[eid] = vel.vy;
}
}

114
src/player/interaction.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Resource Interaction System — Phase 4.3
*
* Handles player collecting elements from world resources
* (mineral veins, geysers). Press E near a resource to collect.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Resource, PlayerTag } from '../ecs/components';
import { removeGameEntity } from '../ecs/factory';
import type { Inventory } from './inventory';
/** Metadata for a resource entity (string data that can't go in bitECS arrays) */
export interface ResourceInfo {
itemId: string; // element symbol or compound id
tileX: number;
tileY: number;
}
/** Result of an interaction attempt */
export interface InteractionResult {
type: 'collected' | 'depleted' | 'inventory_full' | 'nothing_nearby';
itemId?: string;
remaining?: number;
}
/** Elements that mineral veins can yield */
export const MINERAL_ELEMENTS = ['Fe', 'Cu', 'Zn', 'Au', 'Sn'] as const;
/** Elements that geysers can yield */
export const GEYSER_ELEMENTS = ['S', 'H'] as const;
/**
* Deterministic element picker based on tile position and seed.
* Same (x, y, seed) always gives the same element.
*/
export function pickResourceElement(
tileX: number,
tileY: number,
seed: number,
options: readonly string[],
): string {
// Multiplicative hash for spatial distribution
const hash = ((tileX * 73856093) ^ (tileY * 19349663) ^ (seed * 83492791)) >>> 0;
return options[hash % options.length];
}
/**
* Interaction system — handles E-key resource collection.
*
* Finds closest resource in range, adds to inventory, decrements quantity.
* Returns null if E not pressed, or InteractionResult describing what happened.
*/
export function interactionSystem(
world: World,
justPressedInteract: boolean,
inventory: Inventory,
resourceData: Map<number, ResourceInfo>,
): InteractionResult | null {
if (!justPressedInteract) return null;
// Find player position
const players = query(world, [Position, PlayerTag]);
if (players.length === 0) return null;
const playerEid = players[0];
const px = Position.x[playerEid];
const py = Position.y[playerEid];
// Find closest resource in range
let closestEid: number | null = null;
let closestDist = Infinity;
for (const eid of query(world, [Position, Resource])) {
const dx = Position.x[eid] - px;
const dy = Position.y[eid] - py;
const dist = Math.sqrt(dx * dx + dy * dy);
const range = Resource.interactRange[eid];
if (dist <= range && dist < closestDist) {
closestEid = eid;
closestDist = dist;
}
}
if (closestEid === null) {
return { type: 'nothing_nearby' };
}
const info = resourceData.get(closestEid);
if (!info) return { type: 'nothing_nearby' };
// Try to add to inventory
const added = inventory.addItem(info.itemId, 1);
if (added === 0) {
return { type: 'inventory_full', itemId: info.itemId };
}
// Decrement resource quantity
Resource.quantity[closestEid] -= 1;
if (Resource.quantity[closestEid] <= 0) {
// Resource depleted — remove entity
removeGameEntity(world, closestEid);
resourceData.delete(closestEid);
return { type: 'depleted', itemId: info.itemId };
}
return {
type: 'collected',
itemId: info.itemId,
remaining: Resource.quantity[closestEid],
};
}

149
src/player/inventory.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* Player Inventory — Weight-Based with Element Stacking
*
* Items are elements (by symbol) or compounds (by id).
* Weight = real atomic/molecular mass.
* Same items stack automatically (count increases).
*/
import { ElementRegistry } from '../chemistry/elements';
import { CompoundRegistry } from '../chemistry/compounds';
/** A single inventory entry */
export interface InventoryItem {
readonly id: string;
readonly count: number;
}
/**
* Get the mass of an item (element or compound).
* Returns atomic mass for elements, molecular mass for compounds, 0 for unknown.
*/
export function getItemMass(id: string): number {
const el = ElementRegistry.getBySymbol(id);
if (el) return el.atomicMass;
const comp = CompoundRegistry.getById(id);
if (comp) return comp.mass;
return 0;
}
/**
* Weight-based inventory with slot limits and auto-stacking.
*
* Weight uses real atomic/molecular masses (AMU).
* Same items stack into a single slot.
*/
export class Inventory {
private counts = new Map<string, number>();
/** Maximum total weight (AMU) */
readonly maxWeight: number;
/** Maximum number of unique item types (slots) */
readonly maxSlots: number;
constructor(maxWeight = 500, maxSlots = 20) {
this.maxWeight = maxWeight;
this.maxSlots = maxSlots;
}
/**
* Add items to inventory.
* @returns actual count added (may be less if weight limit reached, 0 if impossible)
*/
addItem(id: string, count = 1): number {
if (count <= 0) return 0;
const mass = getItemMass(id);
if (mass <= 0) return 0;
// New item needs an available slot
const isNewItem = !this.counts.has(id);
if (isNewItem && this.counts.size >= this.maxSlots) return 0;
// Calculate how many can fit by weight
const currentWeight = this.getTotalWeight();
const spaceLeft = this.maxWeight - currentWeight;
const maxByWeight = Math.floor(spaceLeft / mass);
const actualAdd = Math.min(count, maxByWeight);
if (actualAdd <= 0) return 0;
const existing = this.counts.get(id) ?? 0;
this.counts.set(id, existing + actualAdd);
return actualAdd;
}
/**
* Remove items from inventory.
* @returns actual count removed (may be less if not enough in stock)
*/
removeItem(id: string, count = 1): number {
if (count <= 0) return 0;
const current = this.counts.get(id) ?? 0;
if (current <= 0) return 0;
const actualRemove = Math.min(count, current);
const newCount = current - actualRemove;
if (newCount <= 0) {
this.counts.delete(id);
} else {
this.counts.set(id, newCount);
}
return actualRemove;
}
/** Get item count (0 if not in inventory) */
getCount(id: string): number {
return this.counts.get(id) ?? 0;
}
/** Check if inventory has at least `count` of an item */
hasItem(id: string, count = 1): boolean {
return (this.counts.get(id) ?? 0) >= count;
}
/** Get all items as array of { id, count } */
getItems(): InventoryItem[] {
const items: InventoryItem[] = [];
for (const [id, count] of this.counts) {
items.push({ id, count });
}
return items;
}
/** Total weight of all items (sum of mass * count) */
getTotalWeight(): number {
let total = 0;
for (const [id, count] of this.counts) {
total += getItemMass(id) * count;
}
return total;
}
/** Weight of a single item stack */
getItemWeight(id: string): number {
const count = this.counts.get(id) ?? 0;
return getItemMass(id) * count;
}
/** Number of unique item types (occupied slots) */
get slotCount(): number {
return this.counts.size;
}
/** Whether inventory has zero items */
isEmpty(): boolean {
return this.counts.size === 0;
}
/** Remove all items */
clear(): void {
this.counts.clear();
}
}

141
src/player/projectile.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* Projectile System — Throw Elements & Compounds
*
* Player launches projectiles toward mouse cursor.
* Projectiles travel in a straight line, expire after a lifetime,
* and are destroyed on hitting non-walkable tiles.
*/
import { addEntity, addComponent, query } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Velocity, SpriteRef, Projectile } from '../ecs/components';
import { removeGameEntity } from '../ecs/factory';
import { isTileWalkable } from './collision';
import { ElementRegistry } from '../chemistry/elements';
import { CompoundRegistry } from '../chemistry/compounds';
import type { TileGrid } from '../world/types';
/** Extra data for a projectile entity (string data) */
export interface ProjectileData {
itemId: string; // element symbol or compound id
}
// === Constants ===
/** Projectile flight speed in pixels per second */
export const PROJECTILE_SPEED = 350;
/** Projectile lifetime in milliseconds */
export const PROJECTILE_LIFETIME = 2000;
/** Projectile visual radius */
export const PROJECTILE_RADIUS = 5;
// === Direction ===
/**
* Calculate normalized direction from source to target.
* Returns (1, 0) if source == target (default: right).
*/
export function calculateDirection(
fromX: number, fromY: number,
toX: number, toY: number,
): { dx: number; dy: number } {
const dx = toX - fromX;
const dy = toY - fromY;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 0.001) {
return { dx: 1, dy: 0 }; // default direction
}
return { dx: dx / len, dy: dy / len };
}
// === Launch ===
/**
* Get the display color for a chemical item.
* Uses element color from registry, or compound color, or default white.
*/
function getItemColor(itemId: string): number {
const el = ElementRegistry.getBySymbol(itemId);
if (el) return parseInt(el.color.replace('#', ''), 16);
const comp = CompoundRegistry.getById(itemId);
if (comp) return parseInt(comp.color.replace('#', ''), 16);
return 0xffffff;
}
/**
* Create a projectile entity flying from (fromX, fromY) toward (toX, toY).
* @returns entity ID
*/
export function launchProjectile(
world: World,
projData: Map<number, ProjectileData>,
fromX: number,
fromY: number,
toX: number,
toY: number,
itemId: string,
): number {
const dir = calculateDirection(fromX, fromY, toX, toY);
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, SpriteRef);
addComponent(world, eid, Projectile);
Position.x[eid] = fromX;
Position.y[eid] = fromY;
Velocity.vx[eid] = dir.dx * PROJECTILE_SPEED;
Velocity.vy[eid] = dir.dy * PROJECTILE_SPEED;
SpriteRef.color[eid] = getItemColor(itemId);
SpriteRef.radius[eid] = PROJECTILE_RADIUS;
Projectile.lifetime[eid] = PROJECTILE_LIFETIME;
projData.set(eid, { itemId });
return eid;
}
// === System ===
/**
* Update projectiles: decrement lifetime, remove expired/collided.
* Runs AFTER movementSystem (projectiles moved by generic movement).
*/
export function projectileSystem(
world: World,
deltaMs: number,
grid: TileGrid,
tileSize: number,
walkable: Set<number>,
projData: Map<number, ProjectileData>,
): void {
const toRemove: number[] = [];
for (const eid of query(world, [Position, Projectile])) {
// Decrement lifetime
Projectile.lifetime[eid] -= deltaMs;
// Check expiry
if (Projectile.lifetime[eid] <= 0) {
toRemove.push(eid);
continue;
}
// Check tile collision (point check at projectile center)
if (!isTileWalkable(Position.x[eid], Position.y[eid], grid, tileSize, walkable)) {
toRemove.push(eid);
}
}
for (const eid of toRemove) {
removeGameEntity(world, eid);
projData.delete(eid);
}
}

66
src/player/quickslots.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Quick Slots — 4 Hotkey Slots for Fast Item Access
*
* Keys 1-4 select active slot. Active slot determines
* what gets thrown (F) or used. Items are referenced
* by id from the inventory.
*/
/** Number of quick slots */
export const SLOT_COUNT = 4;
/**
* Quick slots manager.
* Stores item ids assigned to 4 slots (keys 1-4).
* Active slot determines current "equipped" item.
*/
export class QuickSlots {
private slots: (string | null)[] = new Array(SLOT_COUNT).fill(null);
/** Currently active slot index (0-3) */
activeIndex = 0;
/** Assign an item id to a slot (null to clear) */
assign(index: number, itemId: string | null): void {
if (index < 0 || index >= SLOT_COUNT) return;
this.slots[index] = itemId;
}
/** Get item id in a slot (null if empty) */
getSlot(index: number): string | null {
if (index < 0 || index >= SLOT_COUNT) return null;
return this.slots[index];
}
/** Get item id in the active slot */
getActive(): string | null {
return this.slots[this.activeIndex];
}
/** Set active slot index (clamped to valid range) */
setActive(index: number): void {
this.activeIndex = Math.max(0, Math.min(SLOT_COUNT - 1, index));
}
/** Get all slot contents as array */
getAll(): (string | null)[] {
return [...this.slots];
}
/**
* Auto-assign an item to the first empty slot.
* Returns slot index, or -1 if no empty slot or item already assigned.
*/
autoAssign(itemId: string): number {
// Don't duplicate
if (this.slots.includes(itemId)) return -1;
for (let i = 0; i < SLOT_COUNT; i++) {
if (this.slots[i] === null) {
this.slots[i] = itemId;
return i;
}
}
return -1;
}
}

51
src/player/spawn.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Player Spawn Position
*
* Finds a walkable tile near the center of the map.
* Spirals outward from center to find the nearest valid spawn.
*/
import type { TileGrid } from '../world/types';
/**
* Find a walkable spawn position, starting from map center.
* Returns pixel coordinates (center of tile) or null if no walkable tile exists.
*/
export function findSpawnPosition(
grid: TileGrid,
tileSize: number,
walkable: Set<number>,
): { x: number; y: number } | null {
const height = grid.length;
if (height === 0) return null;
const width = grid[0].length;
if (width === 0) return null;
const centerY = Math.floor(height / 2);
const centerX = Math.floor(width / 2);
// Spiral outward from center
const maxRadius = Math.max(width, height);
for (let r = 0; r <= maxRadius; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
// Only check perimeter of ring (interior was checked in smaller radius)
if (r > 0 && Math.abs(dx) < r && Math.abs(dy) < r) continue;
const ty = centerY + dy;
const tx = centerX + dx;
if (ty >= 0 && ty < height && tx >= 0 && tx < width) {
if (walkable.has(grid[ty][tx])) {
return {
x: tx * tileSize + tileSize / 2,
y: ty * tileSize + tileSize / 2,
};
}
}
}
}
}
return null;
}

33
src/player/types.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Player Types & Constants
*
* InputState represents the current frame's player input.
* Constants define player attributes (speed, size, health, color).
*/
/** Keyboard/gamepad state for current frame */
export interface InputState {
/** Horizontal axis: -1 (left), 0 (none), +1 (right) */
moveX: number;
/** Vertical axis: -1 (up), 0 (none), +1 (down) */
moveY: number;
/** Interact key (E) pressed */
interact: boolean;
}
// === Player Constants ===
/** Movement speed in pixels per second */
export const PLAYER_SPEED = 150;
/** Collision radius in pixels (smaller than visual for forgiving feel) */
export const PLAYER_COLLISION_RADIUS = 6;
/** Starting and maximum health */
export const PLAYER_HEALTH = 100;
/** Visual color (bright cyan — stands out on dark biome) */
export const PLAYER_COLOR = 0x00e5ff;
/** Visual radius in pixels */
export const PLAYER_RADIUS = 10;

105
src/run/crisis.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* Crisis System — Chemical Plague
*
* When escalation reaches its threshold, a crisis activates.
* The Chemical Plague poisons the atmosphere — player must craft
* the correct neutralizing compound to stop it.
*/
import type { CrisisConfig } from './types';
// ─── Chemical Plague Configuration ───────────────────────────────
export const CHEMICAL_PLAGUE: CrisisConfig = {
type: 'chemical-plague',
name: 'Chemical Plague',
nameRu: 'Химическая Чума',
description: 'A chain reaction is poisoning the atmosphere. Neutralize it with the right compound!',
descriptionRu: 'Цепная реакция отравляет атмосферу. Нейтрализуй правильным соединением!',
triggerThreshold: 0.8,
neutralizer: 'CaO', // Calcium oxide (quicklime) — absorbs acid gases
neutralizeAmount: 3, // need 3 units to fully neutralize
};
// ─── Crisis State ────────────────────────────────────────────────
export interface CrisisState {
config: CrisisConfig;
active: boolean;
/** Progress 0.01.0 — at 1.0 the world is fully poisoned */
progress: number;
resolved: boolean;
/** How many neutralizer units have been applied */
neutralizeApplied: number;
}
/** Damage growth rate per second during active crisis */
const CRISIS_DAMAGE_RATE = 0.01; // reaches 1.0 in ~100 seconds
/** Create a new crisis state from config */
export function createCrisisState(config: CrisisConfig): CrisisState {
return {
config,
active: true,
progress: 0,
resolved: false,
neutralizeApplied: 0,
};
}
/** Advance crisis damage over time */
export function applyCrisisDamage(crisis: CrisisState, deltaMs: number): void {
if (!crisis.active || crisis.resolved) return;
const deltaSec = deltaMs / 1000;
crisis.progress = Math.min(1.0, crisis.progress + CRISIS_DAMAGE_RATE * deltaSec);
}
/** Attempt to neutralize the crisis with a compound. Returns true if correct compound. */
export function attemptNeutralize(
crisis: CrisisState,
compoundId: string,
amount: number,
): boolean {
if (compoundId !== crisis.config.neutralizer) return false;
if (crisis.resolved) return true;
crisis.neutralizeApplied += amount;
// Reduce progress proportionally
const reductionPerUnit = 1.0 / crisis.config.neutralizeAmount;
crisis.progress = Math.max(0, crisis.progress - amount * reductionPerUnit);
// Check if fully neutralized
if (crisis.neutralizeApplied >= crisis.config.neutralizeAmount) {
crisis.resolved = true;
crisis.active = false;
crisis.progress = 0;
}
return true;
}
/** Check if crisis has been resolved */
export function isCrisisResolved(crisis: CrisisState): boolean {
return crisis.resolved;
}
/** Calculate player damage per frame from active crisis */
export function getCrisisPlayerDamage(crisis: CrisisState, deltaMs: number): number {
if (crisis.resolved || crisis.progress <= 0.3) return 0;
const deltaSec = deltaMs / 1000;
// Damage scales with how far above 30% threshold
return (crisis.progress - 0.3) * 0.7 * deltaSec;
}
/** Get visual tint for crisis overlay */
export function getCrisisTint(crisis: CrisisState): { color: number; alpha: number } {
if (crisis.resolved || crisis.progress <= 0) {
return { color: 0x88ff88, alpha: 0 };
}
// Green toxic tint that intensifies with progress
return {
color: 0x88ff88,
alpha: Math.min(0.15, crisis.progress * 0.15),
};
}

220
src/run/cycle.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Great Cycle System — 7-run macro cycles with traces between runs
*
* GDD spec (Section IV):
* - Every 7 runs = 1 Great Cycle
* - After 7th run → Great Renewal: world changes, lore unlocks, Mycelium matures
* - Previous 7 runs leave concrete traces: ruins, consequences, mutated creatures
* - Each cycle has a narrative theme (Awakening → Doubt → Realization → ...)
*
* Three Laws of Arcana:
* 1. Law of Return — everything that was, will be again, but never exactly the same
* 2. Law of Trace — nothing disappears without a trace
* 3. Law of Synthesis — when all knowledge fragments connect, the cycle transcends
*/
import type {
GreatCycleState,
RunTrace,
RunState,
MetaState,
CycleTheme,
CycleWorldModifiers,
} from './types';
import { CYCLE_THEMES, RUNS_PER_CYCLE, RunPhase } from './types';
import { countDiscoveries } from './state';
// ─── Initialization ──────────────────────────────────────────────
/** Create initial great cycle state (first time playing) */
export function createGreatCycleState(): GreatCycleState {
return {
cycleNumber: 1,
runInCycle: 1,
theme: 'awakening',
currentCycleTraces: [],
previousCycleTraces: [],
renewalsCompleted: 0,
myceliumMaturation: 0,
};
}
// ─── Theme Resolution ────────────────────────────────────────────
/** Get the cycle theme for a given cycle number (1-based) */
export function getCycleTheme(cycleNumber: number): CycleTheme {
if (cycleNumber < 1) return 'awakening';
const idx = Math.min(cycleNumber - 1, CYCLE_THEMES.length - 1);
return CYCLE_THEMES[idx];
}
// ─── Run Trace Recording ─────────────────────────────────────────
/**
* Create a RunTrace from a completed run state.
* Called when a run ends (death or boss victory).
*/
export function createRunTrace(
run: RunState,
cycleState: GreatCycleState,
): RunTrace {
// Extract up to 5 key element discoveries for trace markers
const keyElements = Array.from(run.discoveries.elements).slice(0, 5);
return {
runId: run.runId,
runInCycle: cycleState.runInCycle,
schoolId: run.schoolId,
biomeId: run.biomeId,
deathPosition: run.deathPosition,
phaseReached: run.phase,
crisisResolved: run.crisisResolved,
discoveryCount: countDiscoveries(run),
keyElements,
duration: run.elapsed,
worldSeed: run.worldSeed,
};
}
/**
* Record a completed run's trace and advance the cycle.
* Returns true if this run triggers a Great Renewal (every 7th run).
*/
export function recordRunAndAdvanceCycle(
meta: MetaState,
run: RunState,
): boolean {
const cycle = meta.greatCycle;
const trace = createRunTrace(run, cycle);
// Add trace to current cycle
cycle.currentCycleTraces.push(trace);
// Check if this completes a great cycle
const isRenewal = cycle.runInCycle >= RUNS_PER_CYCLE;
if (isRenewal) {
// Great Renewal!
performGreatRenewal(meta);
} else {
// Advance to next run within the cycle
cycle.runInCycle += 1;
}
return isRenewal;
}
// ─── Great Renewal ───────────────────────────────────────────────
/**
* Perform the Great Renewal — transitions between great cycles.
*
* GDD spec:
* - World generation fundamentally changes
* - New lore layer unlocks
* - Mycelium "matures" — opens new capabilities
* - Previous 7 runs' traces become "previous cycle" traces
*/
export function performGreatRenewal(meta: MetaState): void {
const cycle = meta.greatCycle;
// Move current traces to previous (only keep last cycle's traces)
cycle.previousCycleTraces = [...cycle.currentCycleTraces];
cycle.currentCycleTraces = [];
// Advance cycle number
cycle.cycleNumber += 1;
cycle.runInCycle = 1;
cycle.theme = getCycleTheme(cycle.cycleNumber);
cycle.renewalsCompleted += 1;
// Mycelium maturation: each renewal strengthens the network
cycle.myceliumMaturation = Math.min(
cycle.myceliumMaturation + 0.15,
1.0,
);
// Strengthen all Mycelium nodes on renewal
strengthenMyceliumOnRenewal(meta);
}
/**
* Strengthen Mycelium nodes during Great Renewal.
* All nodes gain strength proportional to the maturation level.
*/
function strengthenMyceliumOnRenewal(meta: MetaState): void {
const bonus = 0.1 + meta.greatCycle.myceliumMaturation * 0.1;
for (const node of meta.mycelium.nodes) {
node.strength = Math.min(1.0, node.strength + bonus);
}
}
// ─── World Modifiers ─────────────────────────────────────────────
/**
* Get world generation modifiers based on cycle number.
* Higher cycles = more varied/challenging worlds.
*
* GDD: "Fundamentally changes generation (new biome types, different proportions)"
*/
export function getCycleWorldModifiers(cycleNumber: number): CycleWorldModifiers {
// Cycle 1 = baseline, each subsequent cycle adds variation
const t = Math.min((cycleNumber - 1) / 5, 1.0); // 0→1 over 5 cycles
return {
elevationScaleMultiplier: 1.0 + t * 0.3, // terrain becomes more varied
detailScaleMultiplier: 1.0 + t * 0.2, // more detail features
resourceDensityMultiplier: 1.0 + t * 0.15, // slightly more resources
creatureSpawnMultiplier: 1.0 + t * 0.25, // more creatures
escalationRateMultiplier: 1.0 + t * 0.2, // faster escalation
};
}
// ─── Trace Queries ───────────────────────────────────────────────
/**
* Get all traces from the current and previous cycle for a given biome.
* Used by world generation to place ruins/markers.
*/
export function getTracesForBiome(
cycle: GreatCycleState,
biomeId: string,
): RunTrace[] {
const currentBiome = cycle.currentCycleTraces.filter(t => t.biomeId === biomeId);
const previousBiome = cycle.previousCycleTraces.filter(t => t.biomeId === biomeId);
return [...currentBiome, ...previousBiome];
}
/**
* Get traces that left death markers (for ruin placement).
* Returns traces with valid death positions.
*/
export function getDeathTraces(traces: RunTrace[]): RunTrace[] {
return traces.filter(t => t.deathPosition !== null);
}
/**
* Check if the current run is the last in the cycle (7th).
*/
export function isLastRunInCycle(cycle: GreatCycleState): boolean {
return cycle.runInCycle >= RUNS_PER_CYCLE;
}
/**
* Get a human-readable cycle summary for display.
*/
export function getCycleSummary(cycle: GreatCycleState): {
cycleNumber: number;
runInCycle: number;
totalRuns: number;
theme: CycleTheme;
isLastRun: boolean;
} {
return {
cycleNumber: cycle.cycleNumber,
runInCycle: cycle.runInCycle,
totalRuns: RUNS_PER_CYCLE,
theme: cycle.theme,
isLastRun: isLastRunInCycle(cycle),
};
}

48
src/run/escalation.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Escalation Effects — how rising entropy changes the game world.
*
* GDD spec:
* "Entropy grows. Temperature fluctuates harder, chemical reactions become
* more unstable, creatures more aggressive, NPCs more paranoid."
*
* Escalation (0.0 → 1.0) modifies:
* - Creature speed, aggro range, attack damage
* - Reaction instability (chance of unexpected side effects)
* - Environmental damage (toxic atmosphere)
*/
/** Multipliers and modifiers based on escalation level */
export interface EscalationEffects {
/** Creature movement speed multiplier (1.0 → 1.5) */
creatureSpeedMultiplier: number;
/** Creature aggro detection range multiplier (1.0 → 1.8) */
creatureAggroRange: number;
/** Creature attack damage multiplier (1.0 → 1.6) */
creatureAttackMultiplier: number;
/** Reaction instability: chance 00.3 of unexpected side effects */
reactionInstability: number;
/** Environmental damage per second at current escalation */
environmentalDamage: number;
}
/**
* Calculate escalation effects from escalation level (0.01.0).
* All effects scale linearly from neutral to maximum.
*/
export function getEscalationEffects(escalation: number): EscalationEffects {
// Clamp to [0, 1]
const t = Math.max(0, Math.min(1, escalation));
return {
creatureSpeedMultiplier: lerp(1.0, 1.5, t),
creatureAggroRange: lerp(1.0, 1.8, t),
creatureAttackMultiplier: lerp(1.0, 1.6, t),
reactionInstability: lerp(0, 0.3, t),
environmentalDamage: t > 0.6 ? lerp(0, 0.5, (t - 0.6) / 0.4) : 0,
};
}
/** Linear interpolation */
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

179
src/run/meta.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Meta-Progression — persists between runs.
*
* Manages the Codex (permanent knowledge), spores (currency),
* unlocked schools, run history, and school unlock system.
*/
import type { MetaState, CodexEntry, RunSummary, SchoolData, ResolvedSchoolBonuses } from './types';
import type { RunState } from './types';
import { DEFAULT_SCHOOL_BONUSES } from './types';
import { calculateSpores, countDiscoveries } from './state';
import { createGreatCycleState, recordRunAndAdvanceCycle } from './cycle';
import schoolsData from '../data/schools.json';
/** Create a fresh meta state (first time playing) */
export function createMetaState(): MetaState {
return {
spores: 0,
codex: [],
totalRuns: 0,
totalDeaths: 0,
unlockedSchools: ['alchemist'],
bestRunTime: 0,
bestRunDiscoveries: 0,
runHistory: [],
mycelium: { nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0 },
greatCycle: createGreatCycleState(),
};
}
/**
* Apply run results to meta state after a run ends (death or completion).
* Returns true if a Great Renewal was triggered.
*/
export function applyRunResults(meta: MetaState, run: RunState): boolean {
// 1. Calculate and add spores
const sporesEarned = calculateSpores(run);
meta.spores += sporesEarned;
// 2. Add new codex entries (skip duplicates)
const existingIds = new Set(meta.codex.map(e => `${e.type}:${e.id}`));
for (const elemId of run.discoveries.elements) {
const key = `element:${elemId}`;
if (!existingIds.has(key)) {
meta.codex.push({ id: elemId, type: 'element', discoveredOnRun: run.runId });
}
}
for (const reactionId of run.discoveries.reactions) {
const key = `reaction:${reactionId}`;
if (!existingIds.has(key)) {
meta.codex.push({ id: reactionId, type: 'reaction', discoveredOnRun: run.runId });
}
}
for (const compoundId of run.discoveries.compounds) {
const key = `compound:${compoundId}`;
if (!existingIds.has(key)) {
meta.codex.push({ id: compoundId, type: 'compound', discoveredOnRun: run.runId });
}
}
for (const creatureId of run.discoveries.creatures) {
const key = `creature:${creatureId}`;
if (!existingIds.has(key)) {
meta.codex.push({ id: creatureId, type: 'creature', discoveredOnRun: run.runId });
}
}
// 3. Update stats
meta.totalRuns += 1;
meta.totalDeaths += 1; // every run ends in death (for now)
const discoveryCount = countDiscoveries(run);
if (run.elapsed > meta.bestRunTime) {
meta.bestRunTime = run.elapsed;
}
if (discoveryCount > meta.bestRunDiscoveries) {
meta.bestRunDiscoveries = discoveryCount;
}
// 4. Add to run history (with cycle info)
const summary: RunSummary = {
runId: run.runId,
schoolId: run.schoolId,
duration: run.elapsed,
phase: run.phase,
discoveries: discoveryCount,
sporesEarned,
crisisResolved: run.crisisResolved,
biomeId: run.biomeId,
cycleNumber: meta.greatCycle.cycleNumber,
};
meta.runHistory.push(summary);
// 5. Record run trace and advance the great cycle
const isRenewal = recordRunAndAdvanceCycle(meta, run);
// 6. Check for school unlocks after updating codex and stats
checkSchoolUnlocks(meta);
return isRenewal;
}
/** Check if a school is unlocked */
export function isSchoolUnlocked(meta: MetaState, schoolId: string): boolean {
return meta.unlockedSchools.includes(schoolId);
}
/** Get codex entries filtered by type */
export function getCodexEntries(
meta: MetaState,
type: CodexEntry['type'],
): CodexEntry[] {
return meta.codex.filter(e => e.type === type);
}
/** Get total codex entry count */
export function getCodexCount(meta: MetaState): number {
return meta.codex.length;
}
// ─── School Unlock System ────────────────────────────────────────
const schools = schoolsData as unknown as SchoolData[];
/**
* Check all school unlock conditions against current meta state.
* Newly unlocked schools are added to meta.unlockedSchools.
*/
export function checkSchoolUnlocks(meta: MetaState): void {
for (const school of schools) {
// Skip already unlocked
if (meta.unlockedSchools.includes(school.id)) continue;
// Schools without condition are always available (e.g., alchemist)
if (!school.unlockCondition) {
meta.unlockedSchools.push(school.id);
continue;
}
const condition = school.unlockCondition;
let met = false;
switch (condition.type) {
case 'elements_discovered': {
const count = meta.codex.filter(e => e.type === 'element').length;
met = count >= condition.threshold;
break;
}
case 'creatures_discovered': {
const count = meta.codex.filter(e => e.type === 'creature').length;
met = count >= condition.threshold;
break;
}
case 'runs_completed': {
met = meta.totalRuns >= condition.threshold;
break;
}
}
if (met) {
meta.unlockedSchools.push(school.id);
}
}
}
// ─── School Bonuses ──────────────────────────────────────────────
/**
* Resolve school bonuses into a complete multiplier set.
* Unknown school ID returns all-default (1.0) bonuses.
*/
export function getSchoolBonuses(schoolId: string): ResolvedSchoolBonuses {
const school = schools.find(s => s.id === schoolId);
if (!school) return { ...DEFAULT_SCHOOL_BONUSES };
return {
...DEFAULT_SCHOOL_BONUSES,
...school.bonuses,
};
}

133
src/run/persistence.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Persistence — IndexedDB storage for meta-progression.
*
* Saves/loads MetaState between browser sessions.
* Uses a simple key-value pattern with a single object store.
*/
import type { MetaState, CodexEntry, RunSummary, MyceliumGraphData, GreatCycleState } from './types';
import { createMetaState } from './meta';
import { createGreatCycleState } from './cycle';
const DB_NAME = 'synthesis-meta';
const DB_VERSION = 1;
const STORE_NAME = 'meta';
const META_KEY = 'metaState';
/** Serializable version of MetaState (no Sets, plain objects) */
interface SerializedMetaState {
spores: number;
codex: CodexEntry[];
totalRuns: number;
totalDeaths: number;
unlockedSchools: string[];
bestRunTime: number;
bestRunDiscoveries: number;
runHistory: RunSummary[];
mycelium?: MyceliumGraphData;
greatCycle?: GreatCycleState;
}
/** Open (or create) the IndexedDB database */
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** Serialize MetaState to plain JSON-safe object */
function serialize(meta: MetaState): SerializedMetaState {
return {
spores: meta.spores,
codex: [...meta.codex],
totalRuns: meta.totalRuns,
totalDeaths: meta.totalDeaths,
unlockedSchools: [...meta.unlockedSchools],
bestRunTime: meta.bestRunTime,
bestRunDiscoveries: meta.bestRunDiscoveries,
runHistory: [...meta.runHistory],
mycelium: meta.mycelium,
greatCycle: meta.greatCycle,
};
}
/** Default empty mycelium graph */
const EMPTY_MYCELIUM: MyceliumGraphData = {
nodes: [], edges: [], totalDeposits: 0, totalExtractions: 0,
};
/** Deserialize from plain object back to MetaState */
function deserialize(data: SerializedMetaState): MetaState {
return {
spores: data.spores ?? 0,
codex: data.codex ?? [],
totalRuns: data.totalRuns ?? 0,
totalDeaths: data.totalDeaths ?? 0,
unlockedSchools: data.unlockedSchools ?? ['alchemist'],
bestRunTime: data.bestRunTime ?? 0,
bestRunDiscoveries: data.bestRunDiscoveries ?? 0,
runHistory: data.runHistory ?? [],
mycelium: data.mycelium ?? EMPTY_MYCELIUM,
greatCycle: data.greatCycle ?? createGreatCycleState(),
};
}
/** Save meta state to IndexedDB */
export async function saveMetaState(meta: MetaState): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.put(serialize(meta), META_KEY);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
tx.oncomplete = () => db.close();
});
}
/** Load meta state from IndexedDB. Returns fresh state if nothing saved. */
export async function loadMetaState(): Promise<MetaState> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(META_KEY);
request.onsuccess = () => {
const data = request.result as SerializedMetaState | undefined;
resolve(data ? deserialize(data) : createMetaState());
};
request.onerror = () => reject(request.error);
tx.oncomplete = () => db.close();
});
} catch {
// If IndexedDB is unavailable, return fresh state
return createMetaState();
}
}
/** Clear all saved data (for debug/reset) */
export async function clearMetaState(): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(META_KEY);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
tx.oncomplete = () => db.close();
});
}

101
src/run/state.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Run State Management — creates and updates the state of a single run.
*
* A "run" is one cycle: Awakening → Exploration → Escalation → Crisis → Resolution.
* Each run tracks discoveries, escalation level, and crisis status.
*/
import {
type RunState,
type RunDiscoveries,
RunPhase,
ESCALATION_RATE,
SPORE_REWARDS,
} from './types';
/** Create a fresh run state for a new run */
export function createRunState(
runId: number,
schoolId: string,
biomeId: string = 'catalytic-wastes',
worldSeed: number = Date.now(),
): RunState {
return {
runId,
schoolId,
biomeId,
phase: RunPhase.Awakening,
phaseTimer: 0,
elapsed: 0,
escalation: 0,
crisisActive: false,
crisisResolved: false,
discoveries: {
elements: new Set<string>(),
reactions: new Set<string>(),
compounds: new Set<string>(),
creatures: new Set<string>(),
},
alive: true,
worldSeed,
deathPosition: null,
};
}
/** Advance to the next phase. Does nothing if already at Resolution. */
export function advancePhase(state: RunState): void {
if (state.phase < RunPhase.Resolution) {
state.phase = (state.phase + 1) as RunPhase;
state.phaseTimer = 0;
}
}
/** Update escalation level based on elapsed delta (ms). Only active during Escalation and Crisis. */
export function updateEscalation(state: RunState, deltaMs: number): void {
if (state.phase < RunPhase.Escalation) return;
if (state.phase > RunPhase.Crisis) return;
const deltaSec = deltaMs / 1000;
state.escalation = Math.min(1.0, state.escalation + ESCALATION_RATE * deltaSec);
}
/** Discovery type names used in API (singular) */
export type DiscoveryType = 'element' | 'reaction' | 'compound' | 'creature';
/** Map singular type to plural key in RunDiscoveries */
const DISCOVERY_KEY: Record<DiscoveryType, keyof RunDiscoveries> = {
element: 'elements',
reaction: 'reactions',
compound: 'compounds',
creature: 'creatures',
};
/** Record a discovery during the current run */
export function recordDiscovery(
state: RunState,
type: DiscoveryType,
id: string,
): void {
state.discoveries[DISCOVERY_KEY[type]].add(id);
}
/** Count total unique discoveries in a run */
export function countDiscoveries(state: RunState): number {
return (
state.discoveries.elements.size +
state.discoveries.reactions.size +
state.discoveries.compounds.size +
state.discoveries.creatures.size
);
}
/** Calculate spores earned from a run */
export function calculateSpores(state: RunState): number {
let total = 0;
total += state.discoveries.elements.size * SPORE_REWARDS.element;
total += state.discoveries.reactions.size * SPORE_REWARDS.reaction;
total += state.discoveries.compounds.size * SPORE_REWARDS.compound;
total += state.discoveries.creatures.size * SPORE_REWARDS.creature;
if (state.crisisResolved) total += SPORE_REWARDS.crisisResolved;
return total;
}

370
src/run/types.ts Normal file
View File

@@ -0,0 +1,370 @@
/**
* Run Cycle Types — schools, run phases, meta-progression
*
* Defines the structure of a single run (birth → death → rebirth)
* and the persistent meta-progression between runs.
*/
// ─── Schools ─────────────────────────────────────────────────────
export interface SchoolData {
id: string;
name: string;
nameRu: string;
description: string;
descriptionRu: string;
/** Starting element symbols (looked up in element registry) */
startingElements: string[];
/** Quantity of each starting element */
startingQuantities: Record<string, number>;
/** Scientific principle the school teaches */
principle: string;
principleRu: string;
/** Play style description */
playstyle: string;
playstyleRu: string;
/** Hex color for UI representation */
color: string;
/** Passive gameplay bonuses from this school's principle */
bonuses: Record<string, number>;
/** Condition to unlock this school (absent = always unlocked) */
unlockCondition?: SchoolUnlockCondition;
}
/** Condition that must be met to unlock a school */
export interface SchoolUnlockCondition {
/** What metric to check */
type: 'elements_discovered' | 'creatures_discovered' | 'runs_completed';
/** How many needed */
threshold: number;
/** Hint text (English) shown when locked */
hint: string;
/** Hint text (Russian) shown when locked */
hintRu: string;
}
/** Resolved school bonuses with all multipliers filled in (defaults = 1.0) */
export interface ResolvedSchoolBonuses {
/** Projectile damage multiplier */
projectileDamage: number;
/** Player movement speed multiplier */
movementSpeed: number;
/** Creature aggro/flee range multiplier (< 1 = less aggro) */
creatureAggroRange: number;
/** Reaction efficiency multiplier */
reactionEfficiency: number;
}
/** Default bonuses (no school effect) */
export const DEFAULT_SCHOOL_BONUSES: ResolvedSchoolBonuses = {
projectileDamage: 1.0,
movementSpeed: 1.0,
creatureAggroRange: 1.0,
reactionEfficiency: 1.0,
};
// ─── Run Phases ──────────────────────────────────────────────────
/** Phases of a single run in order */
export enum RunPhase {
Awakening = 0,
Exploration = 1,
Escalation = 2,
Crisis = 3,
Resolution = 4,
}
/** Human-readable names for RunPhase */
export const RUN_PHASE_NAMES: Record<RunPhase, string> = {
[RunPhase.Awakening]: 'Awakening',
[RunPhase.Exploration]: 'Exploration',
[RunPhase.Escalation]: 'Escalation',
[RunPhase.Crisis]: 'Crisis',
[RunPhase.Resolution]: 'Resolution',
};
export const RUN_PHASE_NAMES_RU: Record<RunPhase, string> = {
[RunPhase.Awakening]: 'Пробуждение',
[RunPhase.Exploration]: 'Исследование',
[RunPhase.Escalation]: 'Эскалация',
[RunPhase.Crisis]: 'Кризис',
[RunPhase.Resolution]: 'Развязка',
};
// ─── Crisis Types ────────────────────────────────────────────────
export type CrisisType = 'chemical-plague';
export interface CrisisConfig {
type: CrisisType;
name: string;
nameRu: string;
description: string;
descriptionRu: string;
/** Escalation threshold (0-1) at which crisis triggers */
triggerThreshold: number;
/** Compound that neutralizes the crisis */
neutralizer: string;
/** Amount needed to neutralize */
neutralizeAmount: number;
}
// ─── Run State ───────────────────────────────────────────────────
/** State of the current run */
export interface RunState {
/** Unique run identifier */
runId: number;
/** Selected school id */
schoolId: string;
/** Biome being explored this run */
biomeId: string;
/** Current phase */
phase: RunPhase;
/** Time spent in current phase (ms) */
phaseTimer: number;
/** Total run elapsed time (ms) */
elapsed: number;
/** Escalation level 0.0 1.0 */
escalation: number;
/** Whether crisis has been triggered */
crisisActive: boolean;
/** Whether crisis has been resolved */
crisisResolved: boolean;
/** Discoveries made this run */
discoveries: RunDiscoveries;
/** Whether the player is alive */
alive: boolean;
/** World seed for this run */
worldSeed: number;
/** Player death position in tile coords (set on death) */
deathPosition: { tileX: number; tileY: number } | null;
}
export interface RunDiscoveries {
elements: Set<string>;
reactions: Set<string>;
compounds: Set<string>;
creatures: Set<string>;
}
// ─── Meta-Progression (persists between runs) ────────────────────
export interface CodexEntry {
id: string;
type: 'element' | 'reaction' | 'compound' | 'creature' | 'boss';
discoveredOnRun: number;
}
export interface MetaState {
/** Total spores earned across all runs */
spores: number;
/** All codex entries (permanent knowledge) */
codex: CodexEntry[];
/** Total runs completed */
totalRuns: number;
/** Total deaths */
totalDeaths: number;
/** Unlocked school ids */
unlockedSchools: string[];
/** Best run statistics */
bestRunTime: number;
bestRunDiscoveries: number;
/** Run history summaries */
runHistory: RunSummary[];
/** Mycelium knowledge graph (persistent between runs) */
mycelium: MyceliumGraphData;
/** Great cycle state (7-run macro cycles) */
greatCycle: GreatCycleState;
}
/** Serializable Mycelium graph data (stored in MetaState) */
export interface MyceliumGraphData {
/** Knowledge nodes */
nodes: MyceliumNodeData[];
/** Connections between nodes */
edges: MyceliumEdgeData[];
/** Total deposits across all runs */
totalDeposits: number;
/** Total extractions across all runs */
totalExtractions: number;
}
/** A knowledge node in the persistent Mycelium graph */
export interface MyceliumNodeData {
/** Unique node ID (format: "type:id", e.g. "element:Na") */
id: string;
/** Knowledge category */
type: 'element' | 'reaction' | 'compound' | 'creature';
/** Reference to the specific knowledge item */
knowledgeId: string;
/** Run on which this was first deposited */
depositedOnRun: number;
/** Knowledge strength 01 (increases with repeated deposits) */
strength: number;
}
/** An edge connecting two knowledge nodes in the Mycelium */
export interface MyceliumEdgeData {
/** Source node ID */
from: string;
/** Target node ID */
to: string;
/** Connection strength 01 */
weight: number;
}
export interface RunSummary {
runId: number;
schoolId: string;
duration: number;
phase: RunPhase;
discoveries: number;
sporesEarned: number;
crisisResolved: boolean;
/** Biome explored (added Phase 11) */
biomeId?: string;
/** Great cycle number when this run occurred */
cycleNumber?: number;
}
// ─── Body Composition (for death animation) ──────────────────────
/** Real elemental composition of the human body (simplified) */
export const BODY_COMPOSITION: { symbol: string; fraction: number }[] = [
{ symbol: 'O', fraction: 0.65 },
{ symbol: 'C', fraction: 0.18 },
{ symbol: 'H', fraction: 0.10 },
{ symbol: 'N', fraction: 0.03 },
{ symbol: 'Ca', fraction: 0.015 },
{ symbol: 'P', fraction: 0.01 },
{ symbol: 'S', fraction: 0.003 },
{ symbol: 'Na', fraction: 0.002 },
{ symbol: 'K', fraction: 0.002 },
{ symbol: 'Fe', fraction: 0.001 },
];
// ─── Great Cycle (every 7 runs) ──────────────────────────────────
/** Narrative themes for each great cycle (GDD Section IV) */
export type CycleTheme =
| 'awakening' // Cycle 1 (runs 1-7): learning the world
| 'doubt' // Cycle 2 (runs 8-14): finding traces of past adepts
| 'realization' // Cycle 3 (runs 15-21): understanding the nature of cycles
| 'attempt' // Cycle 4 (runs 22-28): first attempts to transcend
| 'acceptance' // Cycle 5 (runs 29-35): cycle is not a prison
| 'synthesis'; // Cycle 6+ (runs 36+): unifying all knowledge
/** Human-readable names for CycleTheme */
export const CYCLE_THEME_NAMES: Record<CycleTheme, string> = {
awakening: 'Awakening',
doubt: 'Doubt',
realization: 'Realization',
attempt: 'Attempt',
acceptance: 'Acceptance',
synthesis: 'Synthesis',
};
export const CYCLE_THEME_NAMES_RU: Record<CycleTheme, string> = {
awakening: 'Пробуждение',
doubt: 'Сомнение',
realization: 'Осознание',
attempt: 'Попытка',
acceptance: 'Принятие',
synthesis: 'Синтез',
};
/** Ordered themes by cycle number (0-indexed, cycles beyond 6 repeat 'synthesis') */
export const CYCLE_THEMES: CycleTheme[] = [
'awakening',
'doubt',
'realization',
'attempt',
'acceptance',
'synthesis',
];
/** A trace left by a completed run within a great cycle */
export interface RunTrace {
/** Run ID this trace belongs to */
runId: number;
/** Position within the great cycle (1-7) */
runInCycle: number;
/** School used this run */
schoolId: string;
/** Biome explored */
biomeId: string;
/** Death location (tile coords), null if run completed via boss victory */
deathPosition: { tileX: number; tileY: number } | null;
/** Phase reached when run ended */
phaseReached: RunPhase;
/** Was the crisis resolved? */
crisisResolved: boolean;
/** Number of unique discoveries */
discoveryCount: number;
/** Key element discoveries (up to 5 symbols for trace markers) */
keyElements: string[];
/** Duration of the run in ms */
duration: number;
/** World seed used for this run */
worldSeed: number;
}
/** Persistent great cycle state (stored in MetaState) */
export interface GreatCycleState {
/** Current great cycle number (1-based) */
cycleNumber: number;
/** Current run within the cycle (1-7, resets on renewal) */
runInCycle: number;
/** Current cycle's theme */
theme: CycleTheme;
/** Traces from runs in the CURRENT cycle (cleared on renewal) */
currentCycleTraces: RunTrace[];
/** Traces from the PREVIOUS cycle (for cross-cycle references) */
previousCycleTraces: RunTrace[];
/** Total great renewals completed */
renewalsCompleted: number;
/** Mycelium maturation level (increases each renewal) */
myceliumMaturation: number;
}
/** Number of runs per great cycle */
export const RUNS_PER_CYCLE = 7;
/** World generation modifiers based on cycle number */
export interface CycleWorldModifiers {
/** Elevation noise scale multiplier (world shape variation) */
elevationScaleMultiplier: number;
/** Detail noise scale multiplier */
detailScaleMultiplier: number;
/** Resource density multiplier */
resourceDensityMultiplier: number;
/** Creature spawn rate multiplier */
creatureSpawnMultiplier: number;
/** Escalation rate multiplier (difficulty) */
escalationRateMultiplier: number;
}
// ─── Constants ───────────────────────────────────────────────────
/** Phase durations in ms (approximate, can be adjusted) */
export const PHASE_DURATIONS: Record<RunPhase, number> = {
[RunPhase.Awakening]: 0, // ends when player leaves Cradle
[RunPhase.Exploration]: 180_000, // 3 minutes
[RunPhase.Escalation]: 120_000, // 2 minutes
[RunPhase.Crisis]: 0, // ends when resolved or player dies
[RunPhase.Resolution]: 60_000, // 1 minute
};
/** Escalation growth rate per second (0→1 over Escalation phase) */
export const ESCALATION_RATE = 0.005;
/** Spores awarded per discovery type */
export const SPORE_REWARDS = {
element: 5,
reaction: 10,
compound: 8,
creature: 15,
crisisResolved: 50,
runCompleted: 20,
};

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { loadMetaState } from '../run/persistence';
import { createMetaState } from '../run/meta';
export class BootScene extends Phaser.Scene { export class BootScene extends Phaser.Scene {
constructor() { constructor() {
@@ -30,29 +32,41 @@ export class BootScene extends Phaser.Scene {
// Version // Version
this.add this.add
.text(cx, cy + 80, 'v0.1.0 — Phase 0: Project Setup', { .text(cx, cy + 80, 'v0.6.0 — Phase 6: Run Cycle', {
fontSize: '12px', fontSize: '12px',
color: '#333333', color: '#333333',
fontFamily: 'monospace', fontFamily: 'monospace',
}) })
.setOrigin(0.5); .setOrigin(0.5);
// Pulsing indicator // Click to start
const dot = this.add const startText = this.add
.text(cx, cy + 120, '', { .text(cx, cy + 120, '[ Click to start ]', {
fontSize: '24px', fontSize: '16px',
color: '#00ff88', color: '#00ff88',
fontFamily: 'monospace', fontFamily: 'monospace',
}) })
.setOrigin(0.5); .setOrigin(0.5);
this.tweens.add({ this.tweens.add({
targets: dot, targets: startText,
alpha: 0.2, alpha: 0.3,
duration: 1500, duration: 1500,
yoyo: true, yoyo: true,
repeat: -1, repeat: -1,
ease: 'Sine.easeInOut', ease: 'Sine.easeInOut',
}); });
this.input.once('pointerdown', () => {
// Load meta-progression from IndexedDB, then go to Cradle
loadMetaState()
.then(meta => {
this.scene.start('CradleScene', { meta });
})
.catch(() => {
// Fallback to fresh meta if persistence fails
this.scene.start('CradleScene', { meta: createMetaState() });
});
});
} }
} }

View File

@@ -0,0 +1,772 @@
/**
* BossArenaScene — Archon boss fight in a circular arena
*
* Features:
* - Circular arena tilemap with hazards
* - Boss entity with cyclical phase AI
* - 3 victory paths (chemical, direct, catalytic)
* - Boss health bar and phase indicator
* - Reward on victory → transition to CradleScene
* - Death → normal DeathScene flow
*/
import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
import { Health, Position, Boss as BossComponent, Velocity } from '../ecs/components';
import { movementSystem } from '../ecs/systems/movement';
import { healthSystem } from '../ecs/systems/health';
import { removeGameEntity } from '../ecs/factory';
import { PhaserBridge } from '../ecs/bridge';
import biomeDataArray from '../data/biomes.json';
import bossDataArray from '../data/bosses.json';
import type { BiomeData } from '../world/types';
import { createWorldTilemap } from '../world/tilemap';
import { buildWalkableSet } from '../player/collision';
import { createPlayerEntity } from '../player/factory';
import { Inventory } from '../player/inventory';
import {
launchProjectile,
projectileSystem,
type ProjectileData,
} from '../player/projectile';
import { playerInputSystem } from '../player/input';
import { tileCollisionSystem } from '../player/collision';
import { QuickSlots } from '../player/quickslots';
import type { InputState } from '../player/types';
// Boss imports
import type { BossData, BossState, BossPhaseEvent } from '../boss/types';
import { BossPhase, BOSS_PHASE_NAMES_RU } from '../boss/types';
import { createBossState, updateBossPhase, getEffectiveArmor, isVulnerable } from '../boss/ai';
import { applyBossDamage, isBossDefeated } from '../boss/victory';
import { generateArena, buildArenaWalkableSet } from '../boss/arena';
import { createBossEntity } from '../boss/factory';
import { calculateBossReward, applyBossReward } from '../boss/reward';
// Run cycle imports
import type { MetaState, RunState } from '../run/types';
import { query } from 'bitecs';
import { Projectile } from '../ecs/components';
// UI zoom compensation
import { fixToScreen } from '../ui/screen-fix';
/** Data passed from GameScene to BossArenaScene */
interface BossArenaInitData {
meta: MetaState;
runState: RunState;
inventoryItems: { id: string; count: number }[];
quickSlotItems: (string | null)[];
activeSlot: number;
playerHealth: number;
playerMaxHealth: number;
}
export class BossArenaScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
// Player
private playerEid!: number;
private inventory!: Inventory;
private quickSlots!: QuickSlots;
private walkableSet!: Set<number>;
private worldGrid!: number[][];
private tileSize!: number;
private projectileData!: Map<number, ProjectileData>;
private keys!: {
W: Phaser.Input.Keyboard.Key;
A: Phaser.Input.Keyboard.Key;
S: Phaser.Input.Keyboard.Key;
D: Phaser.Input.Keyboard.Key;
E: Phaser.Input.Keyboard.Key;
F: Phaser.Input.Keyboard.Key;
ONE: Phaser.Input.Keyboard.Key;
TWO: Phaser.Input.Keyboard.Key;
THREE: Phaser.Input.Keyboard.Key;
FOUR: Phaser.Input.Keyboard.Key;
};
// Boss
private bossEid!: number;
private bossData!: BossData;
private bossState!: BossState;
private bossProjectiles: number[] = [];
private bossAttackTimer = 0;
// Run state
private meta!: MetaState;
private runState!: RunState;
private playerDead = false;
private victoryAchieved = false;
// UI elements
private bossHealthBar!: Phaser.GameObjects.Graphics;
private bossHealthText!: Phaser.GameObjects.Text;
private phaseText!: Phaser.GameObjects.Text;
private feedbackText!: Phaser.GameObjects.Text;
private feedbackTimer = 0;
private bossGlowGraphics!: Phaser.GameObjects.Graphics;
// Input debounce
private wasFDown = false;
constructor() {
super({ key: 'BossArenaScene' });
}
init(data: BossArenaInitData): void {
this.meta = data.meta;
this.runState = data.runState;
this.playerDead = false;
this.victoryAchieved = false;
this.bossProjectiles = [];
this.bossAttackTimer = 0;
// Restore inventory
this.inventory = new Inventory(500, 20);
for (const item of data.inventoryItems) {
for (let i = 0; i < item.count; i++) {
this.inventory.addItem(item.id);
}
}
// Restore quick slots
this.quickSlots = new QuickSlots();
for (let i = 0; i < data.quickSlotItems.length; i++) {
this.quickSlots.assign(i, data.quickSlotItems[i]);
}
this.quickSlots.setActive(data.activeSlot);
}
create(): void {
const biome = biomeDataArray[0] as BiomeData;
this.bossData = bossDataArray[0] as BossData;
this.tileSize = biome.tileSize;
// 1. Initialize ECS
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
this.projectileData = new Map();
// 2. Generate arena
const arena = generateArena(this.bossData, biome);
this.worldGrid = arena.grid;
this.walkableSet = buildArenaWalkableSet();
// 3. Create tilemap (reuse world tilemap system)
const arenaWorldData = {
grid: arena.grid,
biome: biome,
seed: 0,
};
createWorldTilemap(this, arenaWorldData);
// 4. Create player entity at arena entrance
this.playerEid = createPlayerEntity(
this.gameWorld.world, arena.playerSpawnX, arena.playerSpawnY,
);
// Apply player health from GameScene
const initData = this.scene.settings.data as BossArenaInitData;
if (initData?.playerHealth !== undefined) {
Health.current[this.playerEid] = initData.playerHealth;
Health.max[this.playerEid] = initData.playerMaxHealth;
}
// 5. Create boss entity at center
this.bossEid = createBossEntity(
this.gameWorld.world, this.bossData, arena.bossSpawnX, arena.bossSpawnY,
);
this.bossState = createBossState(this.bossData);
// 6. Camera setup
const worldPixelW = arena.width * this.tileSize;
const worldPixelH = arena.height * this.tileSize;
this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH);
this.cameras.main.setZoom(2.0); // Closer zoom for arena
// Sync bridge to create sprites, then follow player
this.bridge.sync(this.gameWorld.world);
const playerSprite = this.bridge.getSprite(this.playerEid);
if (playerSprite) {
playerSprite.setDepth(10);
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
}
// 7. Keyboard input
const keyboard = this.input.keyboard;
if (!keyboard) throw new Error('Keyboard plugin not available');
this.keys = {
W: keyboard.addKey('W'),
A: keyboard.addKey('A'),
S: keyboard.addKey('S'),
D: keyboard.addKey('D'),
E: keyboard.addKey('E'),
F: keyboard.addKey('F'),
ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE),
TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO),
THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE),
FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR),
};
// 8. Boss glow graphics (world-space)
this.bossGlowGraphics = this.add.graphics();
this.bossGlowGraphics.setDepth(5);
// 9. UI elements (screen-space)
this.createBossUI();
// 10. Launch UIScene for player HUD
this.scene.launch('UIScene');
// Entry announcement
this.showFeedback('⚔ УРОБОРОС — Архонт Циклов');
}
update(_time: number, delta: number): void {
if (this.playerDead || this.victoryAchieved) return;
// 1. Update world time
updateTime(this.gameWorld, delta);
// 2. Player input
const input: InputState = {
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
interact: this.keys.E.isDown,
};
playerInputSystem(this.gameWorld.world, input);
// 3. Movement
movementSystem(this.gameWorld.world, delta);
// 4. Tile collision (player)
tileCollisionSystem(
this.gameWorld.world, delta,
this.worldGrid, this.tileSize, this.walkableSet,
);
// 5. Projectile system
projectileSystem(
this.gameWorld.world, delta,
this.worldGrid, this.tileSize, this.walkableSet,
this.projectileData,
);
// 6. Quick slots
if (this.keys.ONE.isDown) this.quickSlots.setActive(0);
if (this.keys.TWO.isDown) this.quickSlots.setActive(1);
if (this.keys.THREE.isDown) this.quickSlots.setActive(2);
if (this.keys.FOUR.isDown) this.quickSlots.setActive(3);
// 7. Throw projectile (F key)
const isFDown = this.keys.F.isDown;
const justPressedF = isFDown && !this.wasFDown;
this.wasFDown = isFDown;
if (justPressedF) {
this.tryLaunchProjectile();
}
// 8. Boss AI phase update
const bossEvents = updateBossPhase(this.bossState, this.bossData, delta);
this.handleBossEvents(bossEvents, delta);
// Sync boss ECS component with runtime state
BossComponent.phase[this.bossEid] = this.bossState.currentPhase;
BossComponent.cycleCount[this.bossEid] = this.bossState.cycleCount;
// 9. Boss attack behavior
this.updateBossAttacks(delta);
// 10. Check projectile → boss collision
this.checkProjectileBossCollision();
// 11. Health system
const dead = healthSystem(this.gameWorld.world);
let playerDied = false;
for (const eid of dead) {
if (eid === this.playerEid) {
playerDied = true;
continue;
}
if (eid === this.bossEid) {
continue; // Boss death handled by victory system
}
removeGameEntity(this.gameWorld.world, eid);
}
if (playerDied) {
this.onPlayerDeath();
return;
}
// 12. Check victory
if (isBossDefeated(this.bossState)) {
this.onBossDefeated();
return;
}
// 13. Render sync
this.bridge.sync(this.gameWorld.world);
// 14. Update boss visuals
this.updateBossVisuals(delta);
// 15. Update UI
this.updateBossUI();
// 16. Feedback text fade
if (this.feedbackTimer > 0) {
this.feedbackTimer -= delta;
if (this.feedbackTimer <= 500) {
this.feedbackText.setAlpha(this.feedbackTimer / 500);
}
if (this.feedbackTimer <= 0) {
this.feedbackText.setAlpha(0);
}
}
// 17. Push state to registry for UIScene
this.registry.set('health', Health.current[this.playerEid] ?? 100);
this.registry.set('healthMax', Health.max[this.playerEid] ?? 100);
this.registry.set('quickSlots', this.quickSlots.getAll());
this.registry.set('activeSlot', this.quickSlots.activeIndex);
this.registry.set('invWeight', this.inventory.getTotalWeight());
this.registry.set('invMaxWeight', this.inventory.maxWeight);
this.registry.set('invSlots', this.inventory.slotCount);
const counts = new Map<string, number>();
for (const item of this.inventory.getItems()) {
counts.set(item.id, item.count);
}
this.registry.set('invCounts', counts);
}
// ─── Boss Attack Logic ─────────────────────────────────────────
private handleBossEvents(events: BossPhaseEvent[], _delta: number): void {
for (const event of events) {
if (event.type === 'phase_change') {
const phaseName = BOSS_PHASE_NAMES_RU[event.phase];
this.showFeedback(`Фаза: ${phaseName}`);
}
if (event.type === 'cycle_complete') {
this.showFeedback(`Цикл ${event.cycleCount} завершён — Уроборос ускоряется!`);
}
}
}
private updateBossAttacks(delta: number): void {
const phase = this.bossState.currentPhase;
const bx = Position.x[this.bossEid];
const by = Position.y[this.bossEid];
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
switch (phase) {
case BossPhase.Coil: {
// Boss slowly moves toward player
const dx = px - bx;
const dy = py - by;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 30) {
const speed = 20 + this.bossState.cycleCount * 5;
Velocity.vx[this.bossEid] = (dx / dist) * speed;
Velocity.vy[this.bossEid] = (dy / dist) * speed;
} else {
Velocity.vx[this.bossEid] = 0;
Velocity.vy[this.bossEid] = 0;
// Close-range damage
this.bossAttackTimer -= delta;
if (this.bossAttackTimer <= 0) {
Health.current[this.playerEid] = Math.max(
0, (Health.current[this.playerEid] ?? 0) - this.bossData.damage * 0.5,
);
this.bossAttackTimer = 1500;
}
}
break;
}
case BossPhase.Spray: {
// Boss stays still, shoots acid projectiles in rotating pattern
Velocity.vx[this.bossEid] = 0;
Velocity.vy[this.bossEid] = 0;
this.bossAttackTimer -= delta;
if (this.bossAttackTimer <= 0) {
const sprayCount = 4 + this.bossState.cycleCount;
const baseAngle = (Date.now() / 1000) * 2; // Rotating
for (let i = 0; i < sprayCount; i++) {
const angle = baseAngle + (i * 2 * Math.PI / sprayCount);
const targetX = bx + Math.cos(angle) * 200;
const targetY = by + Math.sin(angle) * 200;
this.spawnBossProjectile(bx, by, targetX, targetY);
}
this.bossAttackTimer = 1500 - this.bossState.cycleCount * 150;
}
break;
}
case BossPhase.Lash: {
// Boss sweeps tail — area damage if player is in range + angle
Velocity.vx[this.bossEid] = 0;
Velocity.vy[this.bossEid] = 0;
this.bossAttackTimer -= delta;
if (this.bossAttackTimer <= 0) {
const dx = px - bx;
const dy = py - by;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 80 + this.bossState.cycleCount * 10) {
const damage = this.bossData.damage * (1 + this.bossState.cycleCount * 0.2);
Health.current[this.playerEid] = Math.max(
0, (Health.current[this.playerEid] ?? 0) - damage,
);
this.showFeedback(`Удар хвостом! (${Math.round(damage)} урона)`);
}
this.bossAttackTimer = 2000 - this.bossState.cycleCount * 200;
}
break;
}
case BossPhase.Digest: {
// Boss is immobile and vulnerable
Velocity.vx[this.bossEid] = 0;
Velocity.vy[this.bossEid] = 0;
break;
}
}
}
private spawnBossProjectile(fromX: number, fromY: number, toX: number, toY: number): void {
launchProjectile(
this.gameWorld.world,
this.projectileData,
fromX, fromY,
toX, toY,
'__boss_acid__',
);
}
// ─── Projectile → Boss Collision ───────────────────────────────
private checkProjectileBossCollision(): void {
const projEntities = query(this.gameWorld.world, [Position, Projectile]);
const bx = Position.x[this.bossEid];
const by = Position.y[this.bossEid];
const hitRadiusSq = (this.bossData.radius + 5) * (this.bossData.radius + 5);
for (const projEid of projEntities) {
const projInfo = this.projectileData.get(projEid);
if (!projInfo || projInfo.itemId === '__boss_acid__') continue; // Skip boss projectiles
const dx = Position.x[projEid] - bx;
const dy = Position.y[projEid] - by;
const distSq = dx * dx + dy * dy;
if (distSq <= hitRadiusSq) {
// Hit the boss!
const result = applyBossDamage(this.bossState, this.bossData, projInfo.itemId);
// Sync health to ECS
Health.current[this.bossEid] = this.bossState.health;
// Remove projectile
removeGameEntity(this.gameWorld.world, projEid);
this.projectileData.delete(projEid);
// Show feedback
this.showFeedback(result.messageRu);
}
}
// Check boss projectile → player collision
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
const playerHitRadiusSq = 15 * 15;
for (const projEid of projEntities) {
const projInfo = this.projectileData.get(projEid);
if (!projInfo || projInfo.itemId !== '__boss_acid__') continue;
const dx = Position.x[projEid] - px;
const dy = Position.y[projEid] - py;
const distSq = dx * dx + dy * dy;
if (distSq <= playerHitRadiusSq) {
// Boss projectile hits player
const damage = this.bossData.damage * 0.7;
Health.current[this.playerEid] = Math.max(
0, (Health.current[this.playerEid] ?? 0) - damage,
);
removeGameEntity(this.gameWorld.world, projEid);
this.projectileData.delete(projEid);
}
}
}
// ─── Boss Visual Effects ───────────────────────────────────────
private updateBossVisuals(_delta: number): void {
const bx = Position.x[this.bossEid];
const by = Position.y[this.bossEid];
this.bossGlowGraphics.clear();
// Phase-based glow
const phase = this.bossState.currentPhase;
let glowColor = 0xcc44ff;
let glowAlpha = 0.15;
let glowRadius = this.bossData.radius * 2;
switch (phase) {
case BossPhase.Coil:
glowColor = 0xff4444; // Red — danger
glowAlpha = 0.2;
break;
case BossPhase.Spray:
glowColor = 0x88ff00; // Acid green
glowAlpha = 0.25;
glowRadius = this.bossData.radius * 2.5;
break;
case BossPhase.Lash:
glowColor = 0xffaa00; // Orange — warning
glowAlpha = 0.2;
break;
case BossPhase.Digest:
glowColor = 0x4488ff; // Blue — vulnerable
glowAlpha = 0.3;
glowRadius = this.bossData.radius * 1.5;
break;
}
// Pulsing glow
const pulse = 0.7 + 0.3 * Math.sin(Date.now() / 300);
this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse);
this.bossGlowGraphics.fillCircle(bx, by, glowRadius);
// Inner glow
this.bossGlowGraphics.fillStyle(glowColor, glowAlpha * pulse * 1.5);
this.bossGlowGraphics.fillCircle(bx, by, glowRadius * 0.5);
// Catalyst poison visual: darkening stacks
if (this.bossState.catalystStacks > 0) {
const poisonAlpha = 0.1 * this.bossState.catalystStacks;
this.bossGlowGraphics.fillStyle(0x888888, poisonAlpha);
this.bossGlowGraphics.fillCircle(bx, by, this.bossData.radius * 1.2);
}
}
// ─── UI ────────────────────────────────────────────────────────
private createBossUI(): void {
const cam = this.cameras.main;
// Boss health bar background
this.bossHealthBar = this.add.graphics();
this.bossHealthBar.setScrollFactor(0);
this.bossHealthBar.setDepth(100);
// Boss name + health text
this.bossHealthText = this.add.text(cam.width / 2, 20, '', {
fontSize: '14px',
color: '#cc44ff',
fontFamily: 'monospace',
backgroundColor: '#000000cc',
padding: { x: 8, y: 4 },
});
this.bossHealthText.setScrollFactor(0);
this.bossHealthText.setOrigin(0.5, 0);
this.bossHealthText.setDepth(101);
// Phase indicator
this.phaseText = this.add.text(cam.width / 2, 55, '', {
fontSize: '12px',
color: '#ffdd44',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 6, y: 2 },
});
this.phaseText.setScrollFactor(0);
this.phaseText.setOrigin(0.5, 0);
this.phaseText.setDepth(101);
// Feedback text (center)
this.feedbackText = this.add.text(cam.width / 2, cam.height - 60, '', {
fontSize: '14px',
color: '#ffdd44',
fontFamily: 'monospace',
backgroundColor: '#000000cc',
padding: { x: 8, y: 4 },
align: 'center',
});
this.feedbackText.setScrollFactor(0);
this.feedbackText.setOrigin(0.5);
this.feedbackText.setDepth(101);
this.feedbackText.setAlpha(0);
}
private updateBossUI(): void {
const cam = this.cameras.main;
const healthPct = this.bossState.health / this.bossState.maxHealth;
// Fix UI positions for camera zoom.
// Graphics at (0,0) — local draw coordinates map 1:1 to screen pixels.
fixToScreen(this.bossHealthBar, 0, 0, cam);
fixToScreen(this.bossHealthText, cam.width / 2, 20, cam);
fixToScreen(this.phaseText, cam.width / 2, 55, cam);
fixToScreen(this.feedbackText, cam.width / 2, cam.height - 60, cam);
// Health bar
const barWidth = 300;
const barHeight = 8;
const barX = (cam.width - barWidth) / 2;
const barY = 42;
this.bossHealthBar.clear();
// Background
this.bossHealthBar.fillStyle(0x333333, 0.8);
this.bossHealthBar.fillRect(barX, barY, barWidth, barHeight);
// Fill
const fillColor = healthPct > 0.5 ? 0xcc44ff : healthPct > 0.25 ? 0xff8800 : 0xff2222;
this.bossHealthBar.fillStyle(fillColor, 1);
this.bossHealthBar.fillRect(barX, barY, barWidth * healthPct, barHeight);
// Border
this.bossHealthBar.lineStyle(1, 0xffffff, 0.3);
this.bossHealthBar.strokeRect(barX, barY, barWidth, barHeight);
// Health text
const healthStr = `УРОБОРОС — ${Math.ceil(this.bossState.health)}/${this.bossState.maxHealth}`;
this.bossHealthText.setText(healthStr);
// Phase text
const phaseName = BOSS_PHASE_NAMES_RU[this.bossState.currentPhase];
const cycleStr = this.bossState.cycleCount > 0 ? ` | Цикл ${this.bossState.cycleCount}` : '';
const catalystStr = this.bossState.catalystStacks > 0
? ` | ☠ Яд: ${this.bossState.catalystStacks}/${this.bossData.maxCatalystStacks}`
: '';
const vulnStr = isVulnerable(this.bossState, this.bossData) ? ' | ★ УЯЗВИМ' : '';
this.phaseText.setText(`${phaseName}${vulnStr}${cycleStr}${catalystStr}`);
// Phase color
switch (this.bossState.currentPhase) {
case BossPhase.Coil:
this.phaseText.setColor('#ff4444');
break;
case BossPhase.Spray:
this.phaseText.setColor('#88ff00');
break;
case BossPhase.Lash:
this.phaseText.setColor('#ffaa00');
break;
case BossPhase.Digest:
this.phaseText.setColor('#4488ff');
break;
}
}
// ─── Victory & Death ───────────────────────────────────────────
private onBossDefeated(): void {
this.victoryAchieved = true;
// Calculate reward
const reward = calculateBossReward(this.bossState, this.bossData);
applyBossReward(this.meta, reward, this.runState.runId);
// Show victory message
const cam = this.cameras.main;
const victoryText = this.add.text(cam.width / 2, cam.height / 2, '', {
fontSize: '20px',
color: '#cc44ff',
fontFamily: 'monospace',
backgroundColor: '#000000ee',
padding: { x: 16, y: 12 },
align: 'center',
wordWrap: { width: 500 },
});
victoryText.setScrollFactor(0);
victoryText.setOrigin(0.5);
victoryText.setDepth(200);
fixToScreen(victoryText, cam.width / 2, cam.height / 2, cam);
const methodNames: Record<string, string> = {
chemical: 'Алхимическая победа (NaOH)',
direct: 'Прямая победа',
catalytic: 'Каталитическая победа (Hg)',
};
const method = this.bossState.victoryMethod ?? 'direct';
const methodName = methodNames[method] ?? method;
victoryText.setText(
`★ УРОБОРОС ПОВЕРЖЕН ★\n\n${methodName}\n+${reward.spores} спор\n\nАрхонтова Память добавлена в Кодекс`,
);
// Stop UIScene
this.scene.stop('UIScene');
// Transition after delay
this.time.delayedCall(4000, () => {
this.cameras.main.fadeOut(1500, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('DeathScene', {
meta: this.meta,
runState: this.runState,
});
});
});
}
private onPlayerDeath(): void {
this.playerDead = true;
this.runState.alive = false;
this.scene.stop('UIScene');
this.cameras.main.fadeOut(2000, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('DeathScene', {
meta: this.meta,
runState: this.runState,
});
});
}
// ─── Projectile Launch ─────────────────────────────────────────
private tryLaunchProjectile(): void {
const itemId = this.quickSlots.getActive();
if (!itemId || !this.inventory.hasItem(itemId)) return;
const removed = this.inventory.removeItem(itemId, 1);
if (removed === 0) return;
const pointer = this.input.activePointer;
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
launchProjectile(
this.gameWorld.world,
this.projectileData,
Position.x[this.playerEid],
Position.y[this.playerEid],
worldPoint.x,
worldPoint.y,
itemId,
);
// Clear quick slot if empty
if (!this.inventory.hasItem(itemId)) {
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
}
}
// ─── Feedback ──────────────────────────────────────────────────
private showFeedback(message: string): void {
this.feedbackText.setText(message);
this.feedbackText.setAlpha(1);
this.feedbackTimer = 2500;
}
}

591
src/scenes/CradleScene.ts Normal file
View File

@@ -0,0 +1,591 @@
/**
* CradleScene — Spore Cradle (Споровая Колыбель)
*
* Awakening scene where the player "wakes up" inside a giant mushroom.
* Displays school selection and starts the run.
* Receives meta state from BootScene or FractalScene.
*/
import Phaser from 'phaser';
import schoolsData from '../data/schools.json';
import biomeDataArray from '../data/biomes.json';
import type { SchoolData, MetaState } from '../run/types';
import type { BiomeData } from '../world/types';
import { isSchoolUnlocked } from '../run/meta';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession } from '../mycelium/shop';
import { getGraphStats } from '../mycelium/graph';
import { getCycleSummary } from '../run/cycle';
import { CYCLE_THEME_NAMES_RU } from '../run/types';
import type { BonusEffect } from '../mycelium/types';
import narrativeData from '../data/cycle-narrative.json';
const schools = schoolsData as unknown as SchoolData[];
const biomes = biomeDataArray as BiomeData[];
export class CradleScene extends Phaser.Scene {
private meta!: MetaState;
private selectedIndex = 0;
private selectedBiomeIndex = 0;
private schoolCards: Phaser.GameObjects.Container[] = [];
private biomeCards: { bg: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text }[] = [];
private particles: { x: number; y: number; vx: number; vy: number; alpha: number; radius: number }[] = [];
private particleGraphics!: Phaser.GameObjects.Graphics;
private introTimer = 0;
private introComplete = false;
private purchasedEffects: BonusEffect[] = [];
private sporeCountText!: Phaser.GameObjects.Text;
private shopContainer!: Phaser.GameObjects.Container;
constructor() {
super({ key: 'CradleScene' });
}
init(data: { meta: MetaState }): void {
this.meta = data.meta;
this.selectedIndex = 0;
this.selectedBiomeIndex = 0;
this.schoolCards = [];
this.biomeCards = [];
this.introTimer = 0;
this.introComplete = false;
this.purchasedEffects = [];
resetShopSession();
}
create(): void {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
// Background — dark organic interior
this.cameras.main.setBackgroundColor('#050808');
// Ambient spore particles
this.particles = [];
for (let i = 0; i < 60; i++) {
this.particles.push({
x: Math.random() * GAME_WIDTH,
y: Math.random() * GAME_HEIGHT,
vx: (Math.random() - 0.5) * 15,
vy: -Math.random() * 20 - 5,
alpha: Math.random() * 0.4 + 0.1,
radius: Math.random() * 2 + 1,
});
}
this.particleGraphics = this.add.graphics();
this.particleGraphics.setDepth(0);
// Intro text — fades in
const introText = this.add.text(cx, cy - 60, '...пробуждение...', {
fontSize: '24px',
color: '#2a6644',
fontFamily: 'monospace',
});
introText.setOrigin(0.5);
introText.setAlpha(0);
introText.setDepth(10);
// Fade in intro
this.tweens.add({
targets: introText,
alpha: 1,
duration: 2000,
ease: 'Sine.easeIn',
onComplete: () => {
// After intro text appears, fade it out and show school selection
this.tweens.add({
targets: introText,
alpha: 0,
duration: 1500,
delay: 1000,
ease: 'Sine.easeOut',
onComplete: () => {
introText.destroy();
this.showSchoolSelection();
this.introComplete = true;
},
});
},
});
// Meta info (top-right)
const graphStats = getGraphStats(this.meta.mycelium);
const cycleSummary = getCycleSummary(this.meta.greatCycle);
const themeName = CYCLE_THEME_NAMES_RU[cycleSummary.theme];
const metaInfo = [
`Раны: ${this.meta.totalRuns}`,
`Кодекс: ${this.meta.codex.length}`,
`Мицелий: ${graphStats.nodeCount} узлов`,
].join(' | ');
this.add.text(GAME_WIDTH - 12, 12, metaInfo, {
fontSize: '11px',
color: '#334433',
fontFamily: 'monospace',
}).setOrigin(1, 0).setDepth(10);
// Spore count (top-right, below meta info)
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);
// Cycle info (top-right, below spores)
const cycleInfo = `Великий Цикл ${cycleSummary.cycleNumber}: ${themeName} | Ран ${cycleSummary.runInCycle}/${cycleSummary.totalRuns}`;
this.add.text(GAME_WIDTH - 12, 46, cycleInfo, {
fontSize: '11px',
color: '#336644',
fontFamily: 'monospace',
}).setOrigin(1, 0).setDepth(10);
// Cycle narrative quote (top-left, fades in slowly)
const themeNarrative = (narrativeData.themes as Record<string, { cradleQuoteRu: string }>)[cycleSummary.theme];
if (themeNarrative) {
const quoteText = this.add.text(12, 12, themeNarrative.cradleQuoteRu, {
fontSize: '12px',
color: '#223322',
fontFamily: 'monospace',
fontStyle: 'italic',
});
quoteText.setAlpha(0);
quoteText.setDepth(10);
this.tweens.add({
targets: quoteText,
alpha: 0.5,
duration: 3000,
delay: 1000,
});
}
}
private showSchoolSelection(): void {
const cx = GAME_WIDTH / 2;
// Title
const title = this.add.text(cx, 30, 'СПОРОВАЯ КОЛЫБЕЛЬ', {
fontSize: '28px',
color: '#00ff88',
fontFamily: 'monospace',
fontStyle: 'bold',
});
title.setOrigin(0.5);
title.setAlpha(0);
title.setDepth(10);
const subtitle = this.add.text(cx, 65, 'Выбери биом и школу', {
fontSize: '14px',
color: '#557755',
fontFamily: 'monospace',
});
subtitle.setOrigin(0.5);
subtitle.setAlpha(0);
subtitle.setDepth(10);
this.tweens.add({ targets: [title, subtitle], alpha: 1, duration: 800 });
// Biome selection row
this.createBiomeSelector(cx, 105);
// School cards — show ALL schools, grayed out if locked
const cardWidth = 280;
const cardHeight = 220;
const startY = 150;
const cardSpacing = 12;
for (let i = 0; i < schools.length; i++) {
const school = schools[i];
const unlocked = isSchoolUnlocked(this.meta, school.id);
const totalCardWidth = schools.length * (cardWidth + cardSpacing) - cardSpacing;
const cardX = cx - totalCardWidth / 2 + cardWidth / 2 + i * (cardWidth + cardSpacing);
const cardY = startY + cardHeight / 2;
const container = this.add.container(cardX, cardY);
container.setDepth(10);
const schoolColor = parseInt(school.color.replace('#', ''), 16);
// Card background
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight,
unlocked ? 0x0a1a0f : 0x0a0a0a, 0.9);
bg.setStrokeStyle(2, unlocked ? schoolColor : 0x333333);
container.add(bg);
// School name
const nameText = this.add.text(0, -cardHeight / 2 + 20, school.nameRu, {
fontSize: '20px',
color: unlocked ? school.color : '#555555',
fontFamily: 'monospace',
fontStyle: 'bold',
});
nameText.setOrigin(0.5);
container.add(nameText);
// Principle
const principleText = this.add.text(0, -cardHeight / 2 + 48, school.principleRu, {
fontSize: '11px',
color: unlocked ? '#88aa88' : '#444444',
fontFamily: 'monospace',
});
principleText.setOrigin(0.5);
container.add(principleText);
if (unlocked) {
// ─── Unlocked school: full info ───
// Starting elements
const elemList = school.startingElements.map(sym => {
const qty = school.startingQuantities[sym] ?? 1;
return `${sym} ×${qty}`;
}).join(' ');
const elemText = this.add.text(0, -cardHeight / 2 + 72, elemList, {
fontSize: '12px',
color: '#aaffaa',
fontFamily: 'monospace',
});
elemText.setOrigin(0.5);
container.add(elemText);
// Bonus indicator
const bonusKeys = Object.keys(school.bonuses);
if (bonusKeys.length > 0) {
const bonusLabels: Record<string, string> = {
reactionEfficiency: 'Реакции',
projectileDamage: 'Урон',
creatureAggroRange: 'Агрессия',
movementSpeed: 'Скорость',
};
const bonusText = bonusKeys.map(k => {
const label = bonusLabels[k] ?? k;
const val = school.bonuses[k];
const pct = val >= 1 ? `+${Math.round((val - 1) * 100)}%` : `-${Math.round((1 - val) * 100)}%`;
return `${label} ${pct}`;
}).join(' ');
const bText = this.add.text(0, -cardHeight / 2 + 95, bonusText, {
fontSize: '11px',
color: '#ffcc44',
fontFamily: 'monospace',
});
bText.setOrigin(0.5);
container.add(bText);
}
// Playstyle
const playText = this.add.text(0, -cardHeight / 2 + 118,
this.wrapText(school.playstyleRu, 30), {
fontSize: '11px',
color: '#778877',
fontFamily: 'monospace',
lineSpacing: 3,
});
playText.setOrigin(0.5, 0);
container.add(playText);
// Description
const descText = this.add.text(0, cardHeight / 2 - 55,
this.wrapText(school.descriptionRu, 30), {
fontSize: '10px',
color: '#556655',
fontFamily: 'monospace',
lineSpacing: 2,
});
descText.setOrigin(0.5, 0);
container.add(descText);
// Click to select
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
bg.setStrokeStyle(3, 0x00ff88);
this.selectedIndex = i;
});
bg.on('pointerout', () => {
bg.setStrokeStyle(2, schoolColor);
});
bg.on('pointerdown', () => {
this.startRun(school);
});
} else {
// ─── Locked school: grayed out with unlock hint ───
// Lock icon
const lockText = this.add.text(0, -10, '🔒', {
fontSize: '32px',
});
lockText.setOrigin(0.5);
container.add(lockText);
// Unlock hint
const hint = school.unlockCondition?.hintRu ?? 'Заблокировано';
const hintText = this.add.text(0, 30, hint, {
fontSize: '11px',
color: '#666666',
fontFamily: 'monospace',
align: 'center',
});
hintText.setOrigin(0.5);
container.add(hintText);
}
// Fade in
container.setAlpha(0);
this.tweens.add({
targets: container,
alpha: unlocked ? 1 : 0.6,
duration: 600,
delay: 200 + i * 150,
});
this.schoolCards.push(container);
}
// Spore shop (bottom area, only if player has spores)
if (this.meta.spores > 0) {
this.createSporeShop(cx);
}
// Start button hint
const hintText = this.add.text(cx, GAME_HEIGHT - 40, '[ Нажми на школу, чтобы начать ран ]', {
fontSize: '13px',
color: '#445544',
fontFamily: 'monospace',
});
hintText.setOrigin(0.5);
hintText.setAlpha(0);
hintText.setDepth(10);
this.tweens.add({
targets: hintText,
alpha: 0.8,
duration: 600,
delay: 600,
yoyo: true,
repeat: -1,
});
}
/** 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);
}
}
/** Create the biome selection row */
private createBiomeSelector(cx: number, y: number): void {
const btnWidth = 200;
const btnHeight = 36;
const spacing = 12;
const totalWidth = biomes.length * (btnWidth + spacing) - spacing;
const startX = cx - totalWidth / 2 + btnWidth / 2;
const biomeColors: Record<string, number> = {
'catalytic-wastes': 0x886622,
'kinetic-mountains': 0x5577aa,
'verdant-forests': 0x228833,
};
for (let i = 0; i < biomes.length; i++) {
const biome = biomes[i];
const bx = startX + i * (btnWidth + spacing);
const isSelected = i === this.selectedBiomeIndex;
const color = biomeColors[biome.id] ?? 0x444444;
const bg = this.add.rectangle(bx, y, btnWidth, btnHeight,
isSelected ? color : 0x0a1a0f, 0.9);
bg.setStrokeStyle(isSelected ? 3 : 1, color);
bg.setDepth(10);
const label = this.add.text(bx, y, biome.nameRu, {
fontSize: '13px',
color: isSelected ? '#ffffff' : '#888888',
fontFamily: 'monospace',
fontStyle: isSelected ? 'bold' : 'normal',
});
label.setOrigin(0.5);
label.setDepth(10);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.selectedBiomeIndex = i;
this.refreshBiomeButtons();
});
bg.on('pointerover', () => {
if (i !== this.selectedBiomeIndex) {
bg.setStrokeStyle(2, 0x00ff88);
}
});
bg.on('pointerout', () => {
const sel = i === this.selectedBiomeIndex;
bg.setStrokeStyle(sel ? 3 : 1, color);
});
// Fade in
bg.setAlpha(0);
label.setAlpha(0);
this.tweens.add({ targets: [bg, label], alpha: 1, duration: 600, delay: 100 + i * 100 });
this.biomeCards.push({ bg, label });
}
}
/** Refresh biome button visual states */
private refreshBiomeButtons(): void {
const biomeColors: Record<string, number> = {
'catalytic-wastes': 0x886622,
'kinetic-mountains': 0x5577aa,
'verdant-forests': 0x228833,
};
for (let i = 0; i < this.biomeCards.length; i++) {
const card = this.biomeCards[i];
const biome = biomes[i];
const isSelected = i === this.selectedBiomeIndex;
const color = biomeColors[biome.id] ?? 0x444444;
card.bg.setFillStyle(isSelected ? color : 0x0a1a0f, 0.9);
card.bg.setStrokeStyle(isSelected ? 3 : 1, color);
card.label.setColor(isSelected ? '#ffffff' : '#888888');
card.label.setFontStyle(isSelected ? 'bold' : 'normal');
}
}
private startRun(school: SchoolData): void {
// Flash effect
this.cameras.main.flash(300, 0, 255, 136);
const selectedBiome = biomes[this.selectedBiomeIndex];
this.time.delayedCall(400, () => {
this.scene.start('GameScene', {
meta: this.meta,
schoolId: school.id,
runId: this.meta.totalRuns + 1,
purchasedEffects: this.purchasedEffects,
biomeId: selectedBiome.id,
});
});
}
update(_time: number, delta: number): void {
// Animate spore particles
this.particleGraphics.clear();
for (const p of this.particles) {
p.x += p.vx * (delta / 1000);
p.y += p.vy * (delta / 1000);
p.alpha += (Math.random() - 0.5) * 0.01;
p.alpha = Math.max(0.05, Math.min(0.5, p.alpha));
// Wrap around
if (p.y < -5) p.y = GAME_HEIGHT + 5;
if (p.x < -5) p.x = GAME_WIDTH + 5;
if (p.x > GAME_WIDTH + 5) p.x = -5;
this.particleGraphics.fillStyle(0x00ff88, p.alpha);
this.particleGraphics.fillCircle(p.x, p.y, p.radius);
}
}
/** Simple word wrap by character count */
private wrapText(text: string, maxChars: number): string {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
if (currentLine.length + word.length + 1 > maxChars) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine += (currentLine.length > 0 ? ' ' : '') + word;
}
}
if (currentLine.length > 0) lines.push(currentLine);
return lines.join('\n');
}
}

261
src/scenes/DeathScene.ts Normal file
View File

@@ -0,0 +1,261 @@
/**
* DeathScene — Body decomposition and transition to fractal
*
* GDD spec:
* 1. World slows, sounds fade
* 2. Body decomposes into real elements (65% O, 18% C, 10% H...)
* 3. Elements absorbed into soil → rush toward Mycelium
* 4. Transition to FractalScene
*/
import Phaser from 'phaser';
import type { MetaState, RunState } from '../run/types';
import { BODY_COMPOSITION } from '../run/types';
import { ElementRegistry } from '../chemistry/elements';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
/** A single element particle flying out during decomposition */
interface DeathParticle {
x: number;
y: number;
vx: number;
vy: number;
color: number;
symbol: string;
alpha: number;
radius: number;
phase: 'explode' | 'settle' | 'absorb';
timer: number;
}
export class DeathScene extends Phaser.Scene {
private meta!: MetaState;
private runState!: RunState;
private particles: DeathParticle[] = [];
private graphics!: Phaser.GameObjects.Graphics;
private elapsed = 0;
private phaseState: 'decompose' | 'absorb' | 'fadeout' = 'decompose';
private labelTexts: Phaser.GameObjects.Text[] = [];
private compositionText!: Phaser.GameObjects.Text;
constructor() {
super({ key: 'DeathScene' });
}
init(data: { meta: MetaState; runState: RunState }): void {
this.meta = data.meta;
this.runState = data.runState;
this.particles = [];
this.elapsed = 0;
this.phaseState = 'decompose';
this.labelTexts = [];
}
create(): void {
this.cameras.main.setBackgroundColor('#020202');
this.graphics = this.add.graphics();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
// Player "body" — starts as a bright circle, then decomposes
const bodyGlow = this.add.circle(cx, cy, 16, 0x00e5ff, 1);
bodyGlow.setDepth(10);
// Title text
const deathText = this.add.text(cx, 60, 'Распад...', {
fontSize: '20px',
color: '#334455',
fontFamily: 'monospace',
});
deathText.setOrigin(0.5);
deathText.setAlpha(0);
// Composition display
this.compositionText = this.add.text(cx, GAME_HEIGHT - 50, '', {
fontSize: '11px',
color: '#445566',
fontFamily: 'monospace',
});
this.compositionText.setOrigin(0.5);
this.compositionText.setAlpha(0);
// Phase 1: Body pulses, then explodes into elements
this.tweens.add({
targets: bodyGlow,
scaleX: 1.5,
scaleY: 1.5,
alpha: 0.7,
duration: 600,
yoyo: true,
repeat: 2,
ease: 'Sine.easeInOut',
onComplete: () => {
// Explode into element particles
this.spawnElementParticles(cx, cy);
bodyGlow.destroy();
// Show death text
this.tweens.add({
targets: deathText,
alpha: 0.6,
duration: 1000,
});
// Show composition
const compText = BODY_COMPOSITION
.map(e => `${e.symbol}: ${(e.fraction * 100).toFixed(1)}%`)
.join(' ');
this.compositionText.setText(`Элементный состав: ${compText}`);
this.tweens.add({
targets: this.compositionText,
alpha: 0.5,
duration: 2000,
delay: 500,
});
},
});
// After 6 seconds, start absorb phase
this.time.delayedCall(5500, () => {
this.phaseState = 'absorb';
this.tweens.add({
targets: deathText,
alpha: 0,
duration: 800,
});
});
// After 8 seconds, transition to fractal
this.time.delayedCall(8000, () => {
this.phaseState = 'fadeout';
this.cameras.main.fadeOut(1500, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('FractalScene', {
meta: this.meta,
runState: this.runState,
});
});
});
}
private spawnElementParticles(cx: number, cy: number): void {
const totalParticles = 80;
for (const comp of BODY_COMPOSITION) {
const count = Math.max(1, Math.round(comp.fraction * totalParticles));
const elem = ElementRegistry.getBySymbol(comp.symbol);
const color = elem ? parseInt(elem.color.replace('#', ''), 16) : 0xffffff;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 40 + Math.random() * 120;
this.particles.push({
x: cx + (Math.random() - 0.5) * 6,
y: cy + (Math.random() - 0.5) * 6,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color,
symbol: comp.symbol,
alpha: 0.8 + Math.random() * 0.2,
radius: 2 + comp.fraction * 6,
phase: 'explode',
timer: 0,
});
}
// Add a visible label for the most abundant elements
if (comp.fraction >= 0.03) {
const labelAngle = Math.random() * Math.PI * 2;
const labelDist = 60 + comp.fraction * 200;
const labelText = this.add.text(
cx + Math.cos(labelAngle) * labelDist,
cy + Math.sin(labelAngle) * labelDist,
`${comp.symbol} ${(comp.fraction * 100).toFixed(0)}%`,
{
fontSize: '13px',
color: `#${color.toString(16).padStart(6, '0')}`,
fontFamily: 'monospace',
},
);
labelText.setOrigin(0.5);
labelText.setAlpha(0);
this.tweens.add({
targets: labelText,
alpha: 0.7,
duration: 800,
delay: 200 + Math.random() * 600,
});
this.labelTexts.push(labelText);
}
}
}
update(_time: number, delta: number): void {
this.elapsed += delta;
const dt = delta / 1000;
this.graphics.clear();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
for (const p of this.particles) {
p.timer += delta;
if (this.phaseState === 'decompose') {
// Particles fly outward, then slow down
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx * dt;
p.y += p.vy * dt;
} else if (this.phaseState === 'absorb') {
// Particles get pulled toward center-bottom (into "soil"/Mycelium)
const targetX = cx + (Math.random() - 0.5) * 20;
const targetY = GAME_HEIGHT + 20;
const dx = targetX - p.x;
const dy = targetY - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 1) {
p.vx += (dx / dist) * 200 * dt;
p.vy += (dy / dist) * 200 * dt;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.alpha *= 0.995;
} else {
// Fadeout — everything fades
p.alpha *= 0.97;
}
// Draw particle
if (p.alpha > 0.02) {
this.graphics.fillStyle(p.color, p.alpha);
this.graphics.fillCircle(p.x, p.y, p.radius);
}
}
// Fade labels during absorb
if (this.phaseState === 'absorb' || this.phaseState === 'fadeout') {
for (const label of this.labelTexts) {
label.setAlpha(label.alpha * 0.98);
}
}
// Draw "Mycelium threads" rushing down during absorb
if (this.phaseState === 'absorb') {
this.graphics.lineStyle(1, 0x00ff88, 0.15);
for (let i = 0; i < 5; i++) {
const tx = cx + (Math.random() - 0.5) * 200;
this.graphics.beginPath();
this.graphics.moveTo(tx, cy);
let y = cy;
for (let j = 0; j < 8; j++) {
y += 30 + Math.random() * 20;
this.graphics.lineTo(tx + (Math.random() - 0.5) * 30, y);
}
this.graphics.strokePath();
}
}
}
}

335
src/scenes/FractalScene.ts Normal file
View File

@@ -0,0 +1,335 @@
/**
* FractalScene — "Момент между" (Moment Between)
*
* GDD spec:
* 10-30 seconds of pure fractal visual. Myriad cycles flash before eyes.
* Looping patterns of births and deaths, infinitely nested.
* This is a REWARD, not punishment for dying.
*
* Uses a WebGL shader (Mandelbrot/Julia set hybrid) with cycling parameters.
* Falls back to a simpler canvas animation if WebGL pipeline unavailable.
*/
import Phaser from 'phaser';
import type { MetaState, RunState } from '../run/types';
import { applyRunResults } from '../run/meta';
import { saveMetaState } from '../run/persistence';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
// ─── Fractal Fragment Shader ─────────────────────────────────────
const FRACTAL_FRAG = `
precision mediump float;
uniform float uTime;
uniform vec2 uResolution;
uniform float uZoom;
uniform vec2 uCenter;
uniform float uMorphFactor;
vec3 palette(float t) {
// Cyclic color palette — greens/teals/purples (Synthesis color scheme)
vec3 a = vec3(0.02, 0.05, 0.03);
vec3 b = vec3(0.0, 0.6, 0.4);
vec3 c = vec3(1.0, 1.0, 1.0);
vec3 d = vec3(0.0, 0.33, 0.53);
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (gl_FragCoord.xy - uResolution * 0.5) / min(uResolution.x, uResolution.y);
uv = uv / uZoom + uCenter;
// Morph between Mandelbrot and Julia set
vec2 c = mix(uv, vec2(
0.355 + sin(uTime * 0.1) * 0.1,
0.355 + cos(uTime * 0.13) * 0.1
), uMorphFactor);
vec2 z = mix(vec2(0.0), uv, uMorphFactor);
z = mix(uv, z, uMorphFactor);
float iterations = 0.0;
const float maxIter = 128.0;
for (float i = 0.0; i < maxIter; i++) {
// z = z^2 + c with slight twist
float xTemp = z.x * z.x - z.y * z.y + c.x;
z.y = 2.0 * z.x * z.y + c.y;
z.x = xTemp;
if (dot(z, z) > 4.0) {
iterations = i;
break;
}
iterations = i;
}
// Smooth coloring
float smoothIter = iterations;
if (iterations < maxIter - 1.0) {
float logZn = log(dot(z, z)) / 2.0;
float nu = log(logZn / log(2.0)) / log(2.0);
smoothIter = iterations + 1.0 - nu;
}
float t = smoothIter / maxIter;
t = t + uTime * 0.03; // Slow color cycling
vec3 color = palette(t);
// Vignette — darker at edges
vec2 vigUv = gl_FragCoord.xy / uResolution;
float vig = 1.0 - dot(vigUv - 0.5, vigUv - 0.5) * 1.5;
color *= vig;
// Inside the set — deep dark with faint glow
if (iterations >= maxIter - 1.0) {
color = vec3(0.0, 0.02, 0.01) + 0.03 * sin(uTime * vec3(0.7, 1.1, 0.9));
}
gl_FragColor = vec4(color, 1.0);
}
`;
/** Minimal vertex shader — just pass through */
const FRACTAL_VERT = `
precision mediump float;
attribute vec2 inPosition;
void main() {
gl_Position = vec4(inPosition, 0.0, 1.0);
}
`;
export class FractalScene extends Phaser.Scene {
private meta!: MetaState;
private runState!: RunState;
private elapsed = 0;
private duration = 12000; // 12 seconds default
private shaderEnabled = false;
private customPipeline: Phaser.Renderer.WebGL.WebGLPipeline | null = null;
private isRenewal = false;
private previousCycle = 1;
// Fallback canvas animation state
private fallbackGraphics!: Phaser.GameObjects.Graphics;
private fallbackParticles: { x: number; y: number; r: number; angle: number; speed: number; color: number }[] = [];
constructor() {
super({ key: 'FractalScene' });
}
init(data: { meta: MetaState; runState: RunState }): void {
this.meta = data.meta;
this.runState = data.runState;
this.elapsed = 0;
// More discoveries → longer fractal (reward for exploration)
const discCount = this.runState.discoveries.elements.size
+ this.runState.discoveries.reactions.size
+ this.runState.discoveries.compounds.size
+ this.runState.discoveries.creatures.size;
this.duration = Math.min(25000, 10000 + discCount * 1000);
}
create(): void {
this.cameras.main.setBackgroundColor('#000000');
// Apply run results to meta and save; check for Great Renewal
this.previousCycle = this.meta.greatCycle.cycleNumber;
this.isRenewal = applyRunResults(this.meta, this.runState);
saveMetaState(this.meta).catch(() => {
// Silently fail — game continues even if persistence fails
});
// Try to create WebGL shader
this.shaderEnabled = this.tryCreateShader();
if (!this.shaderEnabled) {
this.createFallbackAnimation();
}
// "Spores earned" text (appears after a moment)
const sporesText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 60,
`+${this.meta.runHistory[this.meta.runHistory.length - 1]?.sporesEarned ?? 0} спор`, {
fontSize: '16px',
color: '#00ff88',
fontFamily: 'monospace',
});
sporesText.setOrigin(0.5);
sporesText.setAlpha(0);
sporesText.setDepth(100);
this.tweens.add({
targets: sporesText,
alpha: 0.8,
duration: 1000,
delay: 3000,
onComplete: () => {
this.tweens.add({
targets: sporesText,
alpha: 0,
duration: 2000,
delay: 3000,
});
},
});
// Skip hint
const skipText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 25,
'[ click to skip ]', {
fontSize: '10px',
color: '#333333',
fontFamily: 'monospace',
});
skipText.setOrigin(0.5);
skipText.setDepth(100);
// Click to skip (after minimum 3 seconds)
this.input.on('pointerdown', () => {
if (this.elapsed > 3000) {
this.transitionToCradle();
}
});
}
private tryCreateShader(): boolean {
try {
const renderer = this.renderer;
if (!(renderer instanceof Phaser.Renderer.WebGL.WebGLRenderer)) {
return false;
}
// Use Phaser's shader support via a fullscreen quad
const baseShader = new Phaser.Display.BaseShader(
'fractal',
FRACTAL_FRAG,
FRACTAL_VERT,
{
uTime: { type: '1f', value: 0.0 },
uResolution: { type: '2f', value: { x: GAME_WIDTH, y: GAME_HEIGHT } },
uZoom: { type: '1f', value: 0.5 },
uCenter: { type: '2f', value: { x: -0.5, y: 0.0 } },
uMorphFactor: { type: '1f', value: 0.0 },
},
);
const shader = this.add.shader(baseShader, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT);
shader.setDepth(0);
return true;
} catch {
return false;
}
}
private createFallbackAnimation(): void {
// Canvas-based fractal-ish animation as fallback
this.fallbackGraphics = this.add.graphics();
this.fallbackParticles = [];
for (let i = 0; i < 200; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = 20 + Math.random() * 300;
this.fallbackParticles.push({
x: GAME_WIDTH / 2 + Math.cos(angle) * dist,
y: GAME_HEIGHT / 2 + Math.sin(angle) * dist,
r: 1 + Math.random() * 4,
angle,
speed: 0.2 + Math.random() * 0.8,
color: Phaser.Display.Color.HSLToColor(
0.3 + Math.random() * 0.3,
0.6,
0.3 + Math.random() * 0.3,
).color,
});
}
}
update(_time: number, delta: number): void {
this.elapsed += delta;
if (this.shaderEnabled) {
// Update shader uniforms via the shader game object
const shaders = this.children.list.filter(
(child): child is Phaser.GameObjects.Shader => child instanceof Phaser.GameObjects.Shader,
);
if (shaders.length > 0) {
const shader = shaders[0];
const t = this.elapsed / 1000;
shader.setUniform('uTime.value', t);
// Slowly zoom in
const zoom = 0.5 + t * 0.03;
shader.setUniform('uZoom.value', zoom);
// Slowly morph from Mandelbrot to Julia
const morph = Math.min(1.0, t / (this.duration / 1000) * 2);
shader.setUniform('uMorphFactor.value', morph);
// Pan slowly
shader.setUniform('uCenter.value.x', -0.5 + Math.sin(t * 0.05) * 0.2);
shader.setUniform('uCenter.value.y', Math.cos(t * 0.07) * 0.15);
}
} else {
// Fallback animation
this.updateFallbackAnimation(delta);
}
// Auto-transition after duration
if (this.elapsed >= this.duration) {
this.transitionToCradle();
}
}
private updateFallbackAnimation(delta: number): void {
if (!this.fallbackGraphics) return;
this.fallbackGraphics.clear();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const t = this.elapsed / 1000;
for (const p of this.fallbackParticles) {
// Spiral motion
p.angle += p.speed * (delta / 1000);
const dist = 50 + Math.sin(t * 0.5 + p.angle * 3) * 100 + Math.cos(t * 0.3) * 50;
p.x = cx + Math.cos(p.angle) * dist;
p.y = cy + Math.sin(p.angle) * dist;
const alpha = 0.3 + Math.sin(t + p.angle * 2) * 0.3;
this.fallbackGraphics.fillStyle(p.color, Math.max(0.05, alpha));
this.fallbackGraphics.fillCircle(p.x, p.y, p.r);
// Connect nearby particles with dim lines
if (Math.random() < 0.02) {
const other = this.fallbackParticles[Math.floor(Math.random() * this.fallbackParticles.length)];
this.fallbackGraphics.lineStyle(1, 0x00ff88, 0.05);
this.fallbackGraphics.beginPath();
this.fallbackGraphics.moveTo(p.x, p.y);
this.fallbackGraphics.lineTo(other.x, other.y);
this.fallbackGraphics.strokePath();
}
}
}
private transitionToCradle(): void {
// Prevent double-transition
if ((this as unknown as { _transitioning?: boolean })._transitioning) return;
(this as unknown as { _transitioning?: boolean })._transitioning = true;
this.cameras.main.fadeOut(1500, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
if (this.isRenewal) {
// Great Renewal — show special scene before returning to Cradle
this.scene.start('RenewalScene', {
meta: this.meta,
previousCycle: this.previousCycle,
});
} else {
this.scene.start('CradleScene', { meta: this.meta });
}
});
}
}

974
src/scenes/GameScene.ts Normal file
View File

@@ -0,0 +1,974 @@
import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
import { Health, Position, Creature, LifeCycle } from '../ecs/components';
import { movementSystem } from '../ecs/systems/movement';
import { healthSystem } from '../ecs/systems/health';
import { removeGameEntity } from '../ecs/factory';
import { PhaserBridge } from '../ecs/bridge';
import biomeDataArray from '../data/biomes.json';
import speciesDataArray from '../data/creatures.json';
import schoolsData from '../data/schools.json';
import type { BiomeData } from '../world/types';
import { generateWorld } from '../world/generator';
import { createWorldTilemap } from '../world/tilemap';
import { setupPlayerCamera } from '../world/camera';
import { Minimap } from '../world/minimap';
import { playerInputSystem } from '../player/input';
import { tileCollisionSystem, buildWalkableSet } from '../player/collision';
import { findSpawnPosition } from '../player/spawn';
import { createPlayerEntity } from '../player/factory';
import { Inventory } from '../player/inventory';
import { interactionSystem, type ResourceInfo } from '../player/interaction';
import { spawnResources } from '../world/resources';
import {
launchProjectile,
projectileSystem,
type ProjectileData,
} from '../player/projectile';
import { QuickSlots } from '../player/quickslots';
import type { InputState } from '../player/types';
import type { SpeciesData, CreatureInfo } from '../creatures/types';
import { SpeciesRegistry } from '../creatures/types';
import { aiSystem } from '../creatures/ai';
import { metabolismSystem, clearMetabolismTracking } from '../creatures/metabolism';
import { lifeCycleSystem } from '../creatures/lifecycle';
import {
countPopulations,
spawnInitialCreatures,
reproduce,
} from '../creatures/population';
import {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from '../creatures/interaction';
import { query } from 'bitecs';
// Run cycle imports
import type { MetaState, SchoolData, RunState, ResolvedSchoolBonuses } from '../run/types';
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
import { getSchoolBonuses } from '../run/meta';
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
getCrisisPlayerDamage,
getCrisisTint,
CHEMICAL_PLAGUE,
type CrisisState,
} from '../run/crisis';
import { getEscalationEffects } from '../run/escalation';
// UI zoom compensation
import { fixToScreen } from '../ui/screen-fix';
// 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';
// World traces (Great Cycle)
import { spawnWorldTraces, updateTraceGlow, type WorldTraceInfo } from '../world/traces';
import { CYCLE_THEME_NAMES_RU } from '../run/types';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
private minimap!: Minimap;
private statsText!: Phaser.GameObjects.Text;
private worldSeed!: number;
// Player state
private playerEid!: number;
private inventory!: Inventory;
private walkableSet!: Set<number>;
private worldGrid!: number[][];
private tileSize!: number;
private resourceData!: Map<number, ResourceInfo>;
private projectileData!: Map<number, ProjectileData>;
private quickSlots!: QuickSlots;
private keys!: {
W: Phaser.Input.Keyboard.Key;
A: Phaser.Input.Keyboard.Key;
S: Phaser.Input.Keyboard.Key;
D: Phaser.Input.Keyboard.Key;
E: Phaser.Input.Keyboard.Key;
F: Phaser.Input.Keyboard.Key;
ONE: Phaser.Input.Keyboard.Key;
TWO: Phaser.Input.Keyboard.Key;
THREE: Phaser.Input.Keyboard.Key;
FOUR: Phaser.Input.Keyboard.Key;
};
// Creature state
private speciesRegistry!: SpeciesRegistry;
private speciesLookup!: Map<number, SpeciesData>;
private creatureData!: Map<number, CreatureInfo>;
// Interaction feedback
private interactionText!: Phaser.GameObjects.Text;
private interactionTimer = 0;
private wasEDown = false;
private wasFDown = false;
// Run cycle state
private meta!: MetaState;
private runState!: RunState;
private crisisState: CrisisState | null = null;
private phaseText!: Phaser.GameObjects.Text;
private playerDead = false;
private crisisOverlay!: Phaser.GameObjects.Rectangle;
// Mycelium state
private fungalNodeData!: Map<number, FungalNodeInfo>;
private mycosisState!: MycosisState;
private mycosisOverlay!: Phaser.GameObjects.Rectangle;
private memoryFlashText!: Phaser.GameObjects.Text;
private memoryFlashTimer = 0;
private fungalNodeGlowGraphics!: Phaser.GameObjects.Graphics;
private hasDepositedThisRun = false;
// World traces from past runs
private worldTraceData: WorldTraceInfo[] = [];
private worldTraceGraphics!: Phaser.GameObjects.Graphics;
constructor() {
super({ key: 'GameScene' });
}
// Purchased bonuses from Cradle shop
private purchasedEffects: import('../mycelium/types').BonusEffect[] = [];
// Biome selection
private biomeId = 'catalytic-wastes';
// School bonuses (resolved from school data)
private schoolBonuses!: ResolvedSchoolBonuses;
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[]; biomeId?: string }): void {
this.meta = data.meta;
this.biomeId = data.biomeId ?? 'catalytic-wastes';
this.runState = createRunState(data.runId, data.schoolId, this.biomeId);
this.crisisState = null;
this.playerDead = false;
this.mycosisState = createMycosisState();
this.hasDepositedThisRun = false;
this.memoryFlashTimer = 0;
this.purchasedEffects = data.purchasedEffects ?? [];
this.schoolBonuses = getSchoolBonuses(data.schoolId);
}
create(): void {
// 1. Initialize ECS
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
this.projectileData = new Map();
// 2. Generate world — use selected biome
const biome = (biomeDataArray as BiomeData[]).find(b => b.id === this.biomeId) ?? biomeDataArray[0] as BiomeData;
this.worldSeed = Date.now() % 1000000;
this.runState.worldSeed = this.worldSeed;
const worldData = generateWorld(biome, this.worldSeed);
// 3. Create tilemap
createWorldTilemap(this, worldData);
// 4. Build walkable set + store world data for collision
this.walkableSet = buildWalkableSet(biome.tiles);
this.worldGrid = worldData.grid;
this.tileSize = biome.tileSize;
// 5. Spawn resource entities (mineral veins, geysers)
this.resourceData = spawnResources(
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,
);
// 5c. Spawn world traces from past runs (Great Cycle)
this.worldTraceData = spawnWorldTraces(
this.gameWorld.world, this.meta.greatCycle, this.biomeId, biome,
);
this.worldTraceGraphics = this.add.graphics();
this.worldTraceGraphics.setDepth(4); // above tiles, below entities
// 6. Initialize creature systems — filter by biome
const allSpecies = speciesDataArray as SpeciesData[];
const biomeSpecies = allSpecies.filter(s => s.biome === biome.id);
this.speciesRegistry = new SpeciesRegistry(biomeSpecies);
this.speciesLookup = new Map<number, SpeciesData>();
for (const s of biomeSpecies) {
this.speciesLookup.set(s.speciesId, s);
}
// 7. Spawn creatures across the map
this.creatureData = spawnInitialCreatures(
this.gameWorld.world, worldData.grid, biome, this.worldSeed, biomeSpecies,
);
// 8. Create player at spawn position + inventory with starting kit
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
this.inventory = new Inventory(500, 20);
this.quickSlots = new QuickSlots();
// Give starting elements from chosen school + apply purchased bonuses
this.giveStartingKit();
this.applyPurchasedEffects();
// 9. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
setupPlayerCamera(this, worldPixelW, worldPixelH);
// Sync bridge to create sprites, then attach camera follow to player
this.bridge.sync(this.gameWorld.world);
const playerSprite = this.bridge.getSprite(this.playerEid);
if (playerSprite) {
playerSprite.setDepth(10);
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
}
// 8. Keyboard input
const keyboard = this.input.keyboard;
if (!keyboard) throw new Error('Keyboard plugin not available');
this.keys = {
W: keyboard.addKey('W'),
A: keyboard.addKey('A'),
S: keyboard.addKey('S'),
D: keyboard.addKey('D'),
E: keyboard.addKey('E'),
F: keyboard.addKey('F'),
ONE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE),
TWO: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO),
THREE: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE),
FOUR: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR),
};
// 9. Minimap
this.minimap = new Minimap(this, worldData);
// 10. UI overlay
this.statsText = this.add.text(10, 10, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 4, y: 2 },
});
this.statsText.setScrollFactor(0);
this.statsText.setDepth(100);
// Interaction feedback text (center-bottom of screen)
this.interactionText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height - 40, '', {
fontSize: '14px',
color: '#ffdd44',
fontFamily: 'monospace',
backgroundColor: '#000000cc',
padding: { x: 6, y: 3 },
},
);
this.interactionText.setScrollFactor(0);
this.interactionText.setOrigin(0.5);
this.interactionText.setDepth(100);
this.interactionText.setAlpha(0);
// Phase indicator (top-center)
this.phaseText = this.add.text(
this.cameras.main.width / 2, 12, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 6, y: 2 },
},
);
this.phaseText.setScrollFactor(0);
this.phaseText.setOrigin(0.5, 0);
this.phaseText.setDepth(100);
// Crisis overlay (full-screen tinted rectangle, hidden by default)
this.crisisOverlay = this.add.rectangle(
this.cameras.main.width / 2, this.cameras.main.height / 2,
this.cameras.main.width, this.cameras.main.height,
0x88ff88, 0,
);
this.crisisOverlay.setScrollFactor(0);
this.crisisOverlay.setDepth(90);
// 10b. Mycosis overlay (full-screen tinted rectangle, hidden by default)
this.mycosisOverlay = this.add.rectangle(
this.cameras.main.width / 2, this.cameras.main.height / 2,
this.cameras.main.width, this.cameras.main.height,
MYCOSIS_CONFIG.tintColor, 0,
);
this.mycosisOverlay.setScrollFactor(0);
this.mycosisOverlay.setDepth(91);
// 10c. Fungal node glow graphics (world-space, under entities)
this.fungalNodeGlowGraphics = this.add.graphics();
this.fungalNodeGlowGraphics.setDepth(2);
// 10d. Memory flash text (center of screen, fades in/out)
this.memoryFlashText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height / 2, '', {
fontSize: '16px',
color: '#88ffaa',
fontFamily: 'monospace',
backgroundColor: '#0a1a0fcc',
padding: { x: 12, y: 8 },
align: 'center',
wordWrap: { width: 400 },
},
);
this.memoryFlashText.setScrollFactor(0);
this.memoryFlashText.setOrigin(0.5);
this.memoryFlashText.setDepth(105);
this.memoryFlashText.setAlpha(0);
// 11. Launch UIScene overlay
this.scene.launch('UIScene');
// Transition from Awakening to Exploration after a moment
this.time.delayedCall(500, () => {
advancePhase(this.runState); // Awakening → Exploration
});
// Debug: expose kill method for testing death cycle
(this as unknown as Record<string, unknown>).__debugKill = () => {
Health.current[this.playerEid] = 0;
};
}
/** Apply purchased spore bonuses from the Cradle shop */
private applyPurchasedEffects(): void {
for (const effect of this.purchasedEffects) {
switch (effect.type) {
case 'extra_health':
Health.max[this.playerEid] = (Health.max[this.playerEid] ?? 100) + effect.amount;
Health.current[this.playerEid] = Health.max[this.playerEid];
break;
case 'extra_element':
for (let i = 0; i < effect.quantity; i++) {
this.inventory.addItem(effect.symbol);
}
this.quickSlots.autoAssign(effect.symbol);
break;
case 'knowledge_boost':
// Stored for use when extracting memory flashes
// (increases clarity of extracted flashes)
break;
}
}
}
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData as unknown as SchoolData[];
const school = schools.find(s => s.id === this.runState.schoolId);
if (!school) return;
for (const symbol of school.startingElements) {
const qty = school.startingQuantities[symbol] ?? 1;
for (let i = 0; i < qty; i++) {
this.inventory.addItem(symbol);
}
this.quickSlots.autoAssign(symbol);
// Record discovery of starting elements
recordDiscovery(this.runState, 'element', symbol);
}
}
update(_time: number, delta: number): void {
// Skip updates if death transition is in progress
if (this.playerDead) return;
// 1. Update world time
updateTime(this.gameWorld, delta);
// 1a. Update run state timers
this.runState.elapsed += delta;
this.runState.phaseTimer += delta;
this.updateRunPhase(delta);
// 2. Read keyboard → InputState
const input: InputState = {
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
interact: this.keys.E.isDown,
};
// 3. Player input → velocity (Navigator school gets speed bonus)
playerInputSystem(this.gameWorld.world, input, this.schoolBonuses.movementSpeed);
// 4. Movement (all entities including projectiles)
movementSystem(this.gameWorld.world, delta);
// 5. Tile collision (player only)
tileCollisionSystem(
this.gameWorld.world,
delta,
this.worldGrid,
this.tileSize,
this.walkableSet,
);
// 6. Projectile system (lifetime + tile collision)
projectileSystem(
this.gameWorld.world,
delta,
this.worldGrid,
this.tileSize,
this.walkableSet,
this.projectileData,
);
// 7. E key interaction (debounced) — fungal node has priority, then resources
const isEDown = this.keys.E.isDown;
const justPressedE = isEDown && !this.wasEDown;
this.wasEDown = isEDown;
// Check if player is near a fungal node first
const nearbyFungalNode = this.getNearestFungalNode();
if (justPressedE && nearbyFungalNode !== null) {
// Fungal node interaction takes priority
this.interactWithFungalNode(nearbyFungalNode);
} else {
// Normal resource interaction
const interaction = interactionSystem(
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
);
if (interaction) {
if (interaction.type === 'collected' || interaction.type === 'depleted') {
if (interaction.itemId) {
this.quickSlots.autoAssign(interaction.itemId);
recordDiscovery(this.runState, 'element', interaction.itemId);
}
}
this.showInteractionFeedback(interaction.type, interaction.itemId);
}
}
// 8. Quick slot selection (1-4 keys)
if (this.keys.ONE.isDown) this.quickSlots.setActive(0);
if (this.keys.TWO.isDown) this.quickSlots.setActive(1);
if (this.keys.THREE.isDown) this.quickSlots.setActive(2);
if (this.keys.FOUR.isDown) this.quickSlots.setActive(3);
// 9. Throw projectile (F key, debounced) — uses active quick slot
const isFDown = this.keys.F.isDown;
const justPressedF = isFDown && !this.wasFDown;
this.wasFDown = isFDown;
if (justPressedF) {
this.tryLaunchProjectile();
}
// 9a. Creature AI — aggro range affected by Naturalist school bonus
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
this.schoolBonuses.creatureAggroRange,
);
// 9b. Creature metabolism (feeding, energy drain)
metabolismSystem(
this.gameWorld.world, delta,
this.resourceData, this.gameWorld.time.elapsed,
);
// 9c. Creature life cycle (aging, stage transitions)
const lcEvents = lifeCycleSystem(
this.gameWorld.world, delta, this.speciesLookup,
);
// 9d. Handle reproduction events
const populations = countPopulations(this.gameWorld.world);
for (const event of lcEvents) {
if (event.type === 'ready_to_reproduce') {
const species = this.speciesLookup.get(event.speciesId);
if (species) {
const currentPop = populations.get(event.speciesId) ?? 0;
reproduce(
this.gameWorld.world, event.eid, species,
currentPop, this.creatureData,
);
}
}
}
// 9e. Projectile-creature collision (Mechanic school gets damage bonus)
creatureProjectileSystem(
this.gameWorld.world, this.projectileData, this.speciesLookup,
this.schoolBonuses.projectileDamage,
);
// 9f. Creature attacks on player
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
// 9g. Mycelium — fungal node glow, interaction, mycosis
this.updateMycelium(delta);
// 9h. Crisis damage (if active)
if (this.crisisState?.active) {
applyCrisisDamage(this.crisisState, delta);
const crisisDmg = getCrisisPlayerDamage(this.crisisState, delta);
if (crisisDmg > 0) {
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - crisisDmg,
);
}
// Update crisis visual tint
const tint = getCrisisTint(this.crisisState);
this.crisisOverlay.setFillStyle(tint.color, tint.alpha);
}
// 9i. Environmental damage from high escalation
const escalationFx = getEscalationEffects(this.runState.escalation);
if (escalationFx.environmentalDamage > 0) {
const envDmg = escalationFx.environmentalDamage * (delta / 1000);
Health.current[this.playerEid] = Math.max(
0,
(Health.current[this.playerEid] ?? 0) - envDmg,
);
}
// 10. Health / death
const dead = healthSystem(this.gameWorld.world);
let playerDied = false;
for (const eid of dead) {
if (eid === this.playerEid) {
playerDied = true;
continue; // Don't remove player entity yet
}
// Clean up creature tracking
if (this.creatureData.has(eid)) {
clearMetabolismTracking(eid);
this.creatureData.delete(eid);
}
removeGameEntity(this.gameWorld.world, eid);
}
// Handle player death → transition to DeathScene
if (playerDied) {
this.onPlayerDeath();
return;
}
// 10. Render sync
this.bridge.sync(this.gameWorld.world);
// 11. Minimap viewport
this.minimap.update(this.cameras.main);
// 12. Fade interaction text
if (this.interactionTimer > 0) {
this.interactionTimer -= delta;
if (this.interactionTimer <= 0) {
this.interactionText.setAlpha(0);
}
}
// 13. Push shared state to registry for UIScene
this.registry.set('health', Health.current[this.playerEid] ?? 100);
this.registry.set('healthMax', Health.max[this.playerEid] ?? 100);
this.registry.set('quickSlots', this.quickSlots.getAll());
this.registry.set('activeSlot', this.quickSlots.activeIndex);
this.registry.set('invWeight', this.inventory.getTotalWeight());
this.registry.set('invMaxWeight', this.inventory.maxWeight);
this.registry.set('invSlots', this.inventory.slotCount);
// Build counts map for UIScene
const counts = new Map<string, number>();
for (const item of this.inventory.getItems()) {
counts.set(item.id, item.count);
}
this.registry.set('invCounts', counts);
// 14. Push cycle info to registry for UIScene
this.registry.set('cycleNumber', this.meta.greatCycle.cycleNumber);
this.registry.set('runInCycle', this.meta.greatCycle.runInCycle);
this.registry.set('cycleThemeRu', CYCLE_THEME_NAMES_RU[this.meta.greatCycle.theme]);
this.registry.set('runPhaseRu', RUN_PHASE_NAMES_RU[this.runState.phase]);
// 15. Creature observation for UIScene
const nearbyCreatures = getObservableCreatures(this.gameWorld.world);
if (nearbyCreatures.length > 0) {
const closest = nearbyCreatures[0];
const species = this.speciesLookup.get(closest.speciesId);
this.registry.set('observedCreature', {
name: species?.name ?? 'Unknown',
healthPercent: closest.healthPercent,
energyPercent: closest.energyPercent,
stage: closest.stage,
});
// Record creature observation as discovery
if (species) {
recordDiscovery(this.runState, 'creature', species.id);
}
} else {
this.registry.set('observedCreature', null);
}
// 16. Population counts for debug
const popCounts = countPopulations(this.gameWorld.world);
this.registry.set('creaturePopulations', popCounts);
// 17. Debug stats overlay + phase indicator
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
const px = Math.round(Position.x[this.playerEid]);
const py = Math.round(Position.y[this.playerEid]);
const creatureCount = [...popCounts.values()].reduce((a, b) => a + b, 0);
this.statsText.setText(
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`,
);
// Update phase indicator
const phaseName = RUN_PHASE_NAMES_RU[this.runState.phase];
const escalationPct = Math.round(this.runState.escalation * 100);
const crisisInfo = this.crisisState?.active
? ` | ☠ ЧУМА: ${Math.round(this.crisisState.progress * 100)}%`
: this.runState.crisisResolved ? ' | ✓ Чума нейтрализована' : '';
const escFx = getEscalationEffects(this.runState.escalation);
const speedInfo = escFx.creatureSpeedMultiplier > 1.05
? ` | Агрессия: ×${escFx.creatureAttackMultiplier.toFixed(1)}`
: '';
this.phaseText.setText(`${phaseName} | Энтропия: ${escalationPct}%${speedInfo}${crisisInfo}`);
// Color phase text based on danger
if (this.runState.phase >= RunPhase.Crisis) {
this.phaseText.setColor('#ff4444');
} else if (this.runState.phase >= RunPhase.Escalation) {
this.phaseText.setColor('#ffaa00');
} else {
this.phaseText.setColor('#00ff88');
}
// Fix UI element positions for current camera zoom.
// setScrollFactor(0) prevents scroll but NOT zoom displacement.
const cam = this.cameras.main;
fixToScreen(this.statsText, 10, 30, cam);
fixToScreen(this.interactionText, cam.width / 2, cam.height - 40, cam);
fixToScreen(this.phaseText, cam.width / 2, 12, cam);
fixToScreen(this.memoryFlashText, cam.width / 2, cam.height / 2, cam);
fixToScreen(this.crisisOverlay, cam.width / 2, cam.height / 2, cam);
fixToScreen(this.mycosisOverlay, cam.width / 2, cam.height / 2, cam);
}
/** 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,
);
}
// Render world trace glows (past run markers)
this.worldTraceGraphics.clear();
updateTraceGlow(this.worldTraceData, delta);
for (const trace of this.worldTraceData) {
const eid = trace.eid;
const phase = (this.worldTraceGraphics as unknown as { _phase?: number })._phase ?? 0;
const glowPhase = phase + Position.x[eid] * 0.01; // offset per position
const pulse = 0.3 + 0.7 * Math.sin(glowPhase + this.runState.elapsed * 0.001);
const color = trace.traceType === 'death_site' ? 0xaa3333 : 0x4488aa;
const alpha = 0.1 + 0.1 * pulse;
const radius = 8 * (0.7 + 0.3 * pulse);
this.worldTraceGraphics.fillStyle(color, alpha);
this.worldTraceGraphics.fillCircle(Position.x[eid], Position.y[eid], radius);
// Inner core
this.worldTraceGraphics.fillStyle(color, alpha * 2);
this.worldTraceGraphics.fillCircle(Position.x[eid], Position.y[eid], radius * 0.4);
}
// Fade memory flash text
if (this.memoryFlashTimer > 0) {
this.memoryFlashTimer -= delta;
if (this.memoryFlashTimer <= 500) {
this.memoryFlashText.setAlpha(this.memoryFlashTimer / 500);
}
if (this.memoryFlashTimer <= 0) {
this.memoryFlashText.setAlpha(0);
}
}
// Push mycosis data to registry for UIScene
this.registry.set('mycosisLevel', this.mycosisState.level);
this.registry.set('mycosisRevealing', this.mycosisState.revealing);
}
/** Handle interaction with a fungal node */
private interactWithFungalNode(nodeEid: number): void {
// First time interacting in this run → deposit discoveries
if (!this.hasDepositedThisRun) {
const result = depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
const msg = result.newNodes > 0
? `🍄 Мицелий принял ${result.newNodes} знани${result.newNodes === 1 ? 'е' : 'й'}`
: `🍄 Мицелий укрепил ${result.strengthened} связ${result.strengthened === 1 ? 'ь' : 'ей'}`;
this.showInteractionFeedback('collected', msg);
}
// Extract memory flashes from past runs
if (this.meta.mycelium.nodes.length > 0) {
const flashes = extractMemoryFlashes(this.meta.mycelium, 2);
if (flashes.length > 0) {
this.showMemoryFlash(flashes[0]);
}
} else {
this.showInteractionFeedback('collected', '🍄 Мицелий пуст... пока.');
}
}
/** Display a memory flash on screen */
private showMemoryFlash(flash: MemoryFlash): void {
// Show Russian text with clarity indicator
const clarityStars = flash.clarity >= 0.7 ? '★★★' : flash.clarity >= 0.4 ? '★★☆' : '★☆☆';
const text = `${flash.textRu}\n\n[${clarityStars}]`;
this.memoryFlashText.setText(text);
this.memoryFlashText.setAlpha(1);
this.memoryFlashTimer = 4000; // Show for 4 seconds
}
/** Manage run phase transitions and escalation */
private updateRunPhase(delta: number): void {
const phase = this.runState.phase;
const timer = this.runState.phaseTimer;
const duration = PHASE_DURATIONS[phase];
// Update escalation
updateEscalation(this.runState, delta);
// Auto-advance timed phases
if (duration > 0 && timer >= duration) {
if (phase === RunPhase.Exploration) {
advancePhase(this.runState); // → Escalation
} else if (phase === RunPhase.Escalation) {
advancePhase(this.runState); // → Crisis
this.triggerCrisis();
} else if (phase === RunPhase.Resolution) {
// Resolution phase complete → enter boss arena
this.enterBossArena();
}
}
// Trigger crisis when escalation hits threshold (even before phase ends)
if (
phase === RunPhase.Escalation &&
!this.crisisState &&
this.runState.escalation >= CHEMICAL_PLAGUE.triggerThreshold
) {
advancePhase(this.runState); // → Crisis
this.triggerCrisis();
}
}
/** Activate the Chemical Plague crisis */
private triggerCrisis(): void {
this.crisisState = createCrisisState(CHEMICAL_PLAGUE);
this.runState.crisisActive = true;
this.showInteractionFeedback('collected', '⚠ ХИМИЧЕСКАЯ ЧУМА! Создай CaO для нейтрализации!');
// Tint the world slightly toxic via overlay
this.crisisOverlay.setFillStyle(0x88ff88, 0.08);
}
/** Handle player death — start death sequence */
private onPlayerDeath(): void {
this.playerDead = true;
this.runState.alive = false;
// Record death position for great cycle traces
const px = Position.x[this.playerEid] ?? 0;
const py = Position.y[this.playerEid] ?? 0;
this.runState.deathPosition = {
tileX: Math.floor(px / this.tileSize),
tileY: Math.floor(py / this.tileSize),
};
// Auto-deposit all discoveries into Mycelium on death
if (!this.hasDepositedThisRun) {
depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
}
// Stop UIScene
this.scene.stop('UIScene');
// Slow-motion effect
this.cameras.main.fadeOut(2000, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('DeathScene', {
meta: this.meta,
runState: this.runState,
});
});
}
/** Try to launch a projectile from active quick slot toward mouse */
private tryLaunchProjectile(): void {
const itemId = this.quickSlots.getActive();
if (!itemId || !this.inventory.hasItem(itemId)) {
this.showInteractionFeedback('nothing_nearby');
return;
}
const removed = this.inventory.removeItem(itemId, 1);
if (removed === 0) return;
// Get mouse world position for direction
const pointer = this.input.activePointer;
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const px = Position.x[this.playerEid];
const py = Position.y[this.playerEid];
launchProjectile(
this.gameWorld.world,
this.projectileData,
px, py,
worldPoint.x, worldPoint.y,
itemId,
);
// Check if this compound neutralizes the crisis
if (this.crisisState?.active && itemId === CHEMICAL_PLAGUE.neutralizer) {
attemptNeutralize(this.crisisState, itemId, 1);
if (this.crisisState.resolved) {
this.runState.crisisResolved = true;
this.runState.crisisActive = false;
this.crisisOverlay.setFillStyle(0x88ff88, 0);
this.showInteractionFeedback('collected', '✓ Чума нейтрализована!');
advancePhase(this.runState); // Crisis → Resolution
} else {
this.showInteractionFeedback('collected', `Нейтрализация: ${this.crisisState.neutralizeApplied}/${CHEMICAL_PLAGUE.neutralizeAmount}`);
}
}
// Clear quick slot if inventory is now empty for this item
if (!this.inventory.hasItem(itemId)) {
const slotIdx = this.quickSlots.getAll().indexOf(itemId);
if (slotIdx >= 0) this.quickSlots.assign(slotIdx, null);
}
if (!this.crisisState?.active || itemId !== CHEMICAL_PLAGUE.neutralizer) {
this.showInteractionFeedback('collected', `Threw ${itemId}`);
}
}
/** Transition to the boss arena (triggered when Resolution phase ends) */
private enterBossArena(): void {
if (this.playerDead) return;
// Prevent re-entry
this.playerDead = true; // Reuse flag to stop updates
// Auto-deposit discoveries before leaving
if (!this.hasDepositedThisRun) {
depositKnowledge(this.meta.mycelium, this.runState);
this.hasDepositedThisRun = true;
}
this.showInteractionFeedback('collected', '⚔ Вход в арену Уробороса...');
// Fade out and transition
this.time.delayedCall(1500, () => {
this.scene.stop('UIScene');
this.cameras.main.fadeOut(1000, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('BossArenaScene', {
meta: this.meta,
runState: this.runState,
inventoryItems: this.inventory.getItems(),
quickSlotItems: this.quickSlots.getAll(),
activeSlot: this.quickSlots.activeIndex,
playerHealth: Health.current[this.playerEid] ?? 100,
playerMaxHealth: Health.max[this.playerEid] ?? 100,
});
});
});
}
private showInteractionFeedback(type: string, itemId?: string): void {
let msg = '';
switch (type) {
case 'collected':
msg = itemId?.startsWith('Threw') || itemId?.startsWith('⚠') || itemId?.startsWith('✓') || itemId?.startsWith('Нейтрализация')
? itemId
: `+1 ${itemId ?? ''}`;
break;
case 'depleted':
msg = `+1 ${itemId ?? ''} (depleted)`;
break;
case 'inventory_full':
msg = `Inventory full! Can't pick up ${itemId ?? ''}`;
break;
case 'nothing_nearby':
msg = 'Nothing to throw / interact with';
break;
}
this.interactionText.setText(msg);
this.interactionText.setAlpha(1);
this.interactionTimer = 1500;
}
}

265
src/scenes/RenewalScene.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* RenewalScene — Great Renewal (Великое Обновление)
*
* GDD spec:
* After every 7th run, the world experiences a Great Renewal:
* - World generation fundamentally changes
* - New lore layer unlocks
* - Mycelium "matures" — opens new capabilities
* - Previous 7 runs leave traces in the next cycle
*
* Visual: A special transition between great cycles with
* pulsing Mycelium, cycle counter, and narrative theme reveal.
*/
import Phaser from 'phaser';
import type { MetaState, CycleTheme } from '../run/types';
import { CYCLE_THEME_NAMES_RU } from '../run/types';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
import narrativeData from '../data/cycle-narrative.json';
interface ThemeNarrative {
nameRu: string;
cradleQuoteRu: string;
loreFrag: { textRu: string }[];
}
export class RenewalScene extends Phaser.Scene {
private meta!: MetaState;
private previousCycle!: number;
private newCycle!: number;
private newTheme!: CycleTheme;
private elapsed = 0;
private readonly duration = 15000; // 15 seconds
private graphics!: Phaser.GameObjects.Graphics;
private particles: { x: number; y: number; vx: number; vy: number; r: number; alpha: number; color: number }[] = [];
constructor() {
super({ key: 'RenewalScene' });
}
init(data: { meta: MetaState; previousCycle: number }): void {
this.meta = data.meta;
this.previousCycle = data.previousCycle;
this.newCycle = this.meta.greatCycle.cycleNumber;
this.newTheme = this.meta.greatCycle.theme;
this.elapsed = 0;
this.particles = [];
}
create(): void {
this.cameras.main.setBackgroundColor('#010204');
this.graphics = this.add.graphics();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
// Create Mycelium pulse particles (more than fractal — representing the network)
for (let i = 0; i < 150; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * 350;
this.particles.push({
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
vx: (Math.random() - 0.5) * 30,
vy: (Math.random() - 0.5) * 30,
r: 1 + Math.random() * 3,
alpha: Math.random() * 0.4,
color: Phaser.Display.Color.HSLToColor(
0.35 + Math.random() * 0.15, // green-teal hues
0.5 + Math.random() * 0.3,
0.2 + Math.random() * 0.3,
).color,
});
}
// ─── Staged Text Reveals ─────────────────────────────────
// 1. "Великое Обновление" title (fades in at 0s)
const renewalMsg = this.pickRenewalMessage();
const titleText = this.add.text(cx, cy - 100, 'ВЕЛИКОЕ ОБНОВЛЕНИЕ', {
fontSize: '30px',
color: '#00ff88',
fontFamily: 'monospace',
fontStyle: 'bold',
}).setOrigin(0.5).setAlpha(0).setDepth(10);
this.tweens.add({
targets: titleText,
alpha: 1,
duration: 2000,
});
// 2. Renewal flavor message (fades in at 2s)
const flavorText = this.add.text(cx, cy - 55, renewalMsg, {
fontSize: '13px',
color: '#448866',
fontFamily: 'monospace',
}).setOrigin(0.5).setAlpha(0).setDepth(10);
this.tweens.add({
targets: flavorText,
alpha: 0.8,
duration: 1500,
delay: 2000,
});
// 3. Cycle counter "Цикл 1 → Цикл 2" (fades in at 4s)
const cycleText = this.add.text(cx, cy,
`Цикл ${this.previousCycle} → Цикл ${this.newCycle}`, {
fontSize: '22px',
color: '#ffffff',
fontFamily: 'monospace',
fontStyle: 'bold',
}).setOrigin(0.5).setAlpha(0).setDepth(10);
this.tweens.add({
targets: cycleText,
alpha: 1,
duration: 1500,
delay: 4000,
});
// 4. New theme name (fades in at 6s)
const themeName = CYCLE_THEME_NAMES_RU[this.newTheme];
const themeText = this.add.text(cx, cy + 40,
`Тема: ${themeName}`, {
fontSize: '18px',
color: '#44ddaa',
fontFamily: 'monospace',
}).setOrigin(0.5).setAlpha(0).setDepth(10);
this.tweens.add({
targets: themeText,
alpha: 1,
duration: 1500,
delay: 6000,
});
// 5. Lore fragment from the new theme (fades in at 8s)
const narrative = this.getThemeNarrative(this.newTheme);
if (narrative && narrative.loreFrag.length > 0) {
const fragIdx = this.newCycle % narrative.loreFrag.length;
const loreText = this.add.text(cx, cy + 90,
`«${narrative.loreFrag[fragIdx].textRu}»`, {
fontSize: '12px',
color: '#668866',
fontFamily: 'monospace',
fontStyle: 'italic',
align: 'center',
wordWrap: { width: GAME_WIDTH * 0.7 },
}).setOrigin(0.5).setAlpha(0).setDepth(10);
this.tweens.add({
targets: loreText,
alpha: 0.7,
duration: 2000,
delay: 8000,
});
}
// 6. Mycelium maturation info (fades in at 10s)
const matPct = Math.round(this.meta.greatCycle.myceliumMaturation * 100);
const matText = this.add.text(cx, cy + 140,
`Мицелий созревает... (${matPct}%)`, {
fontSize: '12px',
color: '#33aa66',
fontFamily: 'monospace',
}).setOrigin(0.5).setAlpha(0).setDepth(10);
this.tweens.add({
targets: matText,
alpha: 0.6,
duration: 1500,
delay: 10000,
});
// Skip hint
const skipText = this.add.text(cx, GAME_HEIGHT - 25,
'[ click to skip ]', {
fontSize: '10px',
color: '#333333',
fontFamily: 'monospace',
}).setOrigin(0.5).setDepth(100);
// Click to skip after minimum 5 seconds
this.input.on('pointerdown', () => {
if (this.elapsed > 5000) {
this.transitionToCradle();
}
});
}
update(_time: number, delta: number): void {
this.elapsed += delta;
// Animate Mycelium particles
this.graphics.clear();
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const t = this.elapsed / 1000;
for (const p of this.particles) {
// Spiral inward then outward (breathing effect)
const dx = cx - p.x;
const dy = cy - p.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const breathe = Math.sin(t * 0.5) * 0.3;
p.vx += (dx / dist) * breathe;
p.vy += (dy / dist) * breathe;
p.x += p.vx * (delta / 1000);
p.y += p.vy * (delta / 1000);
// Damping
p.vx *= 0.98;
p.vy *= 0.98;
// Pulsing alpha
p.alpha = 0.1 + Math.sin(t * 1.5 + dist * 0.01) * 0.2;
this.graphics.fillStyle(p.color, Math.max(0.02, p.alpha));
this.graphics.fillCircle(p.x, p.y, p.r);
// Mycelium threads — connecting nearby particles
if (Math.random() < 0.01) {
const other = this.particles[Math.floor(Math.random() * this.particles.length)];
const threadDist = Math.hypot(p.x - other.x, p.y - other.y);
if (threadDist < 80) {
this.graphics.lineStyle(1, 0x33aa66, 0.08);
this.graphics.beginPath();
this.graphics.moveTo(p.x, p.y);
this.graphics.lineTo(other.x, other.y);
this.graphics.strokePath();
}
}
}
// Auto-transition after duration
if (this.elapsed >= this.duration) {
this.transitionToCradle();
}
}
private pickRenewalMessage(): string {
const messages = narrativeData.renewalMessages;
const idx = this.previousCycle % messages.length;
return messages[idx].textRu;
}
private getThemeNarrative(theme: CycleTheme): ThemeNarrative | null {
const themes = narrativeData.themes as Record<string, ThemeNarrative>;
return themes[theme] ?? null;
}
private transitionToCradle(): void {
if ((this as unknown as { _transitioning?: boolean })._transitioning) return;
(this as unknown as { _transitioning?: boolean })._transitioning = true;
this.cameras.main.fadeOut(2000, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('CradleScene', { meta: this.meta });
});
}
}

226
src/scenes/UIScene.ts Normal file
View File

@@ -0,0 +1,226 @@
/**
* UIScene — HUD overlay on top of GameScene
*
* Renders health bar, quick slots, inventory weight.
* Reads shared game state from Phaser registry.
*/
import Phaser from 'phaser';
import { SLOT_COUNT } from '../player/quickslots';
import { ElementRegistry } from '../chemistry/elements';
import { CompoundRegistry } from '../chemistry/compounds';
const SLOT_SIZE = 44;
const SLOT_GAP = 4;
const HEALTH_BAR_WIDTH = 160;
const HEALTH_BAR_HEIGHT = 14;
/** Get color for a chemical item as number (for rendering) */
function getItemDisplayColor(itemId: string): number {
const el = ElementRegistry.getBySymbol(itemId);
if (el) return parseInt(el.color.replace('#', ''), 16);
const comp = CompoundRegistry.getById(itemId);
if (comp) return parseInt(comp.color.replace('#', ''), 16);
return 0xffffff;
}
/** Get display name for a chemical item */
function getItemDisplayName(itemId: string): string {
const el = ElementRegistry.getBySymbol(itemId);
if (el) return el.symbol;
const comp = CompoundRegistry.getById(itemId);
if (comp) return comp.formula;
return itemId;
}
export class UIScene extends Phaser.Scene {
// Health
private healthBarBg!: Phaser.GameObjects.Rectangle;
private healthBarFill!: Phaser.GameObjects.Rectangle;
private healthText!: Phaser.GameObjects.Text;
// Quick slots
private slotBoxes: Phaser.GameObjects.Rectangle[] = [];
private slotTexts: Phaser.GameObjects.Text[] = [];
private slotNumTexts: Phaser.GameObjects.Text[] = [];
private slotCountTexts: Phaser.GameObjects.Text[] = [];
// Inventory
private invText!: Phaser.GameObjects.Text;
// Controls hint
private controlsText!: Phaser.GameObjects.Text;
// Cycle info (top-left, below health)
private cycleText!: Phaser.GameObjects.Text;
// Run phase (top-left, below cycle)
private phaseText!: Phaser.GameObjects.Text;
constructor() {
super({ key: 'UIScene' });
}
create(): void {
const w = this.cameras.main.width;
const h = this.cameras.main.height;
// === Health bar (top-left) ===
const hpX = 12;
const hpY = 12;
this.healthBarBg = this.add.rectangle(
hpX + HEALTH_BAR_WIDTH / 2, hpY + HEALTH_BAR_HEIGHT / 2,
HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT, 0x330000,
);
this.healthBarBg.setStrokeStyle(1, 0x660000);
this.healthBarFill = this.add.rectangle(
hpX + HEALTH_BAR_WIDTH / 2, hpY + HEALTH_BAR_HEIGHT / 2,
HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT, 0x00cc44,
);
this.healthText = this.add.text(
hpX + HEALTH_BAR_WIDTH + 8, hpY, '', {
fontSize: '12px',
color: '#00ff88',
fontFamily: 'monospace',
},
);
// === Quick slots (bottom-center) ===
const totalW = SLOT_COUNT * SLOT_SIZE + (SLOT_COUNT - 1) * SLOT_GAP;
const startX = (w - totalW) / 2;
const startY = h - SLOT_SIZE - 12;
for (let i = 0; i < SLOT_COUNT; i++) {
const x = startX + i * (SLOT_SIZE + SLOT_GAP);
const cx = x + SLOT_SIZE / 2;
const cy = startY + SLOT_SIZE / 2;
// Slot background
const box = this.add.rectangle(cx, cy, SLOT_SIZE, SLOT_SIZE, 0x111111, 0.85);
box.setStrokeStyle(2, 0x444444);
this.slotBoxes.push(box);
// Slot number (top-left corner)
const numText = this.add.text(x + 3, startY + 2, `${i + 1}`, {
fontSize: '10px',
color: '#555555',
fontFamily: 'monospace',
});
this.slotNumTexts.push(numText);
// Item name (center)
const itemText = this.add.text(cx, cy - 2, '', {
fontSize: '14px',
color: '#ffffff',
fontFamily: 'monospace',
fontStyle: 'bold',
});
itemText.setOrigin(0.5);
this.slotTexts.push(itemText);
// Count (bottom-right)
const countText = this.add.text(x + SLOT_SIZE - 3, startY + SLOT_SIZE - 3, '', {
fontSize: '10px',
color: '#aaaaaa',
fontFamily: 'monospace',
});
countText.setOrigin(1, 1);
this.slotCountTexts.push(countText);
}
// === Inventory info (bottom-right) ===
this.invText = this.add.text(w - 12, h - 14, '', {
fontSize: '11px',
color: '#888888',
fontFamily: 'monospace',
backgroundColor: '#000000aa',
padding: { x: 4, y: 2 },
});
this.invText.setOrigin(1, 1);
// === Cycle info (top-left, below health bar) ===
this.cycleText = this.add.text(12, 32, '', {
fontSize: '10px',
color: '#446644',
fontFamily: 'monospace',
});
// === Run phase (below cycle) ===
this.phaseText = this.add.text(12, 46, '', {
fontSize: '10px',
color: '#557755',
fontFamily: 'monospace',
});
// === Controls hint (top-right) ===
this.controlsText = this.add.text(w - 12, 12, 'WASD move | E collect | F throw | 1-4 slots | scroll zoom', {
fontSize: '10px',
color: '#444444',
fontFamily: 'monospace',
});
this.controlsText.setOrigin(1, 0);
}
update(): void {
// === Read shared state from registry ===
const health = (this.registry.get('health') as number) ?? 100;
const healthMax = (this.registry.get('healthMax') as number) ?? 100;
const slots = (this.registry.get('quickSlots') as (string | null)[]) ?? [];
const activeSlot = (this.registry.get('activeSlot') as number) ?? 0;
const invWeight = (this.registry.get('invWeight') as number) ?? 0;
const invMaxWeight = (this.registry.get('invMaxWeight') as number) ?? 500;
const invSlots = (this.registry.get('invSlots') as number) ?? 0;
const invCounts = (this.registry.get('invCounts') as Map<string, number>) ?? new Map();
// === Update health bar ===
const ratio = healthMax > 0 ? health / healthMax : 0;
const fillWidth = HEALTH_BAR_WIDTH * ratio;
this.healthBarFill.width = fillWidth;
this.healthBarFill.x = this.healthBarBg.x - (HEALTH_BAR_WIDTH - fillWidth) / 2;
// Color: green → yellow → red
const hpColor = ratio > 0.5 ? 0x00cc44 : ratio > 0.25 ? 0xccaa00 : 0xcc2200;
this.healthBarFill.setFillStyle(hpColor);
this.healthText.setText(`${Math.round(health)}/${healthMax}`);
// === Update quick slots ===
for (let i = 0; i < SLOT_COUNT; i++) {
const itemId = i < slots.length ? slots[i] : null;
const isActive = i === activeSlot;
// Border color
this.slotBoxes[i].setStrokeStyle(2, isActive ? 0x00ff88 : 0x444444);
this.slotBoxes[i].setFillStyle(isActive ? 0x1a2a1a : 0x111111, 0.85);
if (itemId) {
const displayName = getItemDisplayName(itemId);
const displayColor = getItemDisplayColor(itemId);
this.slotTexts[i].setText(displayName);
this.slotTexts[i].setColor(`#${displayColor.toString(16).padStart(6, '0')}`);
const count = invCounts.get(itemId) ?? 0;
this.slotCountTexts[i].setText(count > 0 ? `${count}` : '0');
this.slotCountTexts[i].setColor(count > 0 ? '#aaaaaa' : '#660000');
} else {
this.slotTexts[i].setText('');
this.slotCountTexts[i].setText('');
}
}
// === Update inventory ===
this.invText.setText(`${Math.round(invWeight)}/${invMaxWeight} AMU | ${invSlots} items`);
// === Update cycle info ===
const cycleNumber = (this.registry.get('cycleNumber') as number) ?? 1;
const runInCycle = (this.registry.get('runInCycle') as number) ?? 1;
const cycleTheme = (this.registry.get('cycleThemeRu') as string) ?? '';
this.cycleText.setText(`Цикл ${cycleNumber}: ${cycleTheme} | Ран ${runInCycle}/7`);
// === Update run phase ===
const runPhaseRu = (this.registry.get('runPhaseRu') as string) ?? '';
this.phaseText.setText(runPhaseRu);
}
}

31
src/ui/screen-fix.ts Normal file
View File

@@ -0,0 +1,31 @@
import type Phaser from 'phaser';
/**
* Position a scrollFactor(0) game object at fixed screen coordinates,
* compensating for camera zoom.
*
* Phaser's camera zoom scales around the viewport center, which displaces
* scrollFactor(0) objects:
* screenPos = (objPos center) × zoom + center
*
* This function solves for the object position that maps to the desired
* screen pixel, and counter-scales the object so it appears at 1× size:
* objPos = (desiredScreen center) / zoom + center
* scale = baseScale / zoom
*
* Call every frame for each UI element that uses setScrollFactor(0).
*/
export function fixToScreen(
obj: { x: number; y: number; setScale(x: number, y?: number): unknown },
screenX: number,
screenY: number,
camera: Phaser.Cameras.Scene2D.Camera,
baseScale = 1,
): void {
const zoom = camera.zoom;
const cx = camera.width * 0.5;
const cy = camera.height * 0.5;
obj.x = (screenX - cx) / zoom + cx;
obj.y = (screenY - cy) / zoom + cy;
obj.setScale(baseScale / zoom);
}

95
src/world/camera.ts Normal file
View File

@@ -0,0 +1,95 @@
import Phaser from 'phaser';
/** Keyboard keys for camera movement */
export interface CameraKeys {
up: Phaser.Input.Keyboard.Key;
down: Phaser.Input.Keyboard.Key;
left: Phaser.Input.Keyboard.Key;
right: Phaser.Input.Keyboard.Key;
}
/**
* Set up camera with bounds, zoom, and WASD movement
* (Temporary controls — replaced by player follow in Phase 4)
*/
export function setupCamera(
scene: Phaser.Scene,
worldPixelWidth: number,
worldPixelHeight: number,
): CameraKeys {
const camera = scene.cameras.main;
camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight);
camera.setZoom(1);
// Start centered
camera.scrollX = (worldPixelWidth - camera.width) / 2;
camera.scrollY = (worldPixelHeight - camera.height) / 2;
// WASD keys
const keyboard = scene.input.keyboard;
if (!keyboard) {
throw new Error('Keyboard plugin not available');
}
const keys: CameraKeys = {
up: keyboard.addKey('W'),
down: keyboard.addKey('S'),
left: keyboard.addKey('A'),
right: keyboard.addKey('D'),
};
// Mouse wheel zoom (0.5x 3x)
scene.input.on('wheel', (
_pointer: unknown,
_gameObjects: unknown,
_deltaX: number,
deltaY: number,
) => {
const newZoom = Phaser.Math.Clamp(camera.zoom - deltaY * 0.001, 0.5, 3);
camera.setZoom(newZoom);
});
return keys;
}
/** Update camera position based on WASD keys — call each frame */
export function updateCamera(
scene: Phaser.Scene,
keys: CameraKeys,
deltaMs: number,
): void {
const camera = scene.cameras.main;
const speed = 300 / camera.zoom; // faster when zoomed out
const dt = deltaMs / 1000;
if (keys.left.isDown) camera.scrollX -= speed * dt;
if (keys.right.isDown) camera.scrollX += speed * dt;
if (keys.up.isDown) camera.scrollY -= speed * dt;
if (keys.down.isDown) camera.scrollY += speed * dt;
}
/**
* Set up camera for player-follow mode.
* No WASD movement — camera follows the player entity.
* Zoom via mouse wheel, bounds clamped to world size.
*/
export function setupPlayerCamera(
scene: Phaser.Scene,
worldPixelWidth: number,
worldPixelHeight: number,
): void {
const camera = scene.cameras.main;
camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight);
camera.setZoom(2); // closer view for gameplay
// Mouse wheel zoom (0.5x 3x)
scene.input.on('wheel', (
_pointer: unknown,
_gameObjects: unknown,
_deltaX: number,
deltaY: number,
) => {
const newZoom = Phaser.Math.Clamp(camera.zoom - deltaY * 0.001, 0.5, 3);
camera.setZoom(newZoom);
});
}

73
src/world/generator.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { BiomeData, TileGrid, WorldData } from './types';
import { createSeededNoise, sampleNoise, type Noise2D } from './noise';
/**
* Generate a world grid from biome data and a seed
*
* Algorithm:
* 1. Elevation noise → base terrain type (acid pools low, crystals high)
* 2. Detail noise → sparse overlay (geysers near acid, minerals on ground)
* 3. Each tile deterministically chosen from noise values
*/
export function generateWorld(biome: BiomeData, seed: number): WorldData {
const elevationNoise = createSeededNoise(seed);
const detailNoise = createSeededNoise(seed + 7919); // prime offset for independence
const grid: TileGrid = [];
for (let y = 0; y < biome.mapHeight; y++) {
const row: number[] = [];
for (let x = 0; x < biome.mapWidth; x++) {
const elevation = sampleNoise(elevationNoise, x, y, biome.generation.elevationScale);
const detail = sampleNoise(detailNoise, x, y, biome.generation.detailScale);
row.push(determineTile(elevation, detail, biome));
}
grid.push(row);
}
return { grid, biome, seed };
}
/**
* Determine tile type from noise values
*
* Base tile from elevation thresholds, then overlay specials:
* - Geysers spawn on acid-shallow tiles with very high detail noise
* - Mineral veins spawn on walkable ground with high detail noise
*/
function determineTile(elevation: number, detail: number, biome: BiomeData): number {
const gen = biome.generation;
// Base tile from elevation rules (first matching threshold)
let baseTileId = gen.elevationRules[gen.elevationRules.length - 1].tileId;
for (const rule of gen.elevationRules) {
if (elevation < rule.below) {
baseTileId = rule.tileId;
break;
}
}
// Interactive overlay (geysers / steam vents / hollow stumps) on specific base tile + high detail noise
if (baseTileId === gen.geyserOnTile && detail > gen.geyserThreshold) {
return findInteractiveTileId(biome);
}
// Resource overlay (mineral veins / ore deposits / herb patches) on walkable ground + high detail noise
if (gen.mineralOnTiles.includes(baseTileId) && detail > gen.mineralThreshold) {
return findResourceTileId(biome);
}
return baseTileId;
}
/** Find the interactive tile (geyser/steam-vent/hollow-stump), falling back to 0 */
function findInteractiveTileId(biome: BiomeData): number {
const tile = biome.tiles.find(t => t.interactive);
return tile ? tile.id : 0;
}
/** Find the resource tile (mineral-vein/ore-deposit/herb-patch), falling back to 0 */
function findResourceTileId(biome: BiomeData): number {
const tile = biome.tiles.find(t => t.resource);
return tile ? tile.id : 0;
}

140
src/world/minimap.ts Normal file
View File

@@ -0,0 +1,140 @@
import Phaser from 'phaser';
import type { TileData, TileGrid, WorldData } from './types';
import { fixToScreen } from '../ui/screen-fix';
const MINIMAP_DEPTH = 100;
const VIEWPORT_DEPTH = 101;
/**
* Minimap — small overview of the entire world in the top-right corner
*
* Shows tile colors at reduced scale with a white rectangle
* indicating the camera's current viewport
*/
export class Minimap {
private image: Phaser.GameObjects.Image;
private viewport: Phaser.GameObjects.Graphics;
private border: Phaser.GameObjects.Graphics;
private mapWidth: number;
private mapHeight: number;
private tileSize: number;
private minimapScale: number;
constructor(
scene: Phaser.Scene,
worldData: WorldData,
scale: number = 2,
) {
const { grid, biome } = worldData;
this.mapWidth = biome.mapWidth;
this.mapHeight = biome.mapHeight;
this.tileSize = biome.tileSize;
this.minimapScale = scale;
const minimapW = this.mapWidth * scale;
const minimapH = this.mapHeight * scale;
// Generate minimap texture
this.createMinimapTexture(scene, grid, biome.tiles, scale);
// Position in top-right corner
const screenW = scene.cameras.main.width;
const padding = 10;
const right = screenW - padding;
const top = padding;
// Border
this.border = scene.add.graphics();
this.border.setScrollFactor(0);
this.border.setDepth(MINIMAP_DEPTH);
this.border.lineStyle(2, 0x00ff88, 0.6);
this.border.strokeRect(
right - minimapW - 1,
top - 1,
minimapW + 2,
minimapH + 2,
);
// Semi-transparent background
this.border.fillStyle(0x000000, 0.4);
this.border.fillRect(right - minimapW, top, minimapW, minimapH);
// Minimap image
this.image = scene.add.image(right, top, 'minimap');
this.image.setOrigin(1, 0);
this.image.setScrollFactor(0);
this.image.setDepth(MINIMAP_DEPTH);
// Viewport indicator
this.viewport = scene.add.graphics();
this.viewport.setScrollFactor(0);
this.viewport.setDepth(VIEWPORT_DEPTH);
}
/** Create canvas texture for minimap (1 pixel per tile * scale) */
private createMinimapTexture(
scene: Phaser.Scene,
grid: TileGrid,
tiles: TileData[],
scale: number,
): void {
const h = grid.length;
const w = grid[0].length;
const canvasW = w * scale;
const canvasH = h * scale;
// Remove old texture if re-entering scene (run cycle)
if (scene.textures.exists('minimap')) {
scene.textures.remove('minimap');
}
const canvasTexture = scene.textures.createCanvas('minimap', canvasW, canvasH);
const ctx = canvasTexture.getContext();
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const tileId = grid[y][x];
ctx.fillStyle = tiles[tileId].color;
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
canvasTexture.refresh();
}
/** Update the viewport indicator rectangle — call each frame */
update(camera: Phaser.Cameras.Scene2D.Camera): void {
// Compensate for camera zoom on all minimap elements.
// Graphics objects (border, viewport) use (0,0) as origin so their
// local draw coordinates map 1:1 to screen pixels after compensation.
fixToScreen(this.border, 0, 0, camera);
fixToScreen(this.viewport, 0, 0, camera);
// Image has origin(1,0): position = top-right corner
const screenW = camera.width;
const padding = 10;
fixToScreen(this.image, screenW - padding, padding, camera);
const minimapW = this.mapWidth * this.minimapScale;
const minimapX = screenW - padding - minimapW;
const minimapY = padding;
// Camera's visible area in world coordinates
const worldView = camera.worldView;
// Convert world coordinates to minimap coordinates
const viewX = minimapX + (worldView.x / this.tileSize) * this.minimapScale;
const viewY = minimapY + (worldView.y / this.tileSize) * this.minimapScale;
const viewW = (worldView.width / this.tileSize) * this.minimapScale;
const viewH = (worldView.height / this.tileSize) * this.minimapScale;
this.viewport.clear();
this.viewport.lineStyle(1, 0xffffff, 0.8);
this.viewport.strokeRect(viewX, viewY, viewW, viewH);
}
destroy(): void {
this.image.destroy();
this.viewport.destroy();
this.border.destroy();
}
}

39
src/world/noise.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createNoise2D } from 'simplex-noise';
/** 2D noise function returning values in [-1, 1] */
export type Noise2D = (x: number, y: number) => number;
/**
* Mulberry32 — fast, seedable 32-bit PRNG
* Returns values in [0, 1)
*/
function mulberry32(seed: number): () => number {
let state = seed | 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Create a seeded 2D simplex noise function */
export function createSeededNoise(seed: number): Noise2D {
return createNoise2D(mulberry32(seed));
}
/**
* Sample 2D noise normalized to [0, 1]
* @param noise - noise function (returns [-1, 1])
* @param x - world x coordinate
* @param y - world y coordinate
* @param scale - frequency (higher = more detail, smaller features)
*/
export function sampleNoise(
noise: Noise2D,
x: number,
y: number,
scale: number,
): number {
return (noise(x * scale, y * scale) + 1) / 2;
}

106
src/world/resources.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Resource Spawner — creates ECS entities for harvestable world objects
*
* Scans the generated tile grid for mineral veins and geysers,
* creates an entity at each with a randomly assigned element.
*/
import { addEntity, addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Resource, SpriteRef } from '../ecs/components';
import type { TileGrid, BiomeData } from './types';
import {
pickResourceElement,
MINERAL_ELEMENTS,
GEYSER_ELEMENTS,
type ResourceInfo,
} from '../player/interaction';
/** Resource spawn configuration per tile type */
interface ResourceTileConfig {
tileId: number;
elements: readonly string[];
minQuantity: number;
maxQuantity: number;
interactRange: number;
spriteColor: number;
spriteRadius: number;
}
/**
* Spawn resource entities for all resource tiles in the grid.
* @returns Map of entity ID → ResourceInfo for string data
*/
export function spawnResources(
world: World,
grid: TileGrid,
biome: BiomeData,
seed: number,
): Map<number, ResourceInfo> {
const resourceData = new Map<number, ResourceInfo>();
// Find tile IDs for resource types (generic: resource + interactive tiles)
const mineralTile = biome.tiles.find(t => t.resource);
const geyserTile = biome.tiles.find(t => t.interactive);
const configs: ResourceTileConfig[] = [];
if (mineralTile) {
configs.push({
tileId: mineralTile.id,
elements: MINERAL_ELEMENTS,
minQuantity: 3,
maxQuantity: 5,
interactRange: 40,
spriteColor: 0xffd700, // gold
spriteRadius: 4,
});
}
if (geyserTile) {
configs.push({
tileId: geyserTile.id,
elements: GEYSER_ELEMENTS,
minQuantity: 2,
maxQuantity: 4,
interactRange: 48,
spriteColor: 0xff6600, // orange
spriteRadius: 5,
});
}
const tileSize = biome.tileSize;
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
const tileId = grid[y][x];
const config = configs.find(c => c.tileId === tileId);
if (!config) continue;
// Pick element deterministically
const itemId = pickResourceElement(x, y, seed, config.elements);
// Quantity from deterministic hash
const qHash = ((x * 48611) ^ (y * 29423) ^ (seed * 61379)) >>> 0;
const range = config.maxQuantity - config.minQuantity + 1;
const quantity = config.minQuantity + (qHash % range);
// Create entity at tile center
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Resource);
addComponent(world, eid, SpriteRef);
Position.x[eid] = x * tileSize + tileSize / 2;
Position.y[eid] = y * tileSize + tileSize / 2;
Resource.quantity[eid] = quantity;
Resource.interactRange[eid] = config.interactRange;
SpriteRef.color[eid] = config.spriteColor;
SpriteRef.radius[eid] = config.spriteRadius;
resourceData.set(eid, { itemId, tileX: x, tileY: y });
}
}
return resourceData;
}

107
src/world/tilemap.ts Normal file
View File

@@ -0,0 +1,107 @@
import Phaser from 'phaser';
import type { TileData, WorldData } from './types';
/**
* Create a Phaser tilemap from generated world data
*
* 1. Generates a canvas-based tileset texture (colored squares with per-pixel variation)
* 2. Creates a Phaser Tilemap from the grid data
* 3. Sets collision for non-walkable tiles
*/
export function createWorldTilemap(
scene: Phaser.Scene,
worldData: WorldData,
): Phaser.Tilemaps.Tilemap {
const { grid, biome } = worldData;
const textureKey = `tileset-${biome.id}`;
// 1. Remove old texture if re-entering scene (run cycle)
if (scene.textures.exists(textureKey)) {
scene.textures.remove(textureKey);
}
// 2. Generate tileset texture
createTilesetTexture(scene, biome.tiles, biome.tileSize, textureKey);
// 2. Create tilemap from grid
const map = scene.make.tilemap({
data: grid,
tileWidth: biome.tileSize,
tileHeight: biome.tileSize,
});
const tileset = map.addTilesetImage(textureKey, textureKey, biome.tileSize, biome.tileSize, 0, 0);
if (!tileset) {
throw new Error(`Failed to create tileset: ${textureKey}`);
}
const layer = map.createLayer(0, tileset, 0, 0);
if (!layer) {
throw new Error('Failed to create tilemap layer');
}
// 3. Set collision for non-walkable tiles
const nonWalkableIds = biome.tiles.filter(t => !t.walkable).map(t => t.id);
layer.setCollision(nonWalkableIds);
return map;
}
/**
* Generate a canvas tileset texture with per-pixel brightness variation
* Creates visual micro-texture so flat-colored tiles look less monotonous
*/
function createTilesetTexture(
scene: Phaser.Scene,
tiles: TileData[],
tileSize: number,
textureKey: string,
): void {
const width = tiles.length * tileSize;
const height = tileSize;
const canvasTexture = scene.textures.createCanvas(textureKey, width, height);
const ctx = canvasTexture.getContext();
const imageData = ctx.createImageData(width, height);
const pixels = imageData.data;
for (const tile of tiles) {
const [baseR, baseG, baseB] = hexToRgb(tile.color);
for (let py = 0; py < tileSize; py++) {
for (let px = 0; px < tileSize; px++) {
// Per-pixel brightness variation (±12) for texture
const hash = pixelHash(tile.id, px, py);
const variation = (hash % 25) - 12;
const idx = ((py * width) + (tile.id * tileSize + px)) * 4;
pixels[idx] = clamp(baseR + variation, 0, 255);
pixels[idx + 1] = clamp(baseG + variation, 0, 255);
pixels[idx + 2] = clamp(baseB + variation, 0, 255);
pixels[idx + 3] = 255;
}
}
}
ctx.putImageData(imageData, 0, 0);
canvasTexture.refresh();
}
/** Convert hex color string (#RRGGBB) to [R, G, B] */
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
/** Deterministic hash for per-pixel variation */
function pixelHash(tileId: number, x: number, y: number): number {
let n = tileId * 73856093 + x * 19349663 + y * 83492791;
n = ((n >> 16) ^ n) * 0x45d9f3b;
n = ((n >> 16) ^ n) * 0x45d9f3b;
return ((n >> 16) ^ n) & 0xff;
}
/** Clamp value to [min, max] */
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

194
src/world/traces.ts Normal file
View File

@@ -0,0 +1,194 @@
/**
* World Trace Placement — places ruins and markers from past runs
*
* GDD (Law of Trace): "Nothing disappears without a trace;
* each cycle leaves an imprint in the next."
*
* When generating a new world, we check for traces from past runs
* in the same biome (from the current and previous great cycle).
* Death sites become ruin markers; discovery sites become faded experiment traces.
*/
import { addEntity, addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, SpriteRef, WorldTrace, TraceType } from '../ecs/components';
import type { BiomeData } from './types';
import type { RunTrace, GreatCycleState } from '../run/types';
import { getTracesForBiome, getDeathTraces } from '../run/cycle';
/** String data for a world trace entity (not stored in ECS — numeric only there) */
export interface WorldTraceInfo {
/** Entity ID */
eid: number;
/** Type of trace */
traceType: 'death_site' | 'discovery_site';
/** Run that left this trace */
sourceRunId: number;
/** School used in that run */
schoolId: string;
/** Key elements discovered (for discovery sites) */
keyElements: string[];
/** Tile position */
tileX: number;
tileY: number;
}
/** Visual config for trace markers */
const TRACE_CONFIG = {
death_site: {
color: 0x884444, // dark red — blood/decay
radius: 6,
interactRange: 40,
glowColor: 0xaa3333, // red glow
},
discovery_site: {
color: 0x446688, // blue-gray — knowledge
radius: 5,
interactRange: 36,
glowColor: 0x4488aa, // blue glow
},
} as const;
/**
* Spawn world trace entities from past run traces.
*
* For each trace from the current/previous cycle in this biome:
* - Death sites: place a ruin marker at the death position
* - Remaining traces: place discovery markers at a position derived from
* the run's world seed (since we don't store exact discovery locations)
*
* @returns Array of WorldTraceInfo for display/interaction
*/
export function spawnWorldTraces(
world: World,
cycleState: GreatCycleState,
biomeId: string,
biome: BiomeData,
): WorldTraceInfo[] {
const traces = getTracesForBiome(cycleState, biomeId);
if (traces.length === 0) return [];
const result: WorldTraceInfo[] = [];
const tileSize = biome.tileSize;
const mapW = biome.mapWidth;
const mapH = biome.mapHeight;
// Place death site markers
const deathTraces = getDeathTraces(traces);
for (const trace of deathTraces) {
const pos = trace.deathPosition;
if (!pos) continue;
// Clamp to map bounds
const tx = Math.max(0, Math.min(pos.tileX, mapW - 1));
const ty = Math.max(0, Math.min(pos.tileY, mapH - 1));
const eid = createTraceEntity(
world, tx, ty, tileSize,
TraceType.DeathSite, trace.runId,
TRACE_CONFIG.death_site,
);
result.push({
eid,
traceType: 'death_site',
sourceRunId: trace.runId,
schoolId: trace.schoolId,
keyElements: trace.keyElements,
tileX: tx,
tileY: ty,
});
}
// Place discovery markers for traces with significant discoveries
// (Only for traces without death sites, to avoid double-marking)
const deathRunIds = new Set(deathTraces.map(t => t.runId));
const discoveryTraces = traces.filter(
t => !deathRunIds.has(t.runId) && t.discoveryCount >= 3,
);
for (const trace of discoveryTraces) {
// Derive a position from the run's seed (deterministic but varied)
const tx = deriveTracePosition(trace.worldSeed, 0, mapW);
const ty = deriveTracePosition(trace.worldSeed, 1, mapH);
const eid = createTraceEntity(
world, tx, ty, tileSize,
TraceType.DiscoverySite, trace.runId,
TRACE_CONFIG.discovery_site,
);
result.push({
eid,
traceType: 'discovery_site',
sourceRunId: trace.runId,
schoolId: trace.schoolId,
keyElements: trace.keyElements,
tileX: tx,
tileY: ty,
});
}
return result;
}
/** Create a single trace entity with position, sprite, and trace component */
function createTraceEntity(
world: World,
tileX: number,
tileY: number,
tileSize: number,
traceType: number,
sourceRunId: number,
config: { color: number; radius: number; interactRange: number },
): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
Position.x[eid] = tileX * tileSize + tileSize / 2;
Position.y[eid] = tileY * tileSize + tileSize / 2;
addComponent(world, eid, SpriteRef);
SpriteRef.color[eid] = config.color;
SpriteRef.radius[eid] = config.radius;
addComponent(world, eid, WorldTrace);
WorldTrace.traceType[eid] = traceType;
WorldTrace.sourceRunId[eid] = sourceRunId;
WorldTrace.glowPhase[eid] = Math.random() * Math.PI * 2;
WorldTrace.interactRange[eid] = config.interactRange;
return eid;
}
/**
* Derive a deterministic tile position from a seed.
* Ensures traces from the same run appear in consistent locations.
*/
function deriveTracePosition(seed: number, axis: number, mapSize: number): number {
// Mulberry32-style hash
let t = (seed + axis * 0x6d2b79f5 + 0x9e3779b9) | 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
const normalized = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
// Keep away from edges (10% margin)
const margin = Math.floor(mapSize * 0.1);
return margin + Math.floor(normalized * (mapSize - 2 * margin));
}
/**
* Update trace glow animation (pulsing effect).
* Called in the game update loop.
*/
export function updateTraceGlow(
traceInfos: WorldTraceInfo[],
delta: number,
): void {
const pulseSpeed = 1.2; // radians per second
const deltaSec = delta / 1000;
for (const info of traceInfos) {
WorldTrace.glowPhase[info.eid] += pulseSpeed * deltaSec;
}
}

52
src/world/types.ts Normal file
View File

@@ -0,0 +1,52 @@
/** Single tile type definition */
export interface TileData {
id: number;
name: string;
nameRu: string;
color: string;
walkable: boolean;
damage: number; // 0 = no damage
interactive: boolean; // true = can interact (e.g. geyser)
resource: boolean; // true = harvestable resource
}
/** Elevation → tile mapping rule (sorted by "below" ascending) */
export interface ElevationRule {
below: number;
tileId: number;
}
/** Biome generation parameters */
export interface BiomeGeneration {
elevationScale: number;
detailScale: number;
elevationRules: ElevationRule[];
geyserThreshold: number;
mineralThreshold: number;
geyserOnTile: number; // base tile ID where geysers can spawn
mineralOnTiles: number[]; // base tile IDs where minerals can spawn
}
/** Complete biome definition (loaded from biomes.json) */
export interface BiomeData {
id: string;
name: string;
nameRu: string;
description: string;
descriptionRu: string;
tileSize: number;
mapWidth: number;
mapHeight: number;
tiles: TileData[];
generation: BiomeGeneration;
}
/** 2D grid of tile IDs — row-major [y][x] */
export type TileGrid = number[][];
/** Complete generated world data */
export interface WorldData {
grid: TileGrid;
biome: BiomeData;
seed: number;
}

758
tests/boss.test.ts Normal file
View File

@@ -0,0 +1,758 @@
/**
* Boss System Tests — Phase 8: First Archont (Ouroboros)
*
* Tests cover:
* - Boss state creation and initialization
* - Phase cycling (Coil → Spray → Lash → Digest → repeat)
* - Phase speedup per cycle
* - Damage mechanics (vulnerability windows, armor)
* - Chemical damage (NaOH during Spray)
* - Catalyst poison (Hg stacks, regen/armor reduction)
* - Victory detection and method determination
* - Arena generation (circular layout, features)
* - Reward calculation (spores, lore)
* - Boss entity factory (ECS)
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import type { World } from '../src/ecs/world';
import { Position, Health, SpriteRef, Boss } from '../src/ecs/components';
// Boss system imports
import {
createBossState,
updateBossPhase,
getEffectiveArmor,
getEffectiveRegen,
isVulnerable,
getEffectivePhaseDuration,
} from '../src/boss/ai';
import {
applyBossDamage,
isBossDefeated,
} from '../src/boss/victory';
import {
generateArena,
buildArenaWalkableSet,
} from '../src/boss/arena';
import {
calculateBossReward,
applyBossReward,
} from '../src/boss/reward';
import { createBossEntity } from '../src/boss/factory';
// Types
import {
BossPhase,
VictoryMethod,
type BossData,
type BossState,
} from '../src/boss/types';
import type { BiomeData } from '../src/world/types';
import { createMetaState } from '../src/run/meta';
// Load boss data
import bossDataArray from '../src/data/bosses.json';
import biomeDataArray from '../src/data/biomes.json';
const ouroboros = bossDataArray[0] as BossData;
const biome = biomeDataArray[0] as BiomeData;
// ─── Boss State Creation ─────────────────────────────────────────
describe('Boss State Creation', () => {
it('creates initial boss state from data', () => {
const state = createBossState(ouroboros);
expect(state.bossId).toBe('ouroboros');
expect(state.health).toBe(300);
expect(state.maxHealth).toBe(300);
expect(state.currentPhase).toBe(BossPhase.Coil);
expect(state.cycleCount).toBe(0);
expect(state.catalystStacks).toBe(0);
expect(state.defeated).toBe(false);
expect(state.victoryMethod).toBeNull();
});
it('starts with phase timer set to first phase duration', () => {
const state = createBossState(ouroboros);
expect(state.phaseTimer).toBe(ouroboros.phaseDurations[0]); // 5000ms
});
it('starts with zero damage counters', () => {
const state = createBossState(ouroboros);
expect(state.totalDamageDealt).toBe(0);
expect(state.chemicalDamageDealt).toBe(0);
expect(state.directDamageDealt).toBe(0);
expect(state.catalystDamageDealt).toBe(0);
});
});
// ─── Phase Cycling ───────────────────────────────────────────────
describe('Boss Phase Cycling', () => {
let state: BossState;
beforeEach(() => {
state = createBossState(ouroboros);
});
it('stays in current phase while timer has not expired', () => {
updateBossPhase(state, ouroboros, 1000);
expect(state.currentPhase).toBe(BossPhase.Coil);
expect(state.phaseTimer).toBeGreaterThan(0);
});
it('transitions from Coil to Spray when timer expires', () => {
updateBossPhase(state, ouroboros, 5000); // Full Coil duration
expect(state.currentPhase).toBe(BossPhase.Spray);
});
it('transitions through full cycle: Coil → Spray → Lash → Digest', () => {
// Coil (5s) → Spray
updateBossPhase(state, ouroboros, 5000);
expect(state.currentPhase).toBe(BossPhase.Spray);
// Spray (8s) → Lash
updateBossPhase(state, ouroboros, 8000);
expect(state.currentPhase).toBe(BossPhase.Lash);
// Lash (6s) → Digest
updateBossPhase(state, ouroboros, 6000);
expect(state.currentPhase).toBe(BossPhase.Digest);
});
it('completes a full cycle and increments cycleCount', () => {
// Full cycle: 5000 + 8000 + 6000 + 4000 = 23000ms
const events = [];
events.push(...updateBossPhase(state, ouroboros, 5000)); // Coil → Spray
events.push(...updateBossPhase(state, ouroboros, 8000)); // Spray → Lash
events.push(...updateBossPhase(state, ouroboros, 6000)); // Lash → Digest
events.push(...updateBossPhase(state, ouroboros, 4000)); // Digest → Coil (cycle 1)
expect(state.currentPhase).toBe(BossPhase.Coil);
expect(state.cycleCount).toBe(1);
const cycleEvent = events.find(e => e.type === 'cycle_complete');
expect(cycleEvent).toBeDefined();
expect(cycleEvent?.cycleCount).toBe(1);
});
it('emits phase_change events', () => {
const events = updateBossPhase(state, ouroboros, 5000); // Coil → Spray
const phaseChange = events.find(e => e.type === 'phase_change');
expect(phaseChange).toBeDefined();
expect(phaseChange?.phase).toBe(BossPhase.Spray);
});
it('emits boss_attack during Spray phase', () => {
updateBossPhase(state, ouroboros, 5000); // → Spray
const events = updateBossPhase(state, ouroboros, 100); // Tick in Spray
const attack = events.find(e => e.type === 'boss_attack');
expect(attack).toBeDefined();
expect(attack?.phase).toBe(BossPhase.Spray);
});
it('emits boss_attack during Lash phase', () => {
updateBossPhase(state, ouroboros, 5000 + 8000); // → Lash
const events = updateBossPhase(state, ouroboros, 100); // Tick in Lash
const attack = events.find(e => e.type === 'boss_attack');
expect(attack).toBeDefined();
expect(attack?.phase).toBe(BossPhase.Lash);
});
it('does not emit boss_attack during Coil or Digest', () => {
// During Coil
const coilEvents = updateBossPhase(state, ouroboros, 100);
expect(coilEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0);
// Advance to Digest
updateBossPhase(state, ouroboros, 4900 + 8000 + 6000); // Coil → Spray → Lash → Digest
const digestEvents = updateBossPhase(state, ouroboros, 100);
expect(digestEvents.filter(e => e.type === 'boss_attack')).toHaveLength(0);
});
it('does not update when defeated', () => {
state.defeated = true;
const events = updateBossPhase(state, ouroboros, 10000);
expect(events).toHaveLength(0);
});
});
// ─── Phase Speedup ───────────────────────────────────────────────
describe('Boss Phase Speedup', () => {
it('returns base duration for cycle 0', () => {
const duration = getEffectivePhaseDuration(
{ cycleCount: 0 } as BossState,
ouroboros,
BossPhase.Coil,
);
expect(duration).toBe(5000);
});
it('reduces duration by speedup factor per cycle', () => {
// Cycle 1: 5000 * 0.9 = 4500
const duration = getEffectivePhaseDuration(
{ cycleCount: 1 } as BossState,
ouroboros,
BossPhase.Coil,
);
expect(duration).toBeCloseTo(4500);
});
it('caps speedup at maxCycles', () => {
// Cycle 5 (maxCycles): 5000 * 0.9^5 = 2952.45
const duration5 = getEffectivePhaseDuration(
{ cycleCount: 5 } as BossState,
ouroboros,
BossPhase.Coil,
);
// Cycle 10 (should cap at 5): same
const duration10 = getEffectivePhaseDuration(
{ cycleCount: 10 } as BossState,
ouroboros,
BossPhase.Coil,
);
expect(duration10).toBeCloseTo(duration5);
});
it('applies speedup to all phases', () => {
const state = { cycleCount: 2 } as BossState;
const coil = getEffectivePhaseDuration(state, ouroboros, BossPhase.Coil);
const spray = getEffectivePhaseDuration(state, ouroboros, BossPhase.Spray);
const lash = getEffectivePhaseDuration(state, ouroboros, BossPhase.Lash);
const digest = getEffectivePhaseDuration(state, ouroboros, BossPhase.Digest);
expect(coil).toBeCloseTo(5000 * 0.81); // 0.9^2
expect(spray).toBeCloseTo(8000 * 0.81);
expect(lash).toBeCloseTo(6000 * 0.81);
expect(digest).toBeCloseTo(4000 * 0.81);
});
});
// ─── Armor & Regeneration ────────────────────────────────────────
describe('Boss Armor and Regeneration', () => {
let state: BossState;
beforeEach(() => {
state = createBossState(ouroboros);
});
it('has full armor during non-vulnerable phases', () => {
state.currentPhase = BossPhase.Coil;
expect(getEffectiveArmor(state, ouroboros)).toBe(0.5);
});
it('has reduced armor during vulnerable phases', () => {
state.currentPhase = BossPhase.Digest;
expect(getEffectiveArmor(state, ouroboros)).toBe(0.1);
});
it('has full regen rate with 0 catalyst stacks', () => {
expect(getEffectiveRegen(state, ouroboros)).toBe(5);
});
it('reduces regen per catalyst stack', () => {
state.catalystStacks = 1;
expect(getEffectiveRegen(state, ouroboros)).toBe(3); // 5 - 1*2 = 3
});
it('regen does not go below 0', () => {
state.catalystStacks = 3;
expect(getEffectiveRegen(state, ouroboros)).toBe(0); // 5 - 3*2 = -1 → 0
});
it('reduces armor per catalyst stack', () => {
state.catalystStacks = 2;
state.currentPhase = BossPhase.Coil;
expect(getEffectiveArmor(state, ouroboros)).toBe(0.3); // 0.5 - 2*0.1
});
it('armor does not go below 0', () => {
state.catalystStacks = 10; // Way over max stacks
state.currentPhase = BossPhase.Digest;
expect(getEffectiveArmor(state, ouroboros)).toBe(0); // 0.1 - 10*0.1 → 0
});
it('identifies vulnerable phases correctly', () => {
state.currentPhase = BossPhase.Digest;
expect(isVulnerable(state, ouroboros)).toBe(true);
state.currentPhase = BossPhase.Coil;
expect(isVulnerable(state, ouroboros)).toBe(false);
state.currentPhase = BossPhase.Spray;
expect(isVulnerable(state, ouroboros)).toBe(false);
state.currentPhase = BossPhase.Lash;
expect(isVulnerable(state, ouroboros)).toBe(false);
});
it('applies regeneration over time', () => {
state.health = 250; // Damaged
updateBossPhase(state, ouroboros, 2000); // 2 seconds → 10 HP regen
expect(state.health).toBeCloseTo(260);
});
it('does not regenerate past max health', () => {
state.health = 298;
updateBossPhase(state, ouroboros, 2000); // Would regen 10, capped at 300
expect(state.health).toBe(300);
});
it('regeneration is reduced by catalyst stacks', () => {
state.health = 250;
state.catalystStacks = 2; // regen = 5 - 2*2 = 1 HP/s
updateBossPhase(state, ouroboros, 2000); // 2 seconds → 2 HP regen
expect(state.health).toBeCloseTo(252);
});
});
// ─── Damage: Direct (Victory Path 2) ────────────────────────────
describe('Boss Damage — Direct', () => {
let state: BossState;
beforeEach(() => {
state = createBossState(ouroboros);
});
it('deals full damage during Digest (vulnerable) phase', () => {
state.currentPhase = BossPhase.Digest;
const result = applyBossDamage(state, ouroboros, 'Fe');
// 15 * (1 - 0.1) = 13.5 → 14 rounded
expect(result.damageDealt).toBe(14);
expect(result.damageType).toBe(VictoryMethod.Direct);
});
it('deals reduced damage during non-vulnerable phases', () => {
state.currentPhase = BossPhase.Coil;
const result = applyBossDamage(state, ouroboros, 'Fe');
// 15 * (1 - 0.5) = 7.5 → 8 rounded
expect(result.damageDealt).toBe(8);
});
it('tracks direct damage dealt', () => {
state.currentPhase = BossPhase.Digest;
applyBossDamage(state, ouroboros, 'Fe');
expect(state.directDamageDealt).toBeGreaterThan(0);
expect(state.totalDamageDealt).toBeGreaterThan(0);
});
it('does not deal damage to defeated boss', () => {
state.defeated = true;
const result = applyBossDamage(state, ouroboros, 'Fe');
expect(result.damageDealt).toBe(0);
});
});
// ─── Damage: Chemical (Victory Path 1) ──────────────────────────
describe('Boss Damage — Chemical (NaOH)', () => {
let state: BossState;
beforeEach(() => {
state = createBossState(ouroboros);
});
it('deals multiplied damage with NaOH during Spray phase', () => {
state.currentPhase = BossPhase.Spray;
const result = applyBossDamage(state, ouroboros, 'NaOH');
// 15 * 3.0 * (1 - 0.5) = 22.5 → 23 rounded
expect(result.damageDealt).toBe(23);
expect(result.damageType).toBe(VictoryMethod.Chemical);
});
it('treats NaOH as normal projectile outside effective phases', () => {
state.currentPhase = BossPhase.Coil; // Not a chemical-effective phase
const result = applyBossDamage(state, ouroboros, 'NaOH');
// Normal reduced damage: 15 * (1 - 0.5) = 8
expect(result.damageDealt).toBe(8);
expect(result.damageType).toBe(VictoryMethod.Direct);
});
it('tracks chemical damage separately', () => {
state.currentPhase = BossPhase.Spray;
applyBossDamage(state, ouroboros, 'NaOH');
expect(state.chemicalDamageDealt).toBeGreaterThan(0);
expect(state.directDamageDealt).toBe(0);
});
});
// ─── Damage: Catalytic Poison (Victory Path 3) ──────────────────
describe('Boss Damage — Catalytic Poison (Hg)', () => {
let state: BossState;
beforeEach(() => {
state = createBossState(ouroboros);
});
it('applies catalyst stack on first Hg hit', () => {
const result = applyBossDamage(state, ouroboros, 'Hg');
expect(result.catalystApplied).toBe(true);
expect(state.catalystStacks).toBe(1);
expect(result.damageType).toBe(VictoryMethod.Catalytic);
});
it('caps at maxCatalystStacks', () => {
state.catalystStacks = 3; // Already at max
const result = applyBossDamage(state, ouroboros, 'Hg');
expect(result.catalystApplied).toBe(false);
expect(state.catalystStacks).toBe(3);
});
it('deals damage on every Hg application', () => {
const result = applyBossDamage(state, ouroboros, 'Hg');
expect(result.damageDealt).toBeGreaterThan(0);
});
it('tracks catalyst damage separately', () => {
applyBossDamage(state, ouroboros, 'Hg');
expect(state.catalystDamageDealt).toBeGreaterThan(0);
expect(state.chemicalDamageDealt).toBe(0);
expect(state.directDamageDealt).toBe(0);
});
it('Hg works in any phase (not phase-dependent)', () => {
for (const phase of [BossPhase.Coil, BossPhase.Spray, BossPhase.Lash, BossPhase.Digest]) {
const s = createBossState(ouroboros);
s.currentPhase = phase;
const result = applyBossDamage(s, ouroboros, 'Hg');
expect(result.catalystApplied).toBe(true);
expect(result.damageType).toBe(VictoryMethod.Catalytic);
}
});
});
// ─── Victory Detection ───────────────────────────────────────────
describe('Boss Victory Detection', () => {
let state: BossState;
beforeEach(() => {
state = createBossState(ouroboros);
});
it('detects killing blow', () => {
state.health = 5;
state.currentPhase = BossPhase.Digest;
const result = applyBossDamage(state, ouroboros, 'Fe');
expect(result.killingBlow).toBe(true);
expect(state.defeated).toBe(true);
});
it('sets victoryMethod based on killing blow type', () => {
state.health = 5;
state.currentPhase = BossPhase.Spray;
applyBossDamage(state, ouroboros, 'NaOH');
expect(state.victoryMethod).toBe(VictoryMethod.Chemical);
});
it('sets Catalytic victory on Hg killing blow', () => {
state.health = 3;
applyBossDamage(state, ouroboros, 'Hg');
expect(state.victoryMethod).toBe(VictoryMethod.Catalytic);
});
it('isBossDefeated returns true when defeated', () => {
expect(isBossDefeated(state)).toBe(false);
state.health = 1;
state.currentPhase = BossPhase.Digest;
applyBossDamage(state, ouroboros, 'Fe');
expect(isBossDefeated(state)).toBe(true);
});
it('health does not go below 0', () => {
state.health = 1;
state.currentPhase = BossPhase.Digest;
applyBossDamage(state, ouroboros, 'Fe');
expect(state.health).toBe(0);
});
});
// ─── Arena Generation ────────────────────────────────────────────
describe('Arena Generation', () => {
it('generates a grid of correct size', () => {
const arena = generateArena(ouroboros, biome);
const diameter = ouroboros.arenaRadius * 2 + 1;
expect(arena.width).toBe(diameter);
expect(arena.height).toBe(diameter);
expect(arena.grid.length).toBe(diameter);
expect(arena.grid[0].length).toBe(diameter);
});
it('has walkable ground in the center', () => {
const arena = generateArena(ouroboros, biome);
const center = ouroboros.arenaRadius;
// Center tile should be walkable ground (0)
expect(arena.grid[center][center]).toBe(0);
});
it('has crystal wall border', () => {
const arena = generateArena(ouroboros, biome);
const r = ouroboros.arenaRadius;
// Top edge should be bedrock (7) or crystal (4)
// Very top-left corner is outside circle → bedrock
expect(arena.grid[0][0]).toBe(7);
});
it('has boss spawn at center', () => {
const arena = generateArena(ouroboros, biome);
const center = ouroboros.arenaRadius;
const tileSize = biome.tileSize;
expect(arena.bossSpawnX).toBe(center * tileSize + tileSize / 2);
expect(arena.bossSpawnY).toBe(center * tileSize + tileSize / 2);
});
it('has player spawn south of center', () => {
const arena = generateArena(ouroboros, biome);
expect(arena.playerSpawnY).toBeGreaterThan(arena.bossSpawnY);
});
it('places 4 resource deposits', () => {
const arena = generateArena(ouroboros, biome);
expect(arena.resourcePositions).toHaveLength(4);
});
it('resource positions are within arena bounds', () => {
const arena = generateArena(ouroboros, biome);
const maxPixel = arena.width * biome.tileSize;
for (const pos of arena.resourcePositions) {
expect(pos.x).toBeGreaterThan(0);
expect(pos.x).toBeLessThan(maxPixel);
expect(pos.y).toBeGreaterThan(0);
expect(pos.y).toBeLessThan(maxPixel);
}
});
it('has acid tiles in the arena', () => {
const arena = generateArena(ouroboros, biome);
let acidCount = 0;
for (const row of arena.grid) {
for (const tile of row) {
if (tile === 2) acidCount++; // ACID_SHALLOW
}
}
expect(acidCount).toBeGreaterThan(0);
});
it('buildArenaWalkableSet includes expected tiles', () => {
const walkable = buildArenaWalkableSet();
expect(walkable.has(0)).toBe(true); // GROUND
expect(walkable.has(1)).toBe(true); // SCORCHED_EARTH
expect(walkable.has(6)).toBe(true); // MINERAL_VEIN
expect(walkable.has(7)).toBe(false); // BEDROCK (not walkable)
expect(walkable.has(4)).toBe(false); // CRYSTAL (not walkable)
});
});
// ─── Reward System ───────────────────────────────────────────────
describe('Boss Reward System', () => {
it('calculates base reward for direct victory', () => {
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Direct;
const reward = calculateBossReward(state, ouroboros);
expect(reward.spores).toBe(100); // Base
expect(reward.loreId).toBe('ouroboros');
expect(reward.victoryMethod).toBe(VictoryMethod.Direct);
});
it('gives 50% bonus for chemical victory', () => {
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Chemical;
const reward = calculateBossReward(state, ouroboros);
expect(reward.spores).toBe(150); // 100 * 1.5
});
it('gives 100% bonus for catalytic victory', () => {
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Catalytic;
const reward = calculateBossReward(state, ouroboros);
expect(reward.spores).toBe(200); // 100 * 2.0
});
it('returns no spores if boss not defeated', () => {
const state = createBossState(ouroboros);
const reward = calculateBossReward(state, ouroboros);
expect(reward.spores).toBe(0);
});
it('includes lore text in reward', () => {
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Direct;
const reward = calculateBossReward(state, ouroboros);
expect(reward.loreText.length).toBeGreaterThan(0);
expect(reward.loreTextRu.length).toBeGreaterThan(0);
});
it('applyBossReward adds spores to meta', () => {
const meta = createMetaState();
const initialSpores = meta.spores;
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Direct;
const reward = calculateBossReward(state, ouroboros);
applyBossReward(meta, reward, 1);
expect(meta.spores).toBe(initialSpores + reward.spores);
});
it('applyBossReward adds lore to codex', () => {
const meta = createMetaState();
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Direct;
const reward = calculateBossReward(state, ouroboros);
applyBossReward(meta, reward, 1);
const bossEntry = meta.codex.find(e => e.id === 'ouroboros');
expect(bossEntry).toBeDefined();
expect(bossEntry?.type).toBe('boss');
expect(bossEntry?.discoveredOnRun).toBe(1);
});
it('does not add duplicate codex entries', () => {
const meta = createMetaState();
const state = createBossState(ouroboros);
state.defeated = true;
state.victoryMethod = VictoryMethod.Direct;
const reward = calculateBossReward(state, ouroboros);
applyBossReward(meta, reward, 1);
applyBossReward(meta, reward, 2);
const bossEntries = meta.codex.filter(e => e.id === 'ouroboros');
expect(bossEntries).toHaveLength(1);
});
});
// ─── Boss Entity Factory ─────────────────────────────────────────
describe('Boss Entity Factory', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('creates an entity with Position component', () => {
const eid = createBossEntity(world, ouroboros, 400, 300);
expect(Position.x[eid]).toBe(400);
expect(Position.y[eid]).toBe(300);
});
it('creates an entity with Health component', () => {
const eid = createBossEntity(world, ouroboros, 400, 300);
expect(Health.current[eid]).toBe(300);
expect(Health.max[eid]).toBe(300);
});
it('creates an entity with SpriteRef component', () => {
const eid = createBossEntity(world, ouroboros, 400, 300);
expect(SpriteRef.color[eid]).toBe(0xcc44ff);
expect(SpriteRef.radius[eid]).toBe(20);
});
it('creates an entity with Boss component', () => {
const eid = createBossEntity(world, ouroboros, 400, 300);
expect(Boss.dataIndex[eid]).toBe(0);
expect(Boss.phase[eid]).toBe(0); // BossPhase.Coil
expect(Boss.cycleCount[eid]).toBe(0);
});
it('entity is queryable by Boss component', () => {
const eid = createBossEntity(world, ouroboros, 400, 300);
const bosses = query(world, [Boss]);
expect(bosses).toContain(eid);
});
});
// ─── Integration: Full Boss Fight ────────────────────────────────
describe('Boss Fight Integration', () => {
it('can defeat boss with direct damage during Digest windows', () => {
const state = createBossState(ouroboros);
let totalCycles = 0;
// Simulate multiple cycles of hitting during Digest
// In a 4s Digest window, player can throw ~10 projectiles
while (!state.defeated && totalCycles < 30) {
// Advance through Coil → Spray → Lash → Digest
updateBossPhase(state, ouroboros, 5000); // Coil (+ regen)
updateBossPhase(state, ouroboros, 8000); // Spray (+ regen)
updateBossPhase(state, ouroboros, 6000); // Lash (+ regen)
// Now in Digest — rapid-fire attacks (10 per 4s window is realistic)
for (let i = 0; i < 10 && !state.defeated; i++) {
state.currentPhase = BossPhase.Digest; // Ensure in Digest
applyBossDamage(state, ouroboros, 'Fe');
}
updateBossPhase(state, ouroboros, 4000); // Complete Digest
totalCycles++;
}
expect(state.defeated).toBe(true);
expect(state.victoryMethod).toBe(VictoryMethod.Direct);
// Direct path is slow but viable (boss regens 5 HP/s between windows)
expect(totalCycles).toBeLessThan(30);
});
it('can defeat boss faster with NaOH during Spray', () => {
const state = createBossState(ouroboros);
let hits = 0;
// Advance to Spray phase and keep hitting with NaOH
state.currentPhase = BossPhase.Spray;
while (!state.defeated && hits < 50) {
applyBossDamage(state, ouroboros, 'NaOH');
hits++;
}
expect(state.defeated).toBe(true);
expect(state.victoryMethod).toBe(VictoryMethod.Chemical);
expect(hits).toBeLessThan(20); // NaOH should be efficient
});
it('Hg makes boss progressively weaker', () => {
const state = createBossState(ouroboros);
// Apply 3 Hg stacks
for (let i = 0; i < 3; i++) {
applyBossDamage(state, ouroboros, 'Hg');
}
expect(state.catalystStacks).toBe(3);
// Regen should be 0 (5 - 3*2 = -1 → 0)
expect(getEffectiveRegen(state, ouroboros)).toBe(0);
// Armor should be reduced: 0.5 - 3*0.1 = 0.2
expect(getEffectiveArmor(state, ouroboros)).toBeCloseTo(0.2);
});
it('catalyst poison + direct damage is a viable strategy', () => {
const state = createBossState(ouroboros);
// Apply 3 Hg stacks to maximize weakness
for (let i = 0; i < 3; i++) {
applyBossDamage(state, ouroboros, 'Hg');
}
// Now kill with direct damage during Digest
state.currentPhase = BossPhase.Digest;
let hits = 0;
while (!state.defeated && hits < 50) {
applyBossDamage(state, ouroboros, 'Fe');
hits++;
}
expect(state.defeated).toBe(true);
// Victory method should be Direct (killing blow was Fe)
expect(state.victoryMethod).toBe(VictoryMethod.Direct);
});
});

514
tests/chemistry.test.ts Normal file
View File

@@ -0,0 +1,514 @@
import { describe, it, expect } from 'vitest';
import { ElementRegistry } from '../src/chemistry/elements';
import { CompoundRegistry } from '../src/chemistry/compounds';
import { ReactionEngine } from '../src/chemistry/engine';
// =============================================================================
// ELEMENT REGISTRY
// =============================================================================
describe('ElementRegistry', () => {
it('should load all 40 elements', () => {
expect(ElementRegistry.count()).toBe(40);
});
it('should look up elements by symbol', () => {
const na = ElementRegistry.getBySymbol('Na');
expect(na).toBeDefined();
expect(na!.name).toBe('Sodium');
expect(na!.nameRu).toBe('Натрий');
expect(na!.atomicNumber).toBe(11);
expect(na!.atomicMass).toBeCloseTo(22.990, 2);
expect(na!.category).toBe('alkali-metal');
});
it('should look up elements by atomic number', () => {
const fe = ElementRegistry.getByNumber(26);
expect(fe).toBeDefined();
expect(fe!.symbol).toBe('Fe');
expect(fe!.name).toBe('Iron');
});
it('should return undefined for non-existent elements', () => {
expect(ElementRegistry.getBySymbol('Xx')).toBeUndefined();
expect(ElementRegistry.getByNumber(999)).toBeUndefined();
});
it('should have correct data for all elements (real periodic table)', () => {
const h = ElementRegistry.getBySymbol('H')!;
expect(h.atomicNumber).toBe(1);
expect(h.atomicMass).toBeCloseTo(1.008, 2);
expect(h.state).toBe('gas');
const hg = ElementRegistry.getBySymbol('Hg')!;
expect(hg.atomicNumber).toBe(80);
expect(hg.state).toBe('liquid'); // Only metal liquid at room temp!
const he = ElementRegistry.getBySymbol('He')!;
expect(he.category).toBe('noble-gas');
expect(he.electronegativity).toBe(0);
const au = ElementRegistry.getBySymbol('Au')!;
expect(au.atomicNumber).toBe(79);
expect(au.category).toBe('transition-metal');
});
it('should identify elements correctly', () => {
expect(ElementRegistry.isElement('Na')).toBe(true);
expect(ElementRegistry.isElement('Fe')).toBe(true);
expect(ElementRegistry.isElement('NaCl')).toBe(false);
expect(ElementRegistry.isElement('H2O')).toBe(false);
});
it('should have all 20 new Phase 9 elements', () => {
const newSymbols = ['Li', 'B', 'F', 'Ne', 'Ar', 'Ti', 'Cr', 'Mn', 'Co', 'Ni', 'As', 'Br', 'Ag', 'I', 'Ba', 'W', 'Pt', 'Pb', 'Bi', 'U'];
for (const sym of newSymbols) {
expect(ElementRegistry.has(sym), `Element ${sym} not found`).toBe(true);
}
});
it('should have correct data for new elements (real periodic table)', () => {
const li = ElementRegistry.getBySymbol('Li')!;
expect(li.atomicNumber).toBe(3);
expect(li.category).toBe('alkali-metal');
const f = ElementRegistry.getBySymbol('F')!;
expect(f.atomicNumber).toBe(9);
expect(f.category).toBe('halogen');
expect(f.state).toBe('gas');
expect(f.electronegativity).toBeCloseTo(3.98, 1); // Most electronegative
const br = ElementRegistry.getBySymbol('Br')!;
expect(br.state).toBe('liquid'); // Only liquid non-metal at room temp
const w = ElementRegistry.getBySymbol('W')!;
expect(w.atomicNumber).toBe(74);
expect(w.name).toBe('Tungsten');
const u = ElementRegistry.getBySymbol('U')!;
expect(u.atomicNumber).toBe(92);
expect(u.category).toBe('actinide');
const pt = ElementRegistry.getBySymbol('Pt')!;
expect(pt.category).toBe('transition-metal');
});
});
// =============================================================================
// COMPOUND REGISTRY
// =============================================================================
describe('CompoundRegistry', () => {
it('should load all compounds', () => {
expect(CompoundRegistry.count()).toBeGreaterThanOrEqual(20);
});
it('should look up compounds by id', () => {
const water = CompoundRegistry.getById('H2O');
expect(water).toBeDefined();
expect(water!.name).toBe('Water');
expect(water!.formula).toBe('H₂O');
expect(water!.mass).toBeCloseTo(18.015, 2);
expect(water!.state).toBe('liquid');
});
it('should have game effects for each compound', () => {
const salt = CompoundRegistry.getById('NaCl')!;
expect(salt.gameEffects).toContain('preservation');
const gunpowder = CompoundRegistry.getById('GUNPOWDER')!;
expect(gunpowder.gameEffects).toContain('explosive');
expect(gunpowder.properties.explosive).toBe(true);
});
it('should correctly flag dangerous compounds', () => {
const hcl = CompoundRegistry.getById('HCl')!;
expect(hcl.properties.acidic).toBe(true);
expect(hcl.properties.corrosive).toBe(true);
const co = CompoundRegistry.getById('CO')!;
expect(co.properties.toxic).toBe(true);
const naoh = CompoundRegistry.getById('NaOH')!;
expect(naoh.properties.basic).toBe(true);
expect(naoh.properties.corrosive).toBe(true);
});
it('should identify compounds correctly', () => {
expect(CompoundRegistry.isCompound('NaCl')).toBe(true);
expect(CompoundRegistry.isCompound('H2O')).toBe(true);
expect(CompoundRegistry.isCompound('Na')).toBe(false);
});
it('should load all 64 compounds', () => {
expect(CompoundRegistry.count()).toBe(64);
});
it('should have new Phase 9 compounds', () => {
const newIds = ['NH3', 'HF', 'HBr', 'TiO2', 'MnO2', 'As2O3', 'H2SO4', 'HNO3', 'CuO', 'FeCl3', 'CaCl2', 'NH4Cl', 'C6H12O6', 'CH3COOH'];
for (const id of newIds) {
expect(CompoundRegistry.has(id), `Compound ${id} not found`).toBe(true);
}
});
it('should correctly flag new dangerous compounds', () => {
const hf = CompoundRegistry.getById('HF')!;
expect(hf.properties.acidic).toBe(true);
expect(hf.properties.corrosive).toBe(true);
expect(hf.properties.toxic).toBe(true);
const h2so4 = CompoundRegistry.getById('H2SO4')!;
expect(h2so4.properties.acidic).toBe(true);
expect(h2so4.properties.oxidizer).toBe(true);
const as2o3 = CompoundRegistry.getById('As2O3')!;
expect(as2o3.properties.toxic).toBe(true);
expect(as2o3.name).toContain('Arsenic');
});
});
// =============================================================================
// REACTION ENGINE — SUCCESSFUL REACTIONS
// =============================================================================
describe('ReactionEngine — success', () => {
it('should produce NaCl from Na + Cl', () => {
const result = ReactionEngine.react([
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'NaCl', count: 1 }]);
});
it('should produce H2O from 2H + O', () => {
const result = ReactionEngine.react(
[
{ id: 'H', count: 2 },
{ id: 'O', count: 1 },
],
{ minTemp: 500 },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'H2O', count: 1 }]);
});
it('should produce CO2 from C + 2O with heat', () => {
const result = ReactionEngine.react(
[
{ id: 'C', count: 1 },
{ id: 'O', count: 2 },
],
{ minTemp: 500 },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'CO2', count: 1 }]);
expect(result.reaction!.type).toBe('combustion');
});
it('should produce NaOH + H from Na + H2O (violent reaction)', () => {
const result = ReactionEngine.react([
{ id: 'Na', count: 1 },
{ id: 'H2O', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'NaOH', count: 1 });
expect(result.products).toContainEqual({ id: 'H', count: 1 });
expect(result.reaction!.energyChange).toBeLessThan(-50); // Very exothermic
});
it('should produce gunpowder from KNO3 + S + C', () => {
const result = ReactionEngine.react([
{ id: 'KNO3', count: 1 },
{ id: 'S', count: 1 },
{ id: 'C', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'GUNPOWDER', count: 1 }]);
});
it('should produce thermite: Fe2O3 + 2Al → 2Fe + Al2O3', () => {
const result = ReactionEngine.react(
[
{ id: 'Fe2O3', count: 1 },
{ id: 'Al', count: 2 },
],
{ minTemp: 1000 },
);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'Fe', count: 2 });
expect(result.products).toContainEqual({ id: 'Al2O3', count: 1 });
expect(result.reaction!.energyChange).toBe(-100); // Maximum exothermic
});
it('should produce NaCl + H2O from NaOH + HCl (neutralization)', () => {
const result = ReactionEngine.react([
{ id: 'NaOH', count: 1 },
{ id: 'HCl', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'NaCl', count: 1 });
expect(result.products).toContainEqual({ id: 'H2O', count: 1 });
expect(result.reaction!.type).toBe('acid-base');
});
it('should decompose H2O with energy input (electrolysis)', () => {
const result = ReactionEngine.react(
[{ id: 'H2O', count: 1 }],
{ requiresEnergy: true },
);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'H', count: 2 });
expect(result.products).toContainEqual({ id: 'O', count: 1 });
expect(result.reaction!.energyChange).toBeGreaterThan(0); // Endothermic
});
it('should produce HF from H + F', () => {
const result = ReactionEngine.react([
{ id: 'H', count: 1 },
{ id: 'F', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'HF', count: 1 }]);
});
it('should produce NH3 via Haber process (N + 3H with Fe catalyst + heat)', () => {
const result = ReactionEngine.react(
[
{ id: 'N', count: 1 },
{ id: 'H', count: 3 },
],
{ minTemp: 500, catalyst: 'Fe' },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'NH3', count: 1 }]);
});
it('should produce TiO2 from Ti + 2O with extreme heat', () => {
const result = ReactionEngine.react(
[
{ id: 'Ti', count: 1 },
{ id: 'O', count: 2 },
],
{ minTemp: 1000 },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'TiO2', count: 1 }]);
});
it('should produce tungsten via WO3 + Al redox', () => {
const result = ReactionEngine.react(
[
{ id: 'WO3', count: 1 },
{ id: 'Al', count: 2 },
],
{ minTemp: 1000 },
);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'W', count: 1 });
expect(result.products).toContainEqual({ id: 'Al2O3', count: 1 });
});
it('should neutralize HF with NaOH (acid-base)', () => {
const result = ReactionEngine.react([
{ id: 'NaOH', count: 1 },
{ id: 'HF', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'NaF', count: 1 });
expect(result.products).toContainEqual({ id: 'H2O', count: 1 });
});
it('should dissolve Zn in HCl (single-replacement)', () => {
const result = ReactionEngine.react([
{ id: 'Zn', count: 1 },
{ id: 'HCl', count: 2 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'ZnCl2', count: 1 });
expect(result.products).toContainEqual({ id: 'H', count: 2 });
});
it('should displace Cu with Fe from CuCl2', () => {
const result = ReactionEngine.react([
{ id: 'Fe', count: 1 },
{ id: 'CuCl2', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'Cu', count: 1 });
expect(result.products).toContainEqual({ id: 'FeCl2', count: 1 });
});
it('should ferment glucose into ethanol + CO2', () => {
const result = ReactionEngine.react([
{ id: 'C6H12O6', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'C2H5OH', count: 2 });
expect(result.products).toContainEqual({ id: 'CO2', count: 2 });
});
it('should produce H2SO4 via Contact process (Pt catalyst)', () => {
const result = ReactionEngine.react(
[
{ id: 'S', count: 1 },
{ id: 'O', count: 3 },
{ id: 'H', count: 2 },
],
{ catalyst: 'Pt' },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'H2SO4', count: 1 }]);
});
it('reactant order should not matter (key is sorted)', () => {
const r1 = ReactionEngine.react([
{ id: 'Cl', count: 1 },
{ id: 'Na', count: 1 },
]);
const r2 = ReactionEngine.react([
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
]);
expect(r1.success).toBe(true);
expect(r2.success).toBe(true);
expect(r1.products).toEqual(r2.products);
});
});
// =============================================================================
// REACTION ENGINE — FAILURES (educational)
// =============================================================================
describe('ReactionEngine — failures', () => {
it('should reject noble gas reactions with explanation', () => {
const result = ReactionEngine.react([
{ id: 'He', count: 1 },
{ id: 'O', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('noble gas');
expect(result.failureReasonRu).toContain('благородный газ');
});
it('should reject reactions with new noble gases (Ne, Ar)', () => {
const ne = ReactionEngine.react([{ id: 'Ne', count: 1 }, { id: 'F', count: 1 }]);
expect(ne.success).toBe(false);
expect(ne.failureReason).toContain('noble gas');
const ar = ReactionEngine.react([{ id: 'Ar', count: 1 }, { id: 'Cl', count: 1 }]);
expect(ar.success).toBe(false);
expect(ar.failureReason).toContain('noble gas');
});
it('should reject gold reactions with explanation', () => {
const result = ReactionEngine.react([
{ id: 'Au', count: 1 },
{ id: 'O', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('Gold');
});
it('should fail when heat is required but not provided', () => {
const result = ReactionEngine.react([
{ id: 'C', count: 1 },
{ id: 'O', count: 2 },
]); // No heat
expect(result.success).toBe(false);
expect(result.failureReason).toContain('temperature');
});
it('should fail when catalyst is required but not provided', () => {
const result = ReactionEngine.react(
[
{ id: 'C', count: 1 },
{ id: 'H', count: 4 },
],
{ minTemp: 500 }, // Heat provided, but catalyst (Fe) missing
);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('catalyst');
});
it('should fail when energy is required but not provided', () => {
const result = ReactionEngine.react([
{ id: 'H2O', count: 1 },
]); // Electrolysis needs energy
expect(result.success).toBe(false);
expect(result.failureReason).toContain('energy');
});
it('should fail for unknown substances', () => {
const result = ReactionEngine.react([
{ id: 'Unobtainium', count: 1 },
{ id: 'Na', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('Unknown substance');
});
it('should fail for same element with itself', () => {
const result = ReactionEngine.react([
{ id: 'Fe', count: 1 },
{ id: 'Fe', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toBeDefined();
});
it('should suggest wrong proportions when elements match but counts differ', () => {
// H + O exists? No, H:1+O:1 doesn't but H:2+O:1 does
const result = ReactionEngine.react(
[
{ id: 'H', count: 1 },
{ id: 'O', count: 1 },
],
{ minTemp: 500 },
);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('proportions');
});
});
// =============================================================================
// REACTION ENGINE — METADATA
// =============================================================================
describe('ReactionEngine — metadata', () => {
it('should have 100+ registered reactions', () => {
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(100);
});
it('should look up reactions by id', () => {
const thermite = ReactionEngine.getById('redox_thermite');
expect(thermite).toBeDefined();
expect(thermite!.type).toBe('redox');
});
it('every reaction should have both English and Russian descriptions', () => {
for (const r of ReactionEngine.getAll()) {
expect(r.description.length).toBeGreaterThan(10);
expect(r.descriptionRu.length).toBeGreaterThan(10);
}
});
it('every reaction should have valid reactants and products', () => {
for (const r of ReactionEngine.getAll()) {
expect(r.reactants.length).toBeGreaterThanOrEqual(1);
expect(r.products.length).toBeGreaterThanOrEqual(1);
for (const reactant of r.reactants) {
expect(reactant.count).toBeGreaterThan(0);
const exists =
ElementRegistry.isElement(reactant.id) || CompoundRegistry.isCompound(reactant.id);
expect(exists, `Reactant "${reactant.id}" in reaction "${r.id}" not found`).toBe(true);
}
for (const product of r.products) {
expect(product.count).toBeGreaterThan(0);
const exists =
ElementRegistry.isElement(product.id) || CompoundRegistry.isCompound(product.id);
expect(exists, `Product "${product.id}" in reaction "${r.id}" not found`).toBe(true);
}
}
});
});

189
tests/crafting.test.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* Crafting System Tests — Phase 4.4
*
* Tests: crafting from inventory, reagent consumption,
* product addition, failure reasons, condition checks.
*/
import { describe, it, expect } from 'vitest';
import { Inventory } from '../src/player/inventory';
import { craftFromInventory, type CraftInput } from '../src/player/crafting';
describe('craftFromInventory — success', () => {
it('crafts NaCl from Na + Cl', () => {
const inv = new Inventory();
inv.addItem('Na', 3);
inv.addItem('Cl', 2);
const inputs: CraftInput[] = [
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
];
const result = craftFromInventory(inv, inputs);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'NaCl', count: 1 });
// Reagents consumed
expect(inv.getCount('Na')).toBe(2);
expect(inv.getCount('Cl')).toBe(1);
// Product added
expect(inv.getCount('NaCl')).toBe(1);
});
it('returns reaction data on success', () => {
const inv = new Inventory();
inv.addItem('Na', 1);
inv.addItem('Cl', 1);
const result = craftFromInventory(inv, [
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.reaction).toBeDefined();
expect(result.reaction!.id).toBe('synth_nacl');
});
it('reports consumed reactants', () => {
const inv = new Inventory();
inv.addItem('Na', 5);
inv.addItem('Cl', 5);
const inputs: CraftInput[] = [
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
];
const result = craftFromInventory(inv, inputs);
expect(result.consumedReactants).toEqual(inputs);
});
});
describe('craftFromInventory — insufficient materials', () => {
it('fails when missing reagent', () => {
const inv = new Inventory();
inv.addItem('Na', 1);
// No Cl in inventory
const result = craftFromInventory(inv, [
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('Cl');
// Na not consumed
expect(inv.getCount('Na')).toBe(1);
});
it('fails when not enough of a reagent', () => {
const inv = new Inventory();
inv.addItem('Na', 1);
inv.addItem('Cl', 1);
const result = craftFromInventory(inv, [
{ id: 'Na', count: 5 }, // need 5, have 1
{ id: 'Cl', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('Na');
});
});
describe('craftFromInventory — unknown reaction', () => {
it('fails with educational reason for no known reaction', () => {
const inv = new Inventory(5000); // large capacity for heavy elements
inv.addItem('Fe', 5);
inv.addItem('Au', 5);
const result = craftFromInventory(inv, [
{ id: 'Fe', count: 1 },
{ id: 'Au', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toBeDefined();
expect(result.failureReason!.length).toBeGreaterThan(10); // educational reason
// Reagents not consumed
expect(inv.getCount('Fe')).toBe(5);
expect(inv.getCount('Au')).toBe(5);
});
it('explains noble gas inertness', () => {
const inv = new Inventory();
inv.addItem('He', 5);
inv.addItem('O', 5);
const result = craftFromInventory(inv, [
{ id: 'He', count: 1 },
{ id: 'O', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('noble gas');
});
});
describe('craftFromInventory — conditions', () => {
it('fails when temperature requirement not met', () => {
const inv = new Inventory();
inv.addItem('Fe', 5);
inv.addItem('S', 5);
// Fe + S → FeS requires minTemp: 500
const result = craftFromInventory(inv, [
{ id: 'Fe', count: 1 },
{ id: 'S', count: 1 },
]);
expect(result.success).toBe(false);
expect(result.failureReason).toContain('temperature');
// Reagents not consumed
expect(inv.getCount('Fe')).toBe(5);
});
it('succeeds when conditions are met', () => {
const inv = new Inventory();
inv.addItem('Fe', 5);
inv.addItem('S', 5);
const result = craftFromInventory(inv, [
{ id: 'Fe', count: 1 },
{ id: 'S', count: 1 },
], { minTemp: 500 });
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'FeS', count: 1 });
expect(inv.getCount('Fe')).toBe(4);
expect(inv.getCount('S')).toBe(4);
});
});
describe('craftFromInventory — edge cases', () => {
it('handles empty reactants list', () => {
const inv = new Inventory();
inv.addItem('Na', 5);
const result = craftFromInventory(inv, []);
expect(result.success).toBe(false);
});
it('handles inventory full for products', () => {
const inv = new Inventory(100, 2); // Only 2 slots
inv.addItem('Na', 1);
inv.addItem('Cl', 1);
// After crafting: Na consumed (slot freed), Cl consumed (slot freed), NaCl added
// This should work since slots are freed before products are added
const result = craftFromInventory(inv, [
{ id: 'Na', count: 1 },
{ id: 'Cl', count: 1 },
]);
expect(result.success).toBe(true);
expect(inv.getCount('NaCl')).toBe(1);
});
});

View File

@@ -0,0 +1,306 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import type { World } from '../src/ecs/world';
import {
Position,
Health,
Creature,
AI,
Metabolism,
LifeCycle,
Projectile,
PlayerTag,
SpriteRef,
Velocity,
} from '../src/ecs/components';
import { SpeciesId, AIState, LifeStage } from '../src/creatures/types';
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
import { createCreatureEntity } from '../src/creatures/factory';
import {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from '../src/creatures/interaction';
import type { ProjectileData } from '../src/player/projectile';
import speciesDataArray from '../src/data/creatures.json';
// ─── Helpers ─────────────────────────────────────────────────────
const allSpecies = speciesDataArray as SpeciesData[];
function getSpecies(id: string): SpeciesData {
const s = allSpecies.find(s => s.id === id);
if (!s) throw new Error(`Species not found: ${id}`);
return s;
}
function buildSpeciesLookup(): Map<number, SpeciesData> {
const map = new Map<number, SpeciesData>();
for (const s of allSpecies) map.set(s.speciesId, s);
return map;
}
function createProjectile(world: World, x: number, y: number, projData: Map<number, ProjectileData>): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Projectile);
addComponent(world, eid, SpriteRef);
addComponent(world, eid, Velocity);
Position.x[eid] = x;
Position.y[eid] = y;
Projectile.lifetime[eid] = 2000;
SpriteRef.color[eid] = 0xffffff;
SpriteRef.radius[eid] = 5;
projData.set(eid, { itemId: 'Na' });
return eid;
}
function createPlayer(world: World, x: number, y: number): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, PlayerTag);
addComponent(world, eid, Health);
Position.x[eid] = x;
Position.y[eid] = y;
Health.current[eid] = 100;
Health.max[eid] = 100;
return eid;
}
// ─── Projectile-Creature Collision ───────────────────────────────
describe('Projectile-Creature Collision', () => {
let world: World;
const speciesLookup = buildSpeciesLookup();
beforeEach(() => {
world = createWorld();
});
it('projectile hits creature within range', () => {
const species = getSpecies('crystallid');
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const hpBefore = Health.current[creature];
const projData = new Map<number, ProjectileData>();
createProjectile(world, 105, 100, projData); // 5px away, within 20px hit radius
const hits = creatureProjectileSystem(world, projData, speciesLookup);
expect(hits).toHaveLength(1);
expect(hits[0].creatureEid).toBe(creature);
expect(hits[0].killed).toBe(false);
expect(Health.current[creature]).toBeLessThan(hpBefore);
});
it('projectile is removed after hitting creature', () => {
const species = getSpecies('crystallid');
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const projData = new Map<number, ProjectileData>();
const projEid = createProjectile(world, 105, 100, projData);
creatureProjectileSystem(world, projData, speciesLookup);
// Projectile should be removed
expect(projData.has(projEid)).toBe(false);
expect([...query(world, [Projectile])]).not.toContain(projEid);
});
it('armor reduces damage (Crystallid has 0.3 armor)', () => {
const species = getSpecies('crystallid'); // armor = 0.3
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const projData = new Map<number, ProjectileData>();
createProjectile(world, 105, 100, projData);
const hits = creatureProjectileSystem(world, projData, speciesLookup);
// Base damage 15, armor 0.3 → 15 * 0.7 = 10.5 → rounded to 11
expect(hits[0].damage).toBe(11);
});
it('Reagent takes full damage (0 armor)', () => {
const species = getSpecies('reagent'); // armor = 0
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const projData = new Map<number, ProjectileData>();
createProjectile(world, 105, 100, projData);
const hits = creatureProjectileSystem(world, projData, speciesLookup);
expect(hits[0].damage).toBe(15);
});
it('projectile misses creature outside range', () => {
const species = getSpecies('crystallid');
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const projData = new Map<number, ProjectileData>();
createProjectile(world, 200, 200, projData); // far away
const hits = creatureProjectileSystem(world, projData, speciesLookup);
expect(hits).toHaveLength(0);
});
it('passive creature flees after being hit', () => {
const species = getSpecies('crystallid'); // aggressionRadius = 0 → passive
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const projData = new Map<number, ProjectileData>();
createProjectile(world, 105, 100, projData);
creatureProjectileSystem(world, projData, speciesLookup);
expect(AI.state[creature]).toBe(AIState.Flee);
});
it('aggressive creature attacks player after being hit', () => {
const species = getSpecies('acidophile'); // aggressionRadius = 100 → aggressive
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const player = createPlayer(world, 150, 100);
const projData = new Map<number, ProjectileData>();
createProjectile(world, 105, 100, projData);
creatureProjectileSystem(world, projData, speciesLookup);
expect(AI.state[creature]).toBe(AIState.Attack);
expect(AI.targetEid[creature]).toBe(player);
});
it('killing a creature reports killed=true', () => {
const species = getSpecies('reagent'); // 60 hp
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
Health.current[creature] = 10; // nearly dead
const projData = new Map<number, ProjectileData>();
createProjectile(world, 105, 100, projData);
const hits = creatureProjectileSystem(world, projData, speciesLookup);
expect(hits[0].killed).toBe(true);
});
});
// ─── Creature Observation ────────────────────────────────────────
describe('Creature Observation', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('detects creature near player', () => {
const species = getSpecies('crystallid');
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
createPlayer(world, 120, 100); // 20px away, within 60px observe range
const observations = getObservableCreatures(world);
expect(observations).toHaveLength(1);
expect(observations[0].speciesId).toBe(SpeciesId.Crystallid);
expect(observations[0].stage).toBe(LifeStage.Mature);
});
it('does not detect distant creature', () => {
const species = getSpecies('crystallid');
createCreatureEntity(world, species, 500, 500, LifeStage.Mature);
createPlayer(world, 100, 100);
const observations = getObservableCreatures(world);
expect(observations).toHaveLength(0);
});
it('returns health and energy percentages', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
Health.current[eid] = 60; // 50% of 120
Metabolism.energy[eid] = 50; // 50% of 100
createPlayer(world, 110, 100);
const observations = getObservableCreatures(world);
expect(observations[0].healthPercent).toBe(50);
expect(observations[0].energyPercent).toBe(50);
});
it('returns empty array when no player', () => {
const species = getSpecies('crystallid');
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const observations = getObservableCreatures(world);
expect(observations).toHaveLength(0);
});
});
// ─── Creature Attacks Player ─────────────────────────────────────
describe('Creature Attacks Player', () => {
let world: World;
const speciesLookup = buildSpeciesLookup();
beforeEach(() => {
world = createWorld();
});
it('attacking creature damages player in range', () => {
const species = getSpecies('reagent'); // attackRange = 18, damage = 20
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const player = createPlayer(world, 110, 100); // 10px away
AI.state[creature] = AIState.Attack;
AI.targetEid[creature] = player;
AI.attackCooldown[creature] = 0;
const damage = creatureAttackPlayerSystem(world, speciesLookup);
expect(damage).toBe(20);
expect(Health.current[player]).toBe(80);
});
it('no damage if creature is out of range', () => {
const species = getSpecies('reagent');
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const player = createPlayer(world, 200, 100); // 100px, beyond attackRange 18
AI.state[creature] = AIState.Attack;
AI.targetEid[creature] = player;
AI.attackCooldown[creature] = 0;
const damage = creatureAttackPlayerSystem(world, speciesLookup);
expect(damage).toBe(0);
});
it('attack cooldown prevents rapid attacks', () => {
const species = getSpecies('reagent');
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const player = createPlayer(world, 110, 100);
AI.state[creature] = AIState.Attack;
AI.targetEid[creature] = player;
AI.attackCooldown[creature] = 500; // on cooldown
const damage = creatureAttackPlayerSystem(world, speciesLookup);
expect(damage).toBe(0);
});
it('non-attacking creature does not damage player', () => {
const species = getSpecies('crystallid');
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const player = createPlayer(world, 110, 100);
AI.state[creature] = AIState.Wander; // not attacking
AI.attackCooldown[creature] = 0;
const damage = creatureAttackPlayerSystem(world, speciesLookup);
expect(damage).toBe(0);
});
});

795
tests/creatures.test.ts Normal file
View File

@@ -0,0 +1,795 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import type { World } from '../src/ecs/world';
import {
Position,
Velocity,
Health,
SpriteRef,
Creature,
AI,
Metabolism,
LifeCycle,
Resource,
PlayerTag,
} from '../src/ecs/components';
import {
SpeciesId,
AIState,
LifeStage,
SpeciesRegistry,
} from '../src/creatures/types';
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
import { createCreatureEntity } from '../src/creatures/factory';
import { aiSystem } from '../src/creatures/ai';
import {
metabolismSystem,
resetMetabolismTracking,
} from '../src/creatures/metabolism';
import { lifeCycleSystem } from '../src/creatures/lifecycle';
import { countPopulations, reproduce } from '../src/creatures/population';
import speciesDataArray from '../src/data/creatures.json';
// ─── Test Helpers ────────────────────────────────────────────────
const allSpecies = speciesDataArray as SpeciesData[];
function getSpecies(id: string): SpeciesData {
const s = allSpecies.find(s => s.id === id);
if (!s) throw new Error(`Species not found: ${id}`);
return s;
}
function buildSpeciesLookup(): Map<number, SpeciesData> {
const map = new Map<number, SpeciesData>();
for (const s of allSpecies) {
map.set(s.speciesId, s);
}
return map;
}
function createResourceEntity(world: World, x: number, y: number, qty: number): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Resource);
Position.x[eid] = x;
Position.y[eid] = y;
Resource.quantity[eid] = qty;
Resource.interactRange[eid] = 40;
return eid;
}
function createPlayerEntity(world: World, x: number, y: number): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, PlayerTag);
Position.x[eid] = x;
Position.y[eid] = y;
return eid;
}
// ─── Species Data ────────────────────────────────────────────────
describe('Species Data', () => {
it('loads 9 species from JSON (3 per biome)', () => {
expect(allSpecies).toHaveLength(9);
});
it('has Crystallid with correct properties', () => {
const c = getSpecies('crystallid');
expect(c.speciesId).toBe(SpeciesId.Crystallid);
expect(c.health).toBe(120);
expect(c.speed).toBe(30);
expect(c.armor).toBe(0.3);
expect(c.diet).toBe('mineral');
expect(c.excretionElement).toBe(14); // Si
});
it('has Acidophile with correct properties', () => {
const a = getSpecies('acidophile');
expect(a.speciesId).toBe(SpeciesId.Acidophile);
expect(a.health).toBe(80);
expect(a.speed).toBe(50);
expect(a.diet).toBe('mineral');
expect(a.excretionElement).toBe(17); // Cl
});
it('has Reagent as predator', () => {
const r = getSpecies('reagent');
expect(r.speciesId).toBe(SpeciesId.Reagent);
expect(r.speed).toBe(80);
expect(r.diet).toBe('creature');
expect(r.damage).toBe(20);
});
it('all species have valid life cycle durations', () => {
for (const species of allSpecies) {
expect(species.eggDuration).toBeGreaterThan(0);
expect(species.youthDuration).toBeGreaterThan(0);
expect(species.matureDuration).toBeGreaterThan(0);
expect(species.agingDuration).toBeGreaterThan(0);
}
});
it('all species have valid color hex strings', () => {
for (const species of allSpecies) {
expect(species.color).toMatch(/^#[0-9a-fA-F]{6}$/);
}
});
});
// ─── Species Registry ────────────────────────────────────────────
describe('Species Registry', () => {
let registry: SpeciesRegistry;
beforeEach(() => {
registry = new SpeciesRegistry(allSpecies);
});
it('has correct count', () => {
expect(registry.count).toBe(9);
});
it('looks up by string ID', () => {
const c = registry.get('crystallid');
expect(c?.name).toBe('Crystallid');
});
it('looks up by numeric ID', () => {
const a = registry.getByNumericId(SpeciesId.Acidophile);
expect(a?.id).toBe('acidophile');
});
it('returns undefined for unknown ID', () => {
expect(registry.get('nonexistent')).toBeUndefined();
expect(registry.getByNumericId(99 as SpeciesId)).toBeUndefined();
});
it('returns all species', () => {
const all = registry.getAll();
expect(all).toHaveLength(9);
});
it('can look up new Phase 9 species', () => {
expect(registry.get('pendulum')?.biome).toBe('kinetic-mountains');
expect(registry.get('mechanoid')?.biome).toBe('kinetic-mountains');
expect(registry.get('resonator')?.biome).toBe('kinetic-mountains');
expect(registry.get('symbiote')?.biome).toBe('verdant-forests');
expect(registry.get('mimic')?.biome).toBe('verdant-forests');
expect(registry.get('spore-bearer')?.biome).toBe('verdant-forests');
});
it('each biome has exactly 3 species', () => {
const all = registry.getAll();
const byBiome = new Map<string, number>();
for (const s of all) {
byBiome.set(s.biome, (byBiome.get(s.biome) ?? 0) + 1);
}
expect(byBiome.get('catalytic-wastes')).toBe(3);
expect(byBiome.get('kinetic-mountains')).toBe(3);
expect(byBiome.get('verdant-forests')).toBe(3);
});
});
// ─── Creature Factory ────────────────────────────────────────────
describe('Creature Factory', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('creates creature with all components', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 200);
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
expect(Creature.speciesId[eid]).toBe(SpeciesId.Crystallid);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
expect(AI.state[eid]).toBe(AIState.Idle);
expect(Metabolism.energyMax[eid]).toBe(species.energyMax);
});
it('starts as egg with reduced health', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Egg);
expect(Health.current[eid]).toBe(Math.round(species.health * 0.3));
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
expect(SpriteRef.radius[eid]).toBe(species.radiusYouth);
});
it('creates mature creature with full stats', () => {
const species = getSpecies('acidophile');
const eid = createCreatureEntity(world, species, 50, 50, LifeStage.Mature);
expect(Health.current[eid]).toBe(species.health);
expect(Health.max[eid]).toBe(species.health);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
expect(SpriteRef.radius[eid]).toBe(species.radius);
expect(AI.state[eid]).toBe(AIState.Wander); // mature starts wandering
});
it('sets home position to spawn position', () => {
const species = getSpecies('reagent');
const eid = createCreatureEntity(world, species, 300, 400);
expect(AI.homeX[eid]).toBe(300);
expect(AI.homeY[eid]).toBe(400);
});
it('sets metabolism from species data', () => {
const species = getSpecies('reagent');
const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Mature);
expect(Metabolism.energyMax[eid]).toBe(species.energyMax);
expect(Metabolism.drainRate[eid]).toBe(species.energyDrainPerSecond);
expect(Metabolism.feedAmount[eid]).toBe(species.energyPerFeed);
expect(Metabolism.energy[eid]).toBe(species.energyMax * 0.7);
});
it('queries creature entities correctly', () => {
const s1 = getSpecies('crystallid');
const s2 = getSpecies('reagent');
const e1 = createCreatureEntity(world, s1, 0, 0);
const e2 = createCreatureEntity(world, s2, 100, 100);
const creatures = [...query(world, [Creature])];
expect(creatures).toContain(e1);
expect(creatures).toContain(e2);
expect(creatures).toHaveLength(2);
});
});
// ─── AI System ───────────────────────────────────────────────────
describe('AI System', () => {
let world: World;
const speciesLookup = buildSpeciesLookup();
beforeEach(() => {
world = createWorld();
});
it('eggs do not move', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
aiSystem(world, 16, speciesLookup, 1);
expect(Velocity.vx[eid]).toBe(0);
expect(Velocity.vy[eid]).toBe(0);
});
it('idle creatures have zero velocity', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Idle;
AI.stateTimer[eid] = 5000;
aiSystem(world, 16, speciesLookup, 1);
expect(Velocity.vx[eid]).toBe(0);
expect(Velocity.vy[eid]).toBe(0);
});
it('wandering creatures have non-zero velocity', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 5000;
aiSystem(world, 16, speciesLookup, 1);
const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2);
expect(speed).toBeGreaterThan(0);
});
it('creature flees from nearby player', () => {
const species = getSpecies('crystallid'); // fleeRadius = 80
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 5000;
// Place player within flee radius
createPlayerEntity(world, 130, 100); // 30px away, within 80
aiSystem(world, 16, speciesLookup, 1);
expect(AI.state[eid]).toBe(AIState.Flee);
});
it('Reagents do not flee from player', () => {
const species = getSpecies('reagent');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 5000;
createPlayerEntity(world, 110, 100); // very close
aiSystem(world, 16, speciesLookup, 1);
expect(AI.state[eid]).not.toBe(AIState.Flee);
});
it('hungry creature enters Feed state', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 5000;
// Set energy below hunger threshold
Metabolism.energy[eid] = species.energyMax * 0.2; // well below 0.4 threshold
aiSystem(world, 16, speciesLookup, 1);
expect(AI.state[eid]).toBe(AIState.Feed);
});
it('Reagent attacks nearby non-Reagent creature when hungry', () => {
const reagent = getSpecies('reagent');
const crystallid = getSpecies('crystallid');
const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
const prey = createCreatureEntity(world, crystallid, 120, 100, LifeStage.Mature); // 20px away, within aggressionRadius 150
// Make predator hungry
Metabolism.energy[predator] = reagent.energyMax * 0.3;
AI.state[predator] = AIState.Wander;
AI.stateTimer[predator] = 5000;
aiSystem(world, 16, speciesLookup, 1);
expect(AI.state[predator]).toBe(AIState.Attack);
expect(AI.targetEid[predator]).toBe(prey);
});
it('attacking creature deals damage when in range', () => {
const reagent = getSpecies('reagent');
const crystallid = getSpecies('crystallid');
const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature); // 10px, within attackRange 18
const preyHpBefore = Health.current[prey];
AI.state[predator] = AIState.Attack;
AI.targetEid[predator] = prey;
AI.stateTimer[predator] = 5000;
AI.attackCooldown[predator] = 0;
Metabolism.energy[predator] = reagent.energyMax * 0.3;
aiSystem(world, 16, speciesLookup, 1);
expect(Health.current[prey]).toBeLessThan(preyHpBefore);
});
it('state transitions on timer expiry (idle → wander)', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Idle;
AI.stateTimer[eid] = 10; // almost expired
aiSystem(world, 20, speciesLookup, 1); // 20ms > 10ms timer
expect(AI.state[eid]).toBe(AIState.Wander);
});
it('wander → idle on timer expiry', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 10;
aiSystem(world, 20, speciesLookup, 1);
expect(AI.state[eid]).toBe(AIState.Idle);
});
it('creature returns to home when too far', () => {
const species = getSpecies('crystallid'); // wanderRadius = 200
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.homeX[eid] = 100;
AI.homeY[eid] = 100;
// Move creature far from home
Position.x[eid] = 500; // 400px away, beyond wanderRadius 200
Position.y[eid] = 100;
AI.state[eid] = AIState.Wander;
AI.stateTimer[eid] = 5000;
aiSystem(world, 16, speciesLookup, 1);
// Should be moving toward home (negative vx toward 100)
expect(Velocity.vx[eid]).toBeLessThan(0);
});
});
// ─── Metabolism System ───────────────────────────────────────────
describe('Metabolism System', () => {
let world: World;
const emptyResourceData = new Map<number, { itemId: string; tileX: number; tileY: number }>();
beforeEach(() => {
world = createWorld();
resetMetabolismTracking();
});
it('drains energy over time', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const energyBefore = Metabolism.energy[eid];
metabolismSystem(world, 1000, emptyResourceData, 1000); // 1 second
expect(Metabolism.energy[eid]).toBeLessThan(energyBefore);
expect(Metabolism.energy[eid]).toBeCloseTo(
energyBefore - species.energyDrainPerSecond,
1,
);
});
it('eggs do not metabolize', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
const energyBefore = Metabolism.energy[eid];
metabolismSystem(world, 1000, emptyResourceData, 1000);
expect(Metabolism.energy[eid]).toBe(energyBefore);
});
it('starvation deals damage when energy = 0', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
Metabolism.energy[eid] = 0;
const hpBefore = Health.current[eid];
metabolismSystem(world, 1000, emptyResourceData, 1000);
expect(Health.current[eid]).toBeLessThan(hpBefore);
});
it('creature feeds from nearby resource', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Feed;
Metabolism.energy[eid] = 20; // low energy
// Place resource next to creature
const resEid = createResourceEntity(world, 110, 100, 5);
metabolismSystem(world, 16, emptyResourceData, 5000);
// Should have gained energy
expect(Metabolism.energy[eid]).toBeGreaterThan(20);
// Resource should be depleted by 1
expect(Resource.quantity[resEid]).toBe(4);
});
it('returns excretion events after feeding', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Feed;
Metabolism.energy[eid] = 20;
createResourceEntity(world, 110, 100, 5);
const excretions = metabolismSystem(world, 16, emptyResourceData, 5000);
expect(excretions.length).toBeGreaterThan(0);
expect(excretions[0][0]).toBe(eid); // creature eid
expect(excretions[0][1]).toBe(SpeciesId.Crystallid);
});
it('energy does not exceed max', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
AI.state[eid] = AIState.Feed;
Metabolism.energy[eid] = species.energyMax - 1; // almost full
createResourceEntity(world, 110, 100, 5);
metabolismSystem(world, 16, emptyResourceData, 5000);
expect(Metabolism.energy[eid]).toBe(species.energyMax);
});
it('youth drains energy slower (0.7x)', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
const energyBefore = Metabolism.energy[eid];
metabolismSystem(world, 1000, emptyResourceData, 1000);
const drained = energyBefore - Metabolism.energy[eid];
expect(drained).toBeCloseTo(species.energyDrainPerSecond * 0.7, 1);
});
it('aging drains energy faster (1.3x)', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging);
const energyBefore = Metabolism.energy[eid];
metabolismSystem(world, 1000, emptyResourceData, 1000);
const drained = energyBefore - Metabolism.energy[eid];
expect(drained).toBeCloseTo(species.energyDrainPerSecond * 1.3, 1);
});
});
// ─── Life Cycle System ───────────────────────────────────────────
describe('Life Cycle System', () => {
let world: World;
const speciesLookup = buildSpeciesLookup();
beforeEach(() => {
world = createWorld();
});
it('advances age over time', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
lifeCycleSystem(world, 1000, speciesLookup);
expect(LifeCycle.age[eid]).toBe(1000);
});
it('egg hatches to youth when timer expires', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
// Fast-forward past egg duration
const events = lifeCycleSystem(world, species.eggDuration + 100, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Youth)).toBe(true);
});
it('youth grows to mature', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
const events = lifeCycleSystem(world, species.youthDuration + 100, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
expect(Health.current[eid]).toBe(species.health); // full health
expect(SpriteRef.radius[eid]).toBe(species.radius); // full size
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Mature)).toBe(true);
});
it('mature ages to aging', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const events = lifeCycleSystem(world, species.matureDuration + 100, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
expect(Health.max[eid]).toBe(Math.round(species.health * 0.7)); // reduced
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Aging)).toBe(true);
});
it('aging leads to natural death', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging);
const events = lifeCycleSystem(world, species.agingDuration + 100, speciesLookup);
expect(Health.current[eid]).toBe(0);
expect(events.some(e => e.type === 'natural_death')).toBe(true);
});
it('reports ready_to_reproduce for mature creatures with high energy', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
Metabolism.energy[eid] = species.energyMax * 0.9; // above 0.8 threshold
// Don't let timer expire
LifeCycle.stageTimer[eid] = 99999;
const events = lifeCycleSystem(world, 100, speciesLookup);
expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(true);
});
it('does not report reproduction for youth', () => {
const species = getSpecies('crystallid');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
Metabolism.energy[eid] = species.energyMax; // full energy but youth
LifeCycle.stageTimer[eid] = 99999;
const events = lifeCycleSystem(world, 100, speciesLookup);
expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(false);
});
it('full lifecycle: egg → youth → mature → aging → death', () => {
const species = getSpecies('acidophile');
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
// Egg → Youth
lifeCycleSystem(world, species.eggDuration + 1, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
// Youth → Mature
lifeCycleSystem(world, species.youthDuration + 1, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
// Mature → Aging
lifeCycleSystem(world, species.matureDuration + 1, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
// Aging → Death
const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup);
expect(Health.current[eid]).toBe(0);
expect(events.some(e => e.type === 'natural_death')).toBe(true);
});
});
// ─── Population Dynamics ─────────────────────────────────────────
describe('Population Dynamics', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('counts populations correctly', () => {
const s1 = getSpecies('crystallid');
const s2 = getSpecies('reagent');
createCreatureEntity(world, s1, 0, 0, LifeStage.Mature);
createCreatureEntity(world, s1, 100, 0, LifeStage.Mature);
createCreatureEntity(world, s2, 200, 0, LifeStage.Mature);
const counts = countPopulations(world);
expect(counts.get(SpeciesId.Crystallid)).toBe(2);
expect(counts.get(SpeciesId.Reagent)).toBe(1);
expect(counts.get(SpeciesId.Acidophile)).toBeUndefined();
});
it('reproduce creates offspring near parent', () => {
const species = getSpecies('crystallid'); // offspringCount = 2
const parent = createCreatureEntity(world, species, 200, 200, LifeStage.Mature);
const creatureData = new Map<number, CreatureInfo>();
const newEids = reproduce(world, parent, species, 1, creatureData);
expect(newEids).toHaveLength(2);
for (const eid of newEids) {
// Offspring should be eggs near parent
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
const dx = Math.abs(Position.x[eid] - 200);
const dy = Math.abs(Position.y[eid] - 200);
expect(dx).toBeLessThanOrEqual(25);
expect(dy).toBeLessThanOrEqual(25);
// Should be tracked in creature data
expect(creatureData.has(eid)).toBe(true);
}
});
it('reproduce respects population cap', () => {
const species = getSpecies('reagent'); // maxPopulation = 8, offspringCount = 1
const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const creatureData = new Map<number, CreatureInfo>();
// At max population
const newEids = reproduce(world, parent, species, 8, creatureData);
expect(newEids).toHaveLength(0);
});
it('reproduce limits offspring to not exceed cap', () => {
const species = getSpecies('crystallid'); // maxPopulation = 12, offspringCount = 2
const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
const creatureData = new Map<number, CreatureInfo>();
// Population at 11, only room for 1 offspring
const newEids = reproduce(world, parent, species, 11, creatureData);
expect(newEids).toHaveLength(1);
});
});
// ─── Integration ─────────────────────────────────────────────────
describe('Creature Integration', () => {
let world: World;
const speciesLookup = buildSpeciesLookup();
beforeEach(() => {
world = createWorld();
resetMetabolismTracking();
});
it('creature lifecycle: hatch → feed → reproduce → age → die', () => {
const species = getSpecies('acidophile');
const eid = createCreatureEntity(world, species, 200, 200, LifeStage.Egg);
// 1. Hatch
lifeCycleSystem(world, species.eggDuration + 1, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
// 2. Grow to mature
lifeCycleSystem(world, species.youthDuration + 1, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
// 3. AI works — creature wanders
aiSystem(world, 16, speciesLookup, 1);
const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2);
expect(speed).toBeGreaterThan(0);
// 4. Metabolism drains energy
const energyBefore = Metabolism.energy[eid];
metabolismSystem(world, 1000, new Map(), 10000);
expect(Metabolism.energy[eid]).toBeLessThan(energyBefore);
// 5. Age to aging
lifeCycleSystem(world, species.matureDuration + 1, speciesLookup);
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
// 6. Natural death
const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup);
expect(events.some(e => e.type === 'natural_death')).toBe(true);
});
it('predator-prey interaction: reagent kills crystallid', () => {
const reagent = getSpecies('reagent');
const crystallid = getSpecies('crystallid');
const pred = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature);
// Set predator to attack
AI.state[pred] = AIState.Attack;
AI.targetEid[pred] = prey;
AI.stateTimer[pred] = 10000;
AI.attackCooldown[pred] = 0;
Metabolism.energy[pred] = reagent.energyMax * 0.3;
const initialHp = Health.current[prey];
// Simulate several attack cycles
for (let i = 0; i < 20; i++) {
aiSystem(world, 1000, speciesLookup, i);
AI.attackCooldown[pred] = 0; // reset cooldown for test
}
expect(Health.current[prey]).toBeLessThanOrEqual(0);
});
it('creature ecosystem with multiple species runs without errors', () => {
const species = allSpecies;
// Spawn several of each
for (const s of species) {
for (let i = 0; i < 3; i++) {
createCreatureEntity(world, s, 100 + i * 50, 100 + s.speciesId * 100, LifeStage.Mature);
}
}
// Run 100 ticks
for (let tick = 0; tick < 100; tick++) {
aiSystem(world, 16, speciesLookup, tick);
metabolismSystem(world, 16, new Map(), tick * 16);
lifeCycleSystem(world, 16, speciesLookup);
}
// Should still have creatures alive
const counts = countPopulations(world);
const total = [...counts.values()].reduce((a, b) => a + b, 0);
expect(total).toBeGreaterThan(0);
});
});

280
tests/ecosystem.test.ts Normal file
View File

@@ -0,0 +1,280 @@
/**
* Ecosystem Test — verify populations fluctuate without dying out
*
* Runs a headless simulation with all creature systems active,
* no player present. Verifies:
* - Populations oscillate (Lotka-Volterra dynamics)
* - No species goes fully extinct within the test window
* - Reproduction keeps populations alive
* - Predation reduces prey population
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import type { World } from '../src/ecs/world';
import { Position, Resource, Creature, Health, Metabolism, LifeCycle, AI } from '../src/ecs/components';
import { SpeciesId, LifeStage, AIState } from '../src/creatures/types';
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
import { createCreatureEntity } from '../src/creatures/factory';
import { aiSystem } from '../src/creatures/ai';
import { metabolismSystem, resetMetabolismTracking } from '../src/creatures/metabolism';
import { lifeCycleSystem } from '../src/creatures/lifecycle';
import { countPopulations, reproduce } from '../src/creatures/population';
import { movementSystem } from '../src/ecs/systems/movement';
import { healthSystem } from '../src/ecs/systems/health';
import { removeGameEntity } from '../src/ecs/factory';
import speciesDataArray from '../src/data/creatures.json';
// ─── Helpers ─────────────────────────────────────────────────────
const allSpecies = speciesDataArray as SpeciesData[];
function getSpecies(id: string): SpeciesData {
const s = allSpecies.find(s => s.id === id);
if (!s) throw new Error(`Species not found: ${id}`);
return s;
}
function buildSpeciesLookup(): Map<number, SpeciesData> {
const map = new Map<number, SpeciesData>();
for (const s of allSpecies) map.set(s.speciesId, s);
return map;
}
function createResourceField(world: World, count: number): void {
for (let i = 0; i < count; i++) {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Resource);
Position.x[eid] = (i % 10) * 50 + 25;
Position.y[eid] = Math.floor(i / 10) * 50 + 25;
Resource.quantity[eid] = 999; // infinite for ecosystem test
Resource.interactRange[eid] = 40;
}
}
/**
* Run one simulation tick.
* Mimics what GameScene.update() does, minus Phaser-specific parts.
*/
function simulateTick(
world: World,
deltaMs: number,
speciesLookup: Map<number, SpeciesData>,
tick: number,
creatureData: Map<number, CreatureInfo>,
): void {
const elapsed = tick * deltaMs;
// AI
aiSystem(world, deltaMs, speciesLookup, tick);
// Movement
movementSystem(world, deltaMs);
// Metabolism
metabolismSystem(world, deltaMs, new Map(), elapsed);
// Life cycle
const lcEvents = lifeCycleSystem(world, deltaMs, speciesLookup);
// Reproduction
const populations = countPopulations(world);
for (const event of lcEvents) {
if (event.type === 'ready_to_reproduce') {
const species = speciesLookup.get(event.speciesId);
if (species) {
const currentPop = populations.get(event.speciesId) ?? 0;
reproduce(world, event.eid, species, currentPop, creatureData);
}
}
}
// Health / death
const dead = healthSystem(world);
for (const eid of dead) {
creatureData.delete(eid);
removeGameEntity(world, eid);
}
}
// ─── Ecosystem Tests ─────────────────────────────────────────────
describe('Ecosystem Simulation', () => {
let world: World;
const speciesLookup = buildSpeciesLookup();
const DELTA = 100; // 100ms per tick (10 fps for speed)
beforeEach(() => {
world = createWorld();
resetMetabolismTracking();
});
it('single species population stabilizes with food', () => {
const species = getSpecies('crystallid');
const creatureData = new Map<number, CreatureInfo>();
// Spawn 5 mature Crystallids
for (let i = 0; i < 5; i++) {
const eid = createCreatureEntity(
world, species, 50 + i * 40, 50, LifeStage.Mature,
);
// Give them plenty of energy so they can reproduce
Metabolism.energy[eid] = species.energyMax;
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
}
// Abundant food
createResourceField(world, 30);
// Simulate 200 ticks (20 seconds game time)
const populationHistory: number[] = [];
for (let t = 0; t < 200; t++) {
simulateTick(world, DELTA, speciesLookup, t, creatureData);
if (t % 10 === 0) {
const counts = countPopulations(world);
populationHistory.push(counts.get(SpeciesId.Crystallid) ?? 0);
}
}
// Population should not die out
const finalCount = countPopulations(world).get(SpeciesId.Crystallid) ?? 0;
expect(finalCount).toBeGreaterThan(0);
// Population should have some variation (not flat)
const min = Math.min(...populationHistory);
const max = Math.max(...populationHistory);
// At minimum, we should have had some births (max > initial 5)
expect(max).toBeGreaterThanOrEqual(5);
});
it('predator-prey dynamics: reagents reduce prey population', () => {
const crystallid = getSpecies('crystallid');
const reagent = getSpecies('reagent');
const creatureData = new Map<number, CreatureInfo>();
// Spawn prey
for (let i = 0; i < 6; i++) {
const eid = createCreatureEntity(
world, crystallid, 100 + i * 30, 100, LifeStage.Mature,
);
Metabolism.energy[eid] = crystallid.energyMax;
creatureData.set(eid, { speciesId: crystallid.speciesId, speciesDataId: crystallid.id });
}
// Spawn predators
for (let i = 0; i < 3; i++) {
const eid = createCreatureEntity(
world, reagent, 100 + i * 30, 130, LifeStage.Mature,
);
Metabolism.energy[eid] = reagent.energyMax * 0.3; // hungry!
creatureData.set(eid, { speciesId: reagent.speciesId, speciesDataId: reagent.id });
}
createResourceField(world, 20);
// Record initial prey count
const initialPrey = countPopulations(world).get(SpeciesId.Crystallid) ?? 0;
expect(initialPrey).toBe(6);
// Simulate for a while — predators should hunt
for (let t = 0; t < 300; t++) {
simulateTick(world, DELTA, speciesLookup, t, creatureData);
}
// Some prey should have been killed by predators
const finalPrey = countPopulations(world).get(SpeciesId.Crystallid) ?? 0;
// Either prey died from predation/aging, or reproduced
// The key check: something happened (population changed)
const totalCreatures = [...countPopulations(world).values()].reduce((a, b) => a + b, 0);
expect(totalCreatures).toBeGreaterThan(0); // ecosystem still alive
});
it('starvation without food leads to population decline', () => {
const species = getSpecies('acidophile');
const creatureData = new Map<number, CreatureInfo>();
// Spawn creatures with low energy and NO food sources
for (let i = 0; i < 5; i++) {
const eid = createCreatureEntity(
world, species, 50 + i * 40, 50, LifeStage.Mature,
);
Metabolism.energy[eid] = 10; // very low energy, no food available
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
}
// NO food! Simulate starvation (need ~25s for full drain + death at 100ms/tick)
for (let t = 0; t < 300; t++) {
simulateTick(world, DELTA, speciesLookup, t, creatureData);
}
// Population should decline due to starvation
const finalCount = countPopulations(world).get(SpeciesId.Acidophile) ?? 0;
expect(finalCount).toBeLessThan(5);
});
it('mixed ecosystem runs without errors for 500 ticks', () => {
const creatureData = new Map<number, CreatureInfo>();
// Spawn a balanced mix
for (const species of allSpecies) {
const count = species.id === 'reagent' ? 2 : 4;
for (let i = 0; i < count; i++) {
const eid = createCreatureEntity(
world, species,
50 + i * 60 + species.speciesId * 200,
50 + species.speciesId * 100,
LifeStage.Mature,
);
Metabolism.energy[eid] = species.energyMax * 0.8;
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
}
}
createResourceField(world, 40);
// Run 500 ticks — should not throw
for (let t = 0; t < 500; t++) {
simulateTick(world, DELTA, speciesLookup, t, creatureData);
}
// At least some creatures should survive
const total = [...countPopulations(world).values()].reduce((a, b) => a + b, 0);
expect(total).toBeGreaterThan(0);
});
it('lifecycle drives population renewal', () => {
const species = getSpecies('acidophile');
const creatureData = new Map<number, CreatureInfo>();
// Start with eggs — they should hatch, grow, reproduce
for (let i = 0; i < 4; i++) {
const eid = createCreatureEntity(
world, species, 50 + i * 30, 50, LifeStage.Egg,
);
creatureData.set(eid, { speciesId: species.speciesId, speciesDataId: species.id });
}
createResourceField(world, 20);
// Run long enough for eggs to hatch and creatures to mature
// egg(6s) + youth(12s) = 18s → need 180 ticks at 100ms/tick
for (let t = 0; t < 250; t++) {
simulateTick(world, DELTA, speciesLookup, t, creatureData);
}
// Should have some mature creatures (eggs hatched and grew)
const allCreatures = query(world, [Creature, LifeCycle]);
let hasMature = false;
for (const eid of allCreatures) {
if (LifeCycle.stage[eid] === LifeStage.Mature || LifeCycle.stage[eid] === LifeStage.Aging) {
hasMature = true;
break;
}
}
// Either has mature creatures or population renewed
const total = countPopulations(world).get(SpeciesId.Acidophile) ?? 0;
expect(total).toBeGreaterThan(0);
});
});

631
tests/ecs.test.ts Normal file
View File

@@ -0,0 +1,631 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import {
Position,
Velocity,
Health,
SpriteRef,
ChemicalComposition,
} from '../src/ecs/components';
import {
createGameWorld,
updateTime,
type World,
} from '../src/ecs/world';
import {
movementSystem,
bounceSystem,
} from '../src/ecs/systems/movement';
import {
healthSystem,
applyDamage,
applyHealing,
} from '../src/ecs/systems/health';
import {
createGameEntity,
removeGameEntity,
} from '../src/ecs/factory';
// ─── World ──────────────────────────────────────────────────────
describe('World', () => {
it('creates a game world with zeroed time', () => {
const gw = createGameWorld();
expect(gw.world).toBeDefined();
expect(gw.time.delta).toBe(0);
expect(gw.time.elapsed).toBe(0);
expect(gw.time.tick).toBe(0);
});
it('updates time tracking correctly', () => {
const gw = createGameWorld();
updateTime(gw, 16.67);
expect(gw.time.delta).toBeCloseTo(16.67);
expect(gw.time.elapsed).toBeCloseTo(16.67);
expect(gw.time.tick).toBe(1);
updateTime(gw, 16.67);
expect(gw.time.delta).toBeCloseTo(16.67);
expect(gw.time.elapsed).toBeCloseTo(33.34);
expect(gw.time.tick).toBe(2);
});
it('tracks varying delta times', () => {
const gw = createGameWorld();
updateTime(gw, 10);
updateTime(gw, 20);
updateTime(gw, 30);
expect(gw.time.delta).toBe(30);
expect(gw.time.elapsed).toBe(60);
expect(gw.time.tick).toBe(3);
});
});
// ─── Components ─────────────────────────────────────────────────
describe('Components', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('stores Position data for entities', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
Position.x[eid] = 100;
Position.y[eid] = 200;
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
});
it('stores Velocity data for entities', () => {
const eid = addEntity(world);
addComponent(world, eid, Velocity);
Velocity.vx[eid] = 50;
Velocity.vy[eid] = -30;
expect(Velocity.vx[eid]).toBe(50);
expect(Velocity.vy[eid]).toBe(-30);
});
it('stores Health data for entities', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 80;
Health.max[eid] = 100;
expect(Health.current[eid]).toBe(80);
expect(Health.max[eid]).toBe(100);
});
it('stores SpriteRef data for entities', () => {
const eid = addEntity(world);
addComponent(world, eid, SpriteRef);
SpriteRef.color[eid] = 0x00ff88;
SpriteRef.radius[eid] = 12;
expect(SpriteRef.color[eid]).toBe(0x00ff88);
expect(SpriteRef.radius[eid]).toBe(12);
});
it('stores ChemicalComposition for entities', () => {
const eid = addEntity(world);
addComponent(world, eid, ChemicalComposition);
ChemicalComposition.primaryElement[eid] = 11; // Na
expect(ChemicalComposition.primaryElement[eid]).toBe(11);
});
it('queries entities by single component', () => {
const e1 = addEntity(world);
const e2 = addEntity(world);
addComponent(world, e1, Position);
addComponent(world, e2, Position);
const entities = [...query(world, [Position])];
expect(entities).toContain(e1);
expect(entities).toContain(e2);
});
it('queries entities by multiple components', () => {
const moving = addEntity(world);
const stationary = addEntity(world);
addComponent(world, moving, Position);
addComponent(world, moving, Velocity);
addComponent(world, stationary, Position);
const movingEntities = [...query(world, [Position, Velocity])];
expect(movingEntities).toContain(moving);
expect(movingEntities).not.toContain(stationary);
});
});
// ─── Movement System ────────────────────────────────────────────
describe('Movement System', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('updates position by velocity * delta', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 100;
Position.y[eid] = 200;
Velocity.vx[eid] = 60;
Velocity.vy[eid] = -30;
movementSystem(world, 1000); // 1 second
expect(Position.x[eid]).toBeCloseTo(160);
expect(Position.y[eid]).toBeCloseTo(170);
});
it('handles fractional delta (16.67ms ≈ 60fps)', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 0;
Position.y[eid] = 0;
Velocity.vx[eid] = 100;
Velocity.vy[eid] = 100;
movementSystem(world, 16.67);
expect(Position.x[eid]).toBeCloseTo(1.667, 1);
expect(Position.y[eid]).toBeCloseTo(1.667, 1);
});
it('does not move entities without Velocity', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
Position.x[eid] = 100;
Position.y[eid] = 200;
movementSystem(world, 1000);
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
});
it('handles negative velocities', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 500;
Position.y[eid] = 500;
Velocity.vx[eid] = -100;
Velocity.vy[eid] = -200;
movementSystem(world, 1000);
expect(Position.x[eid]).toBeCloseTo(400);
expect(Position.y[eid]).toBeCloseTo(300);
});
it('handles zero delta', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 100;
Position.y[eid] = 200;
Velocity.vx[eid] = 999;
Velocity.vy[eid] = 999;
movementSystem(world, 0);
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
});
it('moves multiple entities independently', () => {
const e1 = addEntity(world);
const e2 = addEntity(world);
addComponent(world, e1, Position);
addComponent(world, e1, Velocity);
addComponent(world, e2, Position);
addComponent(world, e2, Velocity);
Position.x[e1] = 0;
Position.y[e1] = 0;
Velocity.vx[e1] = 100;
Velocity.vy[e1] = 0;
Position.x[e2] = 100;
Position.y[e2] = 100;
Velocity.vx[e2] = 0;
Velocity.vy[e2] = -50;
movementSystem(world, 1000);
expect(Position.x[e1]).toBeCloseTo(100);
expect(Position.y[e1]).toBeCloseTo(0);
expect(Position.x[e2]).toBeCloseTo(100);
expect(Position.y[e2]).toBeCloseTo(50);
});
});
// ─── Bounce System ──────────────────────────────────────────────
describe('Bounce System', () => {
let world: World;
const WIDTH = 1280;
const HEIGHT = 720;
beforeEach(() => {
world = createWorld();
});
it('bounces at left boundary (x < 0)', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = -5;
Position.y[eid] = 100;
Velocity.vx[eid] = -100;
Velocity.vy[eid] = 0;
bounceSystem(world, WIDTH, HEIGHT);
expect(Position.x[eid]).toBe(0);
expect(Velocity.vx[eid]).toBe(100);
});
it('bounces at right boundary (x > width)', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = WIDTH + 5;
Position.y[eid] = 100;
Velocity.vx[eid] = 100;
Velocity.vy[eid] = 0;
bounceSystem(world, WIDTH, HEIGHT);
expect(Position.x[eid]).toBe(WIDTH);
expect(Velocity.vx[eid]).toBe(-100);
});
it('bounces at top boundary (y < 0)', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 100;
Position.y[eid] = -10;
Velocity.vx[eid] = 0;
Velocity.vy[eid] = -50;
bounceSystem(world, WIDTH, HEIGHT);
expect(Position.y[eid]).toBe(0);
expect(Velocity.vy[eid]).toBe(50);
});
it('bounces at bottom boundary (y > height)', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 100;
Position.y[eid] = HEIGHT + 10;
Velocity.vx[eid] = 0;
Velocity.vy[eid] = 200;
bounceSystem(world, WIDTH, HEIGHT);
expect(Position.y[eid]).toBe(HEIGHT);
expect(Velocity.vy[eid]).toBe(-200);
});
it('does not affect entities within bounds', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = 640;
Position.y[eid] = 360;
Velocity.vx[eid] = 100;
Velocity.vy[eid] = -50;
bounceSystem(world, WIDTH, HEIGHT);
expect(Position.x[eid]).toBe(640);
expect(Position.y[eid]).toBe(360);
expect(Velocity.vx[eid]).toBe(100);
expect(Velocity.vy[eid]).toBe(-50);
});
it('handles corner bounce (both axes out of bounds)', () => {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
Position.x[eid] = -5;
Position.y[eid] = -10;
Velocity.vx[eid] = -100;
Velocity.vy[eid] = -200;
bounceSystem(world, WIDTH, HEIGHT);
expect(Position.x[eid]).toBe(0);
expect(Position.y[eid]).toBe(0);
expect(Velocity.vx[eid]).toBe(100);
expect(Velocity.vy[eid]).toBe(200);
});
});
// ─── Health System ──────────────────────────────────────────────
describe('Health System', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('detects entities with health = 0', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 0;
Health.max[eid] = 100;
const dead = healthSystem(world);
expect(dead).toContain(eid);
});
it('detects entities with negative health', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = -50;
Health.max[eid] = 100;
const dead = healthSystem(world);
expect(dead).toContain(eid);
});
it('does not flag healthy entities', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 50;
Health.max[eid] = 100;
const dead = healthSystem(world);
expect(dead).not.toContain(eid);
});
it('returns empty array when all entities are healthy', () => {
const e1 = addEntity(world);
const e2 = addEntity(world);
addComponent(world, e1, Health);
addComponent(world, e2, Health);
Health.current[e1] = 100;
Health.max[e1] = 100;
Health.current[e2] = 1;
Health.max[e2] = 100;
const dead = healthSystem(world);
expect(dead).toHaveLength(0);
});
it('returns multiple dead entities', () => {
const e1 = addEntity(world);
const e2 = addEntity(world);
const e3 = addEntity(world);
addComponent(world, e1, Health);
addComponent(world, e2, Health);
addComponent(world, e3, Health);
Health.current[e1] = 0;
Health.max[e1] = 100;
Health.current[e2] = -10;
Health.max[e2] = 50;
Health.current[e3] = 50;
Health.max[e3] = 100;
const dead = healthSystem(world);
expect(dead).toContain(e1);
expect(dead).toContain(e2);
expect(dead).not.toContain(e3);
expect(dead).toHaveLength(2);
});
});
// ─── Damage & Healing ───────────────────────────────────────────
describe('Damage and Healing', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('applies damage correctly', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 100;
Health.max[eid] = 100;
applyDamage(eid, 30);
expect(Health.current[eid]).toBe(70);
});
it('allows overkill (negative health)', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 10;
Health.max[eid] = 100;
applyDamage(eid, 50);
expect(Health.current[eid]).toBe(-40);
});
it('applies healing correctly', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 50;
Health.max[eid] = 100;
applyHealing(eid, 30);
expect(Health.current[eid]).toBe(80);
});
it('caps healing at max health', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 90;
Health.max[eid] = 100;
applyHealing(eid, 50);
expect(Health.current[eid]).toBe(100);
});
it('full damage → heal → kill lifecycle', () => {
const eid = addEntity(world);
addComponent(world, eid, Health);
Health.current[eid] = 100;
Health.max[eid] = 100;
applyDamage(eid, 60);
expect(Health.current[eid]).toBe(40);
applyHealing(eid, 20);
expect(Health.current[eid]).toBe(60);
applyDamage(eid, 70);
expect(Health.current[eid]).toBe(-10);
const dead = healthSystem(world);
expect(dead).toContain(eid);
});
});
// ─── Entity Factory ─────────────────────────────────────────────
describe('Entity Factory', () => {
let world: World;
beforeEach(() => {
world = createWorld();
});
it('creates entity with position only', () => {
const eid = createGameEntity(world, {
position: { x: 100, y: 200 },
});
expect(typeof eid).toBe('number');
const entities = [...query(world, [Position])];
expect(entities).toContain(eid);
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
});
it('creates entity with all components', () => {
const eid = createGameEntity(world, {
position: { x: 10, y: 20 },
velocity: { vx: 30, vy: 40 },
health: { current: 80, max: 100 },
sprite: { color: 0xff0000, radius: 8 },
chemicalElement: 26, // Fe
});
expect(Position.x[eid]).toBe(10);
expect(Position.y[eid]).toBe(20);
expect(Velocity.vx[eid]).toBe(30);
expect(Velocity.vy[eid]).toBe(40);
expect(Health.current[eid]).toBe(80);
expect(Health.max[eid]).toBe(100);
expect(SpriteRef.color[eid]).toBe(0xff0000);
expect(SpriteRef.radius[eid]).toBe(8);
expect(ChemicalComposition.primaryElement[eid]).toBe(26);
});
it('creates multiple independent entities', () => {
const e1 = createGameEntity(world, {
position: { x: 0, y: 0 },
});
const e2 = createGameEntity(world, {
position: { x: 100, y: 100 },
});
expect(e1).not.toBe(e2);
expect(Position.x[e1]).toBe(0);
expect(Position.x[e2]).toBe(100);
});
it('removes entity from world', () => {
const eid = createGameEntity(world, {
position: { x: 100, y: 200 },
health: { current: 50, max: 100 },
});
expect([...query(world, [Position])]).toContain(eid);
removeGameEntity(world, eid);
expect([...query(world, [Position])]).not.toContain(eid);
});
it('creates entity without any components', () => {
const eid = createGameEntity(world, {});
expect(typeof eid).toBe('number');
});
});
// ─── Integration ────────────────────────────────────────────────
describe('Integration', () => {
it('full lifecycle: create → move → damage → die → remove', () => {
const { world } = createGameWorld();
const eid = createGameEntity(world, {
position: { x: 100, y: 100 },
velocity: { vx: 200, vy: 0 },
health: { current: 50, max: 100 },
});
// Move for 1 second
movementSystem(world, 1000);
expect(Position.x[eid]).toBeCloseTo(300);
// Lethal damage
applyDamage(eid, 60);
expect(Health.current[eid]).toBe(-10);
// Health system detects death
const dead = healthSystem(world);
expect(dead).toContain(eid);
// Remove dead entity
removeGameEntity(world, eid);
expect([...query(world, [Health])]).not.toContain(eid);
});
it('movement + bounce in sequence', () => {
const { world } = createGameWorld();
const eid = createGameEntity(world, {
position: { x: 1270, y: 360 },
velocity: { vx: 100, vy: 0 },
});
// Move past right boundary (1270 + 100*0.5 = 1320 > 1280)
movementSystem(world, 500);
expect(Position.x[eid]).toBeCloseTo(1320);
// Bounce should clamp and reverse
bounceSystem(world, 1280, 720);
expect(Position.x[eid]).toBe(1280);
expect(Velocity.vx[eid]).toBe(-100);
// Next frame: move away from boundary
movementSystem(world, 1000);
expect(Position.x[eid]).toBeCloseTo(1180);
});
});

147
tests/escalation.test.ts Normal file
View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { RunState } from '../src/run/types';
import { RunPhase, ESCALATION_RATE } from '../src/run/types';
import { createRunState, advancePhase, updateEscalation } from '../src/run/state';
import {
getEscalationEffects,
type EscalationEffects,
} from '../src/run/escalation';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
isCrisisResolved,
getCrisisPlayerDamage,
getCrisisTint,
CHEMICAL_PLAGUE,
} from '../src/run/crisis';
// ─── Escalation Effects ──────────────────────────────────────────
describe('Escalation Effects', () => {
it('at 0 escalation, all multipliers are 1.0', () => {
const fx = getEscalationEffects(0);
expect(fx.creatureSpeedMultiplier).toBe(1.0);
expect(fx.creatureAggroRange).toBe(1.0);
expect(fx.creatureAttackMultiplier).toBe(1.0);
expect(fx.reactionInstability).toBe(0);
expect(fx.environmentalDamage).toBe(0);
});
it('at 0.5 escalation, effects are moderate', () => {
const fx = getEscalationEffects(0.5);
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
expect(fx.creatureSpeedMultiplier).toBeLessThan(2.0);
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
expect(fx.creatureAttackMultiplier).toBeGreaterThan(1.0);
expect(fx.reactionInstability).toBeGreaterThan(0);
});
it('at 1.0 escalation, effects are maximal', () => {
const fx = getEscalationEffects(1.0);
expect(fx.creatureSpeedMultiplier).toBe(1.5);
expect(fx.creatureAggroRange).toBe(1.8);
expect(fx.creatureAttackMultiplier).toBe(1.6);
expect(fx.reactionInstability).toBe(0.3);
expect(fx.environmentalDamage).toBeGreaterThan(0);
});
it('escalation clamps values correctly', () => {
const fx = getEscalationEffects(2.0); // over max
expect(fx.creatureSpeedMultiplier).toBe(1.5);
const fxNeg = getEscalationEffects(-1.0); // under min
expect(fxNeg.creatureSpeedMultiplier).toBe(1.0);
});
});
// ─── Crisis Damage ───────────────────────────────────────────────
describe('Crisis Player Damage', () => {
it('no damage below 30% progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.2;
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
});
it('damage increases with progress above 30%', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.5;
const dmg = getCrisisPlayerDamage(crisis, 1000);
expect(dmg).toBeGreaterThan(0);
});
it('damage scales with delta time', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
const dmg1 = getCrisisPlayerDamage(crisis, 1000);
const dmg2 = getCrisisPlayerDamage(crisis, 2000);
expect(dmg2).toBeCloseTo(dmg1 * 2, 1);
});
it('no damage when resolved', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
crisis.resolved = true;
expect(getCrisisPlayerDamage(crisis, 1000)).toBe(0);
});
});
describe('Crisis Visual Tint', () => {
it('returns no tint at 0 progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0;
const tint = getCrisisTint(crisis);
expect(tint.alpha).toBe(0);
});
it('returns tint at high progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
const tint = getCrisisTint(crisis);
expect(tint.alpha).toBeGreaterThan(0);
expect(tint.color).toBeDefined();
});
it('returns no tint when resolved', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.8;
crisis.resolved = true;
const tint = getCrisisTint(crisis);
expect(tint.alpha).toBe(0);
});
});
// ─── Escalation-Phase Integration ────────────────────────────────
describe('Escalation Integration', () => {
let state: RunState;
beforeEach(() => {
state = createRunState(1, 'alchemist');
state.phase = RunPhase.Escalation;
});
it('escalation grows steadily over time', () => {
// Simulate 30 seconds
for (let i = 0; i < 30; i++) {
updateEscalation(state, 1000);
}
expect(state.escalation).toBeCloseTo(ESCALATION_RATE * 30, 2);
});
it('effects scale linearly with escalation', () => {
updateEscalation(state, 100_000); // ~100 seconds
const fx = getEscalationEffects(state.escalation);
expect(fx.creatureSpeedMultiplier).toBeGreaterThan(1.0);
expect(fx.creatureAggroRange).toBeGreaterThan(1.0);
});
it('full escalation cycle reaches crisis threshold', () => {
// Simulate until threshold
for (let i = 0; i < 200; i++) {
updateEscalation(state, 1000);
}
expect(state.escalation).toBeGreaterThanOrEqual(CHEMICAL_PLAGUE.triggerThreshold);
});
});

469
tests/great-cycle.test.ts Normal file
View File

@@ -0,0 +1,469 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { MetaState, RunState, GreatCycleState, RunTrace } from '../src/run/types';
import {
CYCLE_THEMES,
CYCLE_THEME_NAMES,
CYCLE_THEME_NAMES_RU,
RUNS_PER_CYCLE,
RunPhase,
} from '../src/run/types';
import {
createGreatCycleState,
getCycleTheme,
createRunTrace,
recordRunAndAdvanceCycle,
performGreatRenewal,
getCycleWorldModifiers,
getTracesForBiome,
getDeathTraces,
isLastRunInCycle,
getCycleSummary,
} from '../src/run/cycle';
import { createMetaState, applyRunResults } from '../src/run/meta';
import { createRunState, recordDiscovery } from '../src/run/state';
import narrativeData from '../src/data/cycle-narrative.json';
// ─── Great Cycle Initialization ──────────────────────────────────
describe('Great Cycle Initialization', () => {
it('should create initial cycle state at cycle 1, run 1', () => {
const state = createGreatCycleState();
expect(state.cycleNumber).toBe(1);
expect(state.runInCycle).toBe(1);
expect(state.theme).toBe('awakening');
expect(state.currentCycleTraces).toEqual([]);
expect(state.previousCycleTraces).toEqual([]);
expect(state.renewalsCompleted).toBe(0);
expect(state.myceliumMaturation).toBe(0);
});
it('MetaState should include greatCycle on creation', () => {
const meta = createMetaState();
expect(meta.greatCycle).toBeDefined();
expect(meta.greatCycle.cycleNumber).toBe(1);
expect(meta.greatCycle.runInCycle).toBe(1);
expect(meta.greatCycle.theme).toBe('awakening');
});
});
// ─── Cycle Theme Resolution ─────────────────────────────────────
describe('Cycle Theme Resolution', () => {
it('should have 6 predefined themes', () => {
expect(CYCLE_THEMES.length).toBe(6);
});
it('should return correct theme for each cycle number', () => {
expect(getCycleTheme(1)).toBe('awakening');
expect(getCycleTheme(2)).toBe('doubt');
expect(getCycleTheme(3)).toBe('realization');
expect(getCycleTheme(4)).toBe('attempt');
expect(getCycleTheme(5)).toBe('acceptance');
expect(getCycleTheme(6)).toBe('synthesis');
});
it('cycles beyond 6 should use synthesis theme', () => {
expect(getCycleTheme(7)).toBe('synthesis');
expect(getCycleTheme(10)).toBe('synthesis');
expect(getCycleTheme(100)).toBe('synthesis');
});
it('invalid cycle numbers default to awakening', () => {
expect(getCycleTheme(0)).toBe('awakening');
expect(getCycleTheme(-1)).toBe('awakening');
});
it('all themes have Russian and English names', () => {
for (const theme of CYCLE_THEMES) {
expect(CYCLE_THEME_NAMES[theme]).toBeDefined();
expect(CYCLE_THEME_NAMES[theme].length).toBeGreaterThan(0);
expect(CYCLE_THEME_NAMES_RU[theme]).toBeDefined();
expect(CYCLE_THEME_NAMES_RU[theme].length).toBeGreaterThan(0);
}
});
});
// ─── Run Trace Recording ─────────────────────────────────────────
describe('Run Trace Recording', () => {
let meta: MetaState;
beforeEach(() => {
meta = createMetaState();
});
function makeCompletedRun(overrides?: Partial<RunState>): RunState {
const run = createRunState(1, 'alchemist', 'catalytic-wastes', 42);
run.phase = RunPhase.Resolution;
run.elapsed = 120000;
run.crisisResolved = true;
run.deathPosition = { tileX: 30, tileY: 40 };
recordDiscovery(run, 'element', 'Na');
recordDiscovery(run, 'element', 'O');
recordDiscovery(run, 'reaction', 'Na+Cl');
if (overrides) Object.assign(run, overrides);
return run;
}
it('should create a trace from completed run', () => {
const run = makeCompletedRun();
const trace = createRunTrace(run, meta.greatCycle);
expect(trace.runId).toBe(1);
expect(trace.runInCycle).toBe(1);
expect(trace.schoolId).toBe('alchemist');
expect(trace.biomeId).toBe('catalytic-wastes');
expect(trace.deathPosition).toEqual({ tileX: 30, tileY: 40 });
expect(trace.phaseReached).toBe(RunPhase.Resolution);
expect(trace.crisisResolved).toBe(true);
expect(trace.discoveryCount).toBe(3);
expect(trace.keyElements).toEqual(['Na', 'O']);
expect(trace.duration).toBe(120000);
expect(trace.worldSeed).toBe(42);
});
it('should limit key elements to 5', () => {
const run = makeCompletedRun();
for (const sym of ['H', 'C', 'Fe', 'Cu', 'Zn', 'Au', 'Hg']) {
recordDiscovery(run, 'element', sym);
}
const trace = createRunTrace(run, meta.greatCycle);
expect(trace.keyElements.length).toBeLessThanOrEqual(5);
});
it('should handle null death position (boss victory)', () => {
const run = makeCompletedRun({ deathPosition: null });
const trace = createRunTrace(run, meta.greatCycle);
expect(trace.deathPosition).toBeNull();
});
});
// ─── Cycle Advancement ───────────────────────────────────────────
describe('Cycle Advancement', () => {
let meta: MetaState;
beforeEach(() => {
meta = createMetaState();
});
function runAndRecord(runId: number, biome: string = 'catalytic-wastes'): boolean {
const run = createRunState(runId, 'alchemist', biome, runId * 100);
run.phase = RunPhase.Resolution;
run.elapsed = 60000;
run.deathPosition = { tileX: 10, tileY: 20 };
recordDiscovery(run, 'element', 'Na');
return applyRunResults(meta, run);
}
it('should advance runInCycle after each run', () => {
expect(meta.greatCycle.runInCycle).toBe(1);
runAndRecord(1);
expect(meta.greatCycle.runInCycle).toBe(2);
runAndRecord(2);
expect(meta.greatCycle.runInCycle).toBe(3);
});
it('should stay in cycle 1 for first 6 runs', () => {
for (let i = 1; i <= 6; i++) {
runAndRecord(i);
expect(meta.greatCycle.cycleNumber).toBe(1);
expect(meta.greatCycle.theme).toBe('awakening');
}
});
it('should trigger Great Renewal on 7th run', () => {
for (let i = 1; i <= 6; i++) {
const isRenewal = runAndRecord(i);
expect(isRenewal).toBe(false);
}
const isRenewal = runAndRecord(7);
expect(isRenewal).toBe(true);
});
it('after renewal should advance to cycle 2', () => {
for (let i = 1; i <= 7; i++) runAndRecord(i);
expect(meta.greatCycle.cycleNumber).toBe(2);
expect(meta.greatCycle.runInCycle).toBe(1);
expect(meta.greatCycle.theme).toBe('doubt');
});
it('should accumulate traces within a cycle', () => {
runAndRecord(1);
runAndRecord(2);
runAndRecord(3);
expect(meta.greatCycle.currentCycleTraces.length).toBe(3);
});
it('should move current traces to previous on renewal', () => {
for (let i = 1; i <= 7; i++) runAndRecord(i);
// After renewal: previous has 7 traces, current is empty
expect(meta.greatCycle.previousCycleTraces.length).toBe(7);
expect(meta.greatCycle.currentCycleTraces.length).toBe(0);
});
it('should track renewals completed', () => {
expect(meta.greatCycle.renewalsCompleted).toBe(0);
for (let i = 1; i <= 7; i++) runAndRecord(i);
expect(meta.greatCycle.renewalsCompleted).toBe(1);
for (let i = 8; i <= 14; i++) runAndRecord(i);
expect(meta.greatCycle.renewalsCompleted).toBe(2);
});
it('should increment mycelium maturation on renewal', () => {
expect(meta.greatCycle.myceliumMaturation).toBe(0);
for (let i = 1; i <= 7; i++) runAndRecord(i);
expect(meta.greatCycle.myceliumMaturation).toBeGreaterThan(0);
expect(meta.greatCycle.myceliumMaturation).toBeLessThanOrEqual(1.0);
});
it('mycelium maturation caps at 1.0', () => {
// Simulate many cycles
for (let cycle = 0; cycle < 20; cycle++) {
for (let run = 1; run <= 7; run++) {
runAndRecord(cycle * 7 + run);
}
}
expect(meta.greatCycle.myceliumMaturation).toBeLessThanOrEqual(1.0);
});
});
// ─── Mycelium Strengthening on Renewal ───────────────────────────
describe('Mycelium Strengthening on Renewal', () => {
it('should strengthen all Mycelium nodes during renewal', () => {
const meta = createMetaState();
// Seed some mycelium nodes
meta.mycelium.nodes = [
{ id: 'element:Na', type: 'element', knowledgeId: 'Na', depositedOnRun: 1, strength: 0.3 },
{ id: 'element:O', type: 'element', knowledgeId: 'O', depositedOnRun: 1, strength: 0.5 },
];
const oldStrengths = meta.mycelium.nodes.map(n => n.strength);
performGreatRenewal(meta);
for (let i = 0; i < meta.mycelium.nodes.length; i++) {
expect(meta.mycelium.nodes[i].strength).toBeGreaterThan(oldStrengths[i]);
}
});
it('strength should not exceed 1.0', () => {
const meta = createMetaState();
meta.mycelium.nodes = [
{ id: 'element:Na', type: 'element', knowledgeId: 'Na', depositedOnRun: 1, strength: 0.95 },
];
performGreatRenewal(meta);
expect(meta.mycelium.nodes[0].strength).toBeLessThanOrEqual(1.0);
});
});
// ─── World Modifiers ─────────────────────────────────────────────
describe('Cycle World Modifiers', () => {
it('cycle 1 should have baseline modifiers (~1.0)', () => {
const mods = getCycleWorldModifiers(1);
expect(mods.elevationScaleMultiplier).toBeCloseTo(1.0, 1);
expect(mods.detailScaleMultiplier).toBeCloseTo(1.0, 1);
expect(mods.resourceDensityMultiplier).toBeCloseTo(1.0, 1);
expect(mods.creatureSpawnMultiplier).toBeCloseTo(1.0, 1);
expect(mods.escalationRateMultiplier).toBeCloseTo(1.0, 1);
});
it('higher cycles should have higher modifiers', () => {
const mod1 = getCycleWorldModifiers(1);
const mod3 = getCycleWorldModifiers(3);
const mod6 = getCycleWorldModifiers(6);
expect(mod3.elevationScaleMultiplier).toBeGreaterThan(mod1.elevationScaleMultiplier);
expect(mod6.escalationRateMultiplier).toBeGreaterThan(mod3.escalationRateMultiplier);
expect(mod6.creatureSpawnMultiplier).toBeGreaterThan(mod1.creatureSpawnMultiplier);
});
it('modifiers should cap at cycle 6', () => {
const mod6 = getCycleWorldModifiers(6);
const mod10 = getCycleWorldModifiers(10);
expect(mod10.elevationScaleMultiplier).toBeCloseTo(mod6.elevationScaleMultiplier, 2);
});
});
// ─── Trace Queries ───────────────────────────────────────────────
describe('Trace Queries', () => {
let cycle: GreatCycleState;
beforeEach(() => {
cycle = createGreatCycleState();
cycle.currentCycleTraces = [
makeTrace(1, 'catalytic-wastes', { tileX: 10, tileY: 20 }),
makeTrace(2, 'kinetic-mountains', { tileX: 30, tileY: 40 }),
makeTrace(3, 'catalytic-wastes', null),
];
cycle.previousCycleTraces = [
makeTrace(4, 'catalytic-wastes', { tileX: 50, tileY: 60 }),
];
});
function makeTrace(
runId: number,
biomeId: string,
deathPos: { tileX: number; tileY: number } | null,
): RunTrace {
return {
runId,
runInCycle: runId,
schoolId: 'alchemist',
biomeId,
deathPosition: deathPos,
phaseReached: RunPhase.Resolution,
crisisResolved: false,
discoveryCount: 5,
keyElements: ['Na'],
duration: 60000,
worldSeed: runId * 100,
};
}
it('should filter traces by biome (current + previous)', () => {
const wastesTraces = getTracesForBiome(cycle, 'catalytic-wastes');
expect(wastesTraces.length).toBe(3); // 2 current + 1 previous
});
it('should return empty for biome with no traces', () => {
const forestTraces = getTracesForBiome(cycle, 'verdant-forests');
expect(forestTraces.length).toBe(0);
});
it('should filter death traces (non-null death position)', () => {
const allTraces = getTracesForBiome(cycle, 'catalytic-wastes');
const deathTraces = getDeathTraces(allTraces);
expect(deathTraces.length).toBe(2); // run 1 and run 4 have death positions
});
it('isLastRunInCycle should detect 7th run', () => {
cycle.runInCycle = 6;
expect(isLastRunInCycle(cycle)).toBe(false);
cycle.runInCycle = 7;
expect(isLastRunInCycle(cycle)).toBe(true);
});
});
// ─── Cycle Summary ───────────────────────────────────────────────
describe('Cycle Summary', () => {
it('should return formatted summary', () => {
const cycle = createGreatCycleState();
cycle.runInCycle = 3;
const summary = getCycleSummary(cycle);
expect(summary.cycleNumber).toBe(1);
expect(summary.runInCycle).toBe(3);
expect(summary.totalRuns).toBe(7);
expect(summary.theme).toBe('awakening');
expect(summary.isLastRun).toBe(false);
});
it('should indicate last run', () => {
const cycle = createGreatCycleState();
cycle.runInCycle = 7;
const summary = getCycleSummary(cycle);
expect(summary.isLastRun).toBe(true);
});
});
// ─── Cycle Narrative Data ────────────────────────────────────────
describe('Cycle Narrative Data', () => {
it('should have narrative for all 6 themes', () => {
const themes = narrativeData.themes;
expect(Object.keys(themes).length).toBe(6);
expect(themes.awakening).toBeDefined();
expect(themes.doubt).toBeDefined();
expect(themes.realization).toBeDefined();
expect(themes.attempt).toBeDefined();
expect(themes.acceptance).toBeDefined();
expect(themes.synthesis).toBeDefined();
});
it('each theme should have Russian text', () => {
for (const [, theme] of Object.entries(narrativeData.themes)) {
expect(theme.nameRu).toBeDefined();
expect(theme.nameRu.length).toBeGreaterThan(0);
expect(theme.descriptionRu).toBeDefined();
expect(theme.cradleQuoteRu).toBeDefined();
expect(theme.loreFrag.length).toBeGreaterThan(0);
for (const frag of theme.loreFrag) {
expect(frag.textRu).toBeDefined();
expect(frag.textRu.length).toBeGreaterThan(0);
}
}
});
it('should have renewal messages', () => {
expect(narrativeData.renewalMessages.length).toBeGreaterThan(0);
for (const msg of narrativeData.renewalMessages) {
expect(msg.textRu).toBeDefined();
}
});
it('should have trace messages for death and discovery sites', () => {
expect(narrativeData.traceMessages.death_site.length).toBeGreaterThan(0);
expect(narrativeData.traceMessages.discovery_site.length).toBeGreaterThan(0);
});
});
// ─── Full Integration: 2 Complete Cycles ─────────────────────────
describe('Full Integration: 2 Complete Cycles', () => {
it('should progress through 2 cycles correctly', () => {
const meta = createMetaState();
let renewalCount = 0;
for (let i = 1; i <= 14; i++) {
const run = createRunState(i, 'alchemist', 'catalytic-wastes', i * 100);
run.phase = RunPhase.Crisis;
run.elapsed = 90000;
run.deathPosition = { tileX: i * 5, tileY: i * 3 };
recordDiscovery(run, 'element', `E${i}`);
const isRenewal = applyRunResults(meta, run);
if (isRenewal) renewalCount++;
}
expect(renewalCount).toBe(2);
expect(meta.greatCycle.cycleNumber).toBe(3);
expect(meta.greatCycle.theme).toBe('realization');
expect(meta.greatCycle.runInCycle).toBe(1);
expect(meta.greatCycle.renewalsCompleted).toBe(2);
expect(meta.greatCycle.previousCycleTraces.length).toBe(7);
expect(meta.greatCycle.currentCycleTraces.length).toBe(0);
expect(meta.totalRuns).toBe(14);
});
});
// ─── RunState Extended Fields ────────────────────────────────────
describe('RunState Extended Fields', () => {
it('should include biomeId and worldSeed', () => {
const run = createRunState(1, 'alchemist', 'kinetic-mountains', 12345);
expect(run.biomeId).toBe('kinetic-mountains');
expect(run.worldSeed).toBe(12345);
expect(run.deathPosition).toBeNull();
});
it('should default biomeId to catalytic-wastes', () => {
const run = createRunState(1, 'alchemist');
expect(run.biomeId).toBe('catalytic-wastes');
});
it('RunSummary should include biomeId and cycleNumber', () => {
const meta = createMetaState();
const run = createRunState(1, 'alchemist', 'verdant-forests', 999);
run.phase = RunPhase.Exploration;
run.elapsed = 30000;
recordDiscovery(run, 'element', 'H');
applyRunResults(meta, run);
const lastSummary = meta.runHistory[meta.runHistory.length - 1];
expect(lastSummary.biomeId).toBe('verdant-forests');
expect(lastSummary.cycleNumber).toBe(1);
});
});

191
tests/interaction.test.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Resource Interaction Tests — Phase 4.3
*
* Tests: resource element picking, proximity detection,
* collection into inventory, resource depletion.
*/
import { describe, it, expect } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import { Position, Resource, PlayerTag } from '../src/ecs/components';
import { Inventory } from '../src/player/inventory';
import {
pickResourceElement,
interactionSystem,
type ResourceInfo,
type InteractionResult,
MINERAL_ELEMENTS,
GEYSER_ELEMENTS,
} from '../src/player/interaction';
// === Helpers ===
function createTestWorld() {
const world = createWorld();
const resourceData = new Map<number, ResourceInfo>();
const inventory = new Inventory();
return { world, resourceData, inventory };
}
function addPlayer(world: ReturnType<typeof createWorld>, x: number, y: number): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, PlayerTag);
Position.x[eid] = x;
Position.y[eid] = y;
return eid;
}
function addResource(
world: ReturnType<typeof createWorld>,
resourceData: Map<number, ResourceInfo>,
x: number,
y: number,
itemId: string,
quantity: number,
range = 40,
): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Resource);
Position.x[eid] = x;
Position.y[eid] = y;
Resource.quantity[eid] = quantity;
Resource.interactRange[eid] = range;
resourceData.set(eid, { itemId, tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) });
return eid;
}
// === pickResourceElement ===
describe('pickResourceElement', () => {
it('always returns element from provided list', () => {
for (let x = 0; x < 20; x++) {
for (let y = 0; y < 20; y++) {
const el = pickResourceElement(x, y, 12345, MINERAL_ELEMENTS);
expect(MINERAL_ELEMENTS).toContain(el);
}
}
});
it('is deterministic (same inputs → same output)', () => {
const a = pickResourceElement(5, 10, 42, MINERAL_ELEMENTS);
const b = pickResourceElement(5, 10, 42, MINERAL_ELEMENTS);
expect(a).toBe(b);
});
it('varies with different positions', () => {
const results = new Set<string>();
for (let i = 0; i < 100; i++) {
results.add(pickResourceElement(i, i * 7, 42, MINERAL_ELEMENTS));
}
// Should use more than one element (statistical certainty)
expect(results.size).toBeGreaterThan(1);
});
it('works with geyser elements', () => {
const el = pickResourceElement(3, 7, 42, GEYSER_ELEMENTS);
expect(GEYSER_ELEMENTS).toContain(el);
});
});
// === Interaction System ===
describe('interactionSystem — collection', () => {
it('collects element when in range and pressing E', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 5);
const result = interactionSystem(world, true, inventory, resourceData);
expect(result).not.toBeNull();
expect(result!.type).toBe('collected');
expect(result!.itemId).toBe('Fe');
expect(inventory.getCount('Fe')).toBe(1);
});
it('does nothing when E is not pressed', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 5);
const result = interactionSystem(world, false, inventory, resourceData);
expect(result).toBeNull();
expect(inventory.getCount('Fe')).toBe(0);
});
it('does nothing when no resources in range', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 500, 500, 'Fe', 5); // far away
const result = interactionSystem(world, true, inventory, resourceData);
expect(result).not.toBeNull();
expect(result!.type).toBe('nothing_nearby');
});
it('picks closest resource when multiple in range', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 130, 100, 'Cu', 3); // 30px away
addResource(world, resourceData, 115, 100, 'Fe', 5); // 15px away (closer)
const result = interactionSystem(world, true, inventory, resourceData);
expect(result!.itemId).toBe('Fe'); // picked closer one
});
it('decrements resource quantity on collection', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
const resEid = addResource(world, resourceData, 120, 100, 'Fe', 3);
interactionSystem(world, true, inventory, resourceData);
expect(Resource.quantity[resEid]).toBe(2);
});
});
describe('interactionSystem — depletion', () => {
it('depletes resource when quantity reaches 0', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 1);
const result = interactionSystem(world, true, inventory, resourceData);
expect(result!.type).toBe('depleted');
expect(result!.itemId).toBe('Fe');
expect(resourceData.size).toBe(0);
});
it('removes entity from world on depletion', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 1);
interactionSystem(world, true, inventory, resourceData);
const remaining = query(world, [Resource]);
expect(remaining.length).toBe(0);
});
});
describe('interactionSystem — inventory full', () => {
it('reports inventory_full when cannot add', () => {
const { world, resourceData } = createTestWorld();
const inventory = new Inventory(1, 1); // very small
inventory.addItem('H', 1); // fills it up
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 5);
const result = interactionSystem(world, true, inventory, resourceData);
expect(result!.type).toBe('inventory_full');
expect(Resource.quantity[query(world, [Resource])[0]]).toBe(5); // not consumed
});
});

244
tests/inventory.test.ts Normal file
View File

@@ -0,0 +1,244 @@
/**
* Inventory System Tests — Phase 4.2
*
* Weight-based inventory with element stacking,
* mass limits, and slot limits.
*/
import { describe, it, expect } from 'vitest';
import { Inventory, getItemMass } from '../src/player/inventory';
// === Item Mass Lookup ===
describe('getItemMass', () => {
it('returns atomic mass for elements', () => {
expect(getItemMass('H')).toBeCloseTo(1.008);
expect(getItemMass('Na')).toBeCloseTo(22.99);
expect(getItemMass('Fe')).toBeCloseTo(55.845);
expect(getItemMass('O')).toBeCloseTo(15.999);
});
it('returns molecular mass for compounds', () => {
expect(getItemMass('NaCl')).toBeCloseTo(58.44);
expect(getItemMass('H2O')).toBeCloseTo(18.015);
expect(getItemMass('CO2')).toBeCloseTo(44.01);
});
it('returns 0 for unknown items', () => {
expect(getItemMass('UNKNOWN')).toBe(0);
expect(getItemMass('')).toBe(0);
});
});
// === Inventory Core ===
describe('Inventory — creation', () => {
it('starts empty', () => {
const inv = new Inventory();
expect(inv.isEmpty()).toBe(true);
expect(inv.getItems()).toEqual([]);
expect(inv.getTotalWeight()).toBe(0);
expect(inv.slotCount).toBe(0);
});
it('respects custom limits', () => {
const inv = new Inventory(100, 5);
expect(inv.maxWeight).toBe(100);
expect(inv.maxSlots).toBe(5);
});
});
describe('Inventory — adding items', () => {
it('adds elements', () => {
const inv = new Inventory();
const added = inv.addItem('H', 3);
expect(added).toBe(3);
expect(inv.getCount('H')).toBe(3);
expect(inv.isEmpty()).toBe(false);
expect(inv.slotCount).toBe(1);
});
it('adds compounds', () => {
const inv = new Inventory();
const added = inv.addItem('NaCl', 1);
expect(added).toBe(1);
expect(inv.getCount('NaCl')).toBe(1);
});
it('stacks same elements', () => {
const inv = new Inventory();
inv.addItem('Na', 2);
inv.addItem('Na', 3);
expect(inv.getCount('Na')).toBe(5);
expect(inv.slotCount).toBe(1);
});
it('stacking does not consume new slot', () => {
const inv = new Inventory(10000, 3);
inv.addItem('H', 1);
inv.addItem('O', 1);
inv.addItem('Na', 1);
const added = inv.addItem('H', 5);
expect(added).toBe(5);
expect(inv.getCount('H')).toBe(6);
expect(inv.slotCount).toBe(3);
});
it('returns 0 for unknown items', () => {
const inv = new Inventory();
expect(inv.addItem('UNKNOWN', 1)).toBe(0);
expect(inv.isEmpty()).toBe(true);
});
it('returns 0 for zero/negative count', () => {
const inv = new Inventory();
expect(inv.addItem('H', 0)).toBe(0);
expect(inv.addItem('H', -1)).toBe(0);
});
});
describe('Inventory — removing items', () => {
it('removes items', () => {
const inv = new Inventory();
inv.addItem('H', 5);
const removed = inv.removeItem('H', 3);
expect(removed).toBe(3);
expect(inv.getCount('H')).toBe(2);
});
it('cleans up slot when count reaches zero', () => {
const inv = new Inventory();
inv.addItem('H', 3);
inv.removeItem('H', 3);
expect(inv.getCount('H')).toBe(0);
expect(inv.slotCount).toBe(0);
expect(inv.isEmpty()).toBe(true);
});
it('removes only what is available', () => {
const inv = new Inventory();
inv.addItem('H', 2);
const removed = inv.removeItem('H', 5);
expect(removed).toBe(2);
expect(inv.getCount('H')).toBe(0);
});
it('returns 0 for non-existent item', () => {
const inv = new Inventory();
expect(inv.removeItem('H', 1)).toBe(0);
});
it('returns 0 for zero/negative count', () => {
const inv = new Inventory();
inv.addItem('H', 5);
expect(inv.removeItem('H', 0)).toBe(0);
expect(inv.removeItem('H', -1)).toBe(0);
});
});
describe('Inventory — weight system', () => {
it('calculates total weight from atomic masses', () => {
const inv = new Inventory();
inv.addItem('Na', 2); // 22.99 * 2 = 45.98
expect(inv.getTotalWeight()).toBeCloseTo(22.99 * 2);
});
it('calculates mixed element + compound weight', () => {
const inv = new Inventory();
inv.addItem('Na', 1); // 22.99
inv.addItem('NaCl', 1); // 58.44
expect(inv.getTotalWeight()).toBeCloseTo(22.99 + 58.44);
});
it('limits additions by weight', () => {
const inv = new Inventory(100); // 100 AMU limit
// Na mass = 22.99 → max 4 fit (4 * 22.99 = 91.96)
const added = inv.addItem('Na', 10);
expect(added).toBe(4);
expect(inv.getCount('Na')).toBe(4);
expect(inv.getTotalWeight()).toBeCloseTo(22.99 * 4);
});
it('weight frees up after removal', () => {
const inv = new Inventory(100);
inv.addItem('Na', 4); // 91.96
inv.removeItem('Na', 2); // now 45.98 used, ~54 free
const added = inv.addItem('Na', 3); // 2 more fit (45.98 + 2*22.99 = 91.96)
expect(added).toBe(2);
expect(inv.getCount('Na')).toBe(4);
});
it('getItemWeight returns stack weight', () => {
const inv = new Inventory();
inv.addItem('Na', 3);
expect(inv.getItemWeight('Na')).toBeCloseTo(22.99 * 3);
});
it('getItemWeight returns 0 for absent item', () => {
const inv = new Inventory();
expect(inv.getItemWeight('H')).toBe(0);
});
});
describe('Inventory — slot limits', () => {
it('respects max slots', () => {
const inv = new Inventory(10000, 3);
inv.addItem('H', 1);
inv.addItem('O', 1);
inv.addItem('Na', 1);
const added = inv.addItem('Fe', 1); // 4th slot
expect(added).toBe(0);
expect(inv.slotCount).toBe(3);
expect(inv.hasItem('Fe')).toBe(false);
});
it('frees slot after full removal', () => {
const inv = new Inventory(10000, 2);
inv.addItem('H', 1);
inv.addItem('O', 1);
expect(inv.addItem('Na', 1)).toBe(0); // full
inv.removeItem('O', 1); // free a slot
expect(inv.addItem('Na', 1)).toBe(1); // now fits
expect(inv.slotCount).toBe(2);
});
});
describe('Inventory — queries', () => {
it('hasItem checks against count', () => {
const inv = new Inventory();
inv.addItem('H', 3);
expect(inv.hasItem('H', 1)).toBe(true);
expect(inv.hasItem('H', 3)).toBe(true);
expect(inv.hasItem('H', 4)).toBe(false);
expect(inv.hasItem('O')).toBe(false);
});
it('getItems returns all items', () => {
const inv = new Inventory();
inv.addItem('H', 3);
inv.addItem('Na', 2);
const items = inv.getItems();
expect(items).toHaveLength(2);
expect(items).toContainEqual({ id: 'H', count: 3 });
expect(items).toContainEqual({ id: 'Na', count: 2 });
});
it('getCount returns 0 for absent item', () => {
const inv = new Inventory();
expect(inv.getCount('H')).toBe(0);
});
});
describe('Inventory — clear', () => {
it('removes everything', () => {
const inv = new Inventory();
inv.addItem('H', 3);
inv.addItem('Na', 2);
inv.addItem('NaCl', 1);
inv.clear();
expect(inv.isEmpty()).toBe(true);
expect(inv.getTotalWeight()).toBe(0);
expect(inv.slotCount).toBe(0);
expect(inv.getItems()).toEqual([]);
});
});

537
tests/mycelium.test.ts Normal file
View File

@@ -0,0 +1,537 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { MyceliumGraphData, MetaState, RunState } from '../src/run/types';
import { RunPhase } from '../src/run/types';
import { createMetaState } from '../src/run/meta';
import {
createMyceliumGraph,
depositKnowledge,
getNode,
getEdge,
getNodesByType,
getConnectedNodes,
getGraphStats,
} from '../src/mycelium/graph';
import {
extractMemoryFlashes,
generateFlashText,
} from '../src/mycelium/knowledge';
import {
createMycosisState,
updateMycosis,
getMycosisVisuals,
} from '../src/mycelium/mycosis';
import {
getAvailableBonuses,
purchaseBonus,
canAffordBonus,
} from '../src/mycelium/shop';
import {
spawnFungalNodes,
} from '../src/mycelium/nodes';
import type { MycosisState, MemoryFlash, FungalNodeInfo } from '../src/mycelium/types';
import { MYCOSIS_CONFIG } from '../src/mycelium/types';
// ─── Test helpers ────────────────────────────────────────────────
function createTestRunState(runId: number = 1): RunState {
return {
runId,
schoolId: 'alchemist',
phase: RunPhase.Exploration,
phaseTimer: 0,
elapsed: 60000,
escalation: 0,
crisisActive: false,
crisisResolved: false,
discoveries: {
elements: new Set(['Na', 'Cl', 'H', 'O']),
reactions: new Set(['Na+Cl']),
compounds: new Set(['NaCl']),
creatures: new Set(['crystallid']),
},
alive: true,
};
}
function createTestMeta(): MetaState {
const meta = createMetaState();
meta.totalRuns = 2;
meta.spores = 100;
meta.mycelium = createMyceliumGraph();
return meta;
}
// ─── Mycelium Graph Tests ────────────────────────────────────────
describe('Mycelium Graph', () => {
let graph: MyceliumGraphData;
beforeEach(() => {
graph = createMyceliumGraph();
});
it('should create an empty graph', () => {
expect(graph.nodes).toHaveLength(0);
expect(graph.edges).toHaveLength(0);
expect(graph.totalDeposits).toBe(0);
expect(graph.totalExtractions).toBe(0);
});
it('should deposit element knowledge from a run', () => {
const run = createTestRunState(1);
const result = depositKnowledge(graph, run);
expect(result.newNodes).toBeGreaterThan(0);
// Should have nodes for elements + reactions + compounds + creatures
expect(graph.nodes.length).toBeGreaterThanOrEqual(4);
expect(graph.totalDeposits).toBe(1);
});
it('should create nodes for all discovery types', () => {
const run = createTestRunState(1);
depositKnowledge(graph, run);
// 4 elements (Na, Cl, H, O) + 1 reaction + 1 compound + 1 creature = 7
expect(graph.nodes).toHaveLength(7);
const elementNodes = getNodesByType(graph, 'element');
expect(elementNodes).toHaveLength(4);
const reactionNodes = getNodesByType(graph, 'reaction');
expect(reactionNodes).toHaveLength(1);
const compoundNodes = getNodesByType(graph, 'compound');
expect(compoundNodes).toHaveLength(1);
const creatureNodes = getNodesByType(graph, 'creature');
expect(creatureNodes).toHaveLength(1);
});
it('should create edges between related discoveries', () => {
const run = createTestRunState(1);
depositKnowledge(graph, run);
// Elements that form a reaction should be connected to the reaction
// Na + Cl → NaCl: edges Na→reaction, Cl→reaction, reaction→compound
expect(graph.edges.length).toBeGreaterThan(0);
});
it('should strengthen existing nodes on re-deposit', () => {
const run1 = createTestRunState(1);
depositKnowledge(graph, run1);
const naNode = getNode(graph, 'element:Na');
expect(naNode).toBeDefined();
const initialStrength = naNode!.strength;
// Second deposit with same discoveries should strengthen
const run2 = createTestRunState(2);
run2.discoveries.elements = new Set(['Na']); // Only Na
run2.discoveries.reactions = new Set();
run2.discoveries.compounds = new Set();
run2.discoveries.creatures = new Set();
const result = depositKnowledge(graph, run2);
expect(result.strengthened).toBe(1);
expect(result.newNodes).toBe(0);
const naNodeAfter = getNode(graph, 'element:Na');
expect(naNodeAfter!.strength).toBeGreaterThan(initialStrength);
});
it('should cap node strength at 1.0', () => {
const run = createTestRunState(1);
run.discoveries.elements = new Set(['Na']);
run.discoveries.reactions = new Set();
run.discoveries.compounds = new Set();
run.discoveries.creatures = new Set();
// Deposit many times
for (let i = 0; i < 20; i++) {
depositKnowledge(graph, run);
}
const naNode = getNode(graph, 'element:Na');
expect(naNode!.strength).toBeLessThanOrEqual(1.0);
});
it('should look up nodes by ID', () => {
const run = createTestRunState(1);
depositKnowledge(graph, run);
const node = getNode(graph, 'element:Na');
expect(node).toBeDefined();
expect(node!.type).toBe('element');
expect(node!.knowledgeId).toBe('Na');
const missing = getNode(graph, 'element:Au');
expect(missing).toBeUndefined();
});
it('should look up edges between nodes', () => {
const run = createTestRunState(1);
depositKnowledge(graph, run);
// There should be edges from Na and Cl to the Na+Cl reaction
const naEdge = getEdge(graph, 'element:Na', 'reaction:Na+Cl');
// Edge may exist in either direction — check both
const naEdgeReverse = getEdge(graph, 'reaction:Na+Cl', 'element:Na');
expect(naEdge ?? naEdgeReverse).toBeDefined();
});
it('should return connected nodes', () => {
const run = createTestRunState(1);
depositKnowledge(graph, run);
// Na should be connected to the Na+Cl reaction
const connected = getConnectedNodes(graph, 'element:Na');
expect(connected.length).toBeGreaterThan(0);
});
it('should report graph statistics', () => {
const run = createTestRunState(1);
depositKnowledge(graph, run);
const stats = getGraphStats(graph);
expect(stats.nodeCount).toBe(7);
expect(stats.edgeCount).toBeGreaterThan(0);
expect(stats.totalDeposits).toBe(1);
expect(stats.averageStrength).toBeGreaterThan(0);
expect(stats.averageStrength).toBeLessThanOrEqual(1);
});
});
// ─── Knowledge System Tests ──────────────────────────────────────
describe('Knowledge System', () => {
let graph: MyceliumGraphData;
beforeEach(() => {
graph = createMyceliumGraph();
const run = createTestRunState(1);
depositKnowledge(graph, run);
});
it('should extract memory flashes from populated graph', () => {
const flashes = extractMemoryFlashes(graph, 3);
expect(flashes.length).toBeGreaterThan(0);
expect(flashes.length).toBeLessThanOrEqual(3);
});
it('should return empty flashes from empty graph', () => {
const emptyGraph = createMyceliumGraph();
const flashes = extractMemoryFlashes(emptyGraph, 3);
expect(flashes).toHaveLength(0);
});
it('should generate flash text for element hints', () => {
const flash = generateFlashText('element', 'Na');
expect(flash.type).toBe('element_hint');
expect(flash.text.length).toBeGreaterThan(0);
expect(flash.textRu.length).toBeGreaterThan(0);
// Should contain the element name somewhere
expect(flash.text).toContain('Na');
expect(flash.textRu).toContain('Na');
});
it('should generate flash text for reaction hints', () => {
const flash = generateFlashText('reaction', 'Na+Cl');
expect(flash.type).toBe('reaction_hint');
expect(flash.text.length).toBeGreaterThan(0);
});
it('should generate flash text for creature hints', () => {
const flash = generateFlashText('creature', 'crystallid');
expect(flash.type).toBe('creature_hint');
expect(flash.text.length).toBeGreaterThan(0);
});
it('should increment extraction counter', () => {
expect(graph.totalExtractions).toBe(0);
extractMemoryFlashes(graph, 3);
expect(graph.totalExtractions).toBe(1);
});
it('should prefer stronger nodes for flashes', () => {
// Strengthen Na by depositing multiple times
for (let i = 0; i < 5; i++) {
const run = createTestRunState(i + 2);
run.discoveries.elements = new Set(['Na']);
run.discoveries.reactions = new Set();
run.discoveries.compounds = new Set();
run.discoveries.creatures = new Set();
depositKnowledge(graph, run);
}
// Extract many flashes — Na should appear frequently
let naCount = 0;
for (let i = 0; i < 10; i++) {
const flashes = extractMemoryFlashes(graph, 3);
for (const f of flashes) {
if (f.text.includes('Na') || f.textRu.includes('Na')) naCount++;
}
}
// Na should appear at least once in 10 rounds of 3 flashes
expect(naCount).toBeGreaterThan(0);
});
});
// ─── Mycosis Tests ───────────────────────────────────────────────
describe('Mycosis', () => {
let state: MycosisState;
beforeEach(() => {
state = createMycosisState();
});
it('should create initial mycosis state at zero', () => {
expect(state.level).toBe(0);
expect(state.exposure).toBe(0);
expect(state.revealing).toBe(false);
});
it('should increase level when near a fungal node', () => {
updateMycosis(state, 1000, true); // 1 second near a node
expect(state.level).toBeGreaterThan(0);
expect(state.exposure).toBe(1000);
});
it('should decrease level when away from nodes', () => {
// Build up some mycosis
updateMycosis(state, 5000, true);
const peakLevel = state.level;
// Move away
updateMycosis(state, 2000, false);
expect(state.level).toBeLessThan(peakLevel);
});
it('should not go below zero', () => {
updateMycosis(state, 10000, false);
expect(state.level).toBe(0);
});
it('should not exceed max level', () => {
updateMycosis(state, 100000, true); // Very long exposure
expect(state.level).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxLevel);
});
it('should start revealing at threshold', () => {
// Build up to reveal threshold
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
updateMycosis(state, timeToThreshold + 1000, true);
expect(state.revealing).toBe(true);
});
it('should stop revealing when level drops below threshold', () => {
// Build up past threshold
const timeToThreshold = MYCOSIS_CONFIG.revealThreshold / MYCOSIS_CONFIG.buildRate * 1000;
updateMycosis(state, timeToThreshold + 2000, true);
expect(state.revealing).toBe(true);
// Let it decay
const decayTime = (state.level - MYCOSIS_CONFIG.revealThreshold + 0.1) / MYCOSIS_CONFIG.decayRate * 1000;
updateMycosis(state, decayTime + 1000, false);
expect(state.revealing).toBe(false);
});
it('should return visual parameters based on level', () => {
updateMycosis(state, 5000, true);
const visuals = getMycosisVisuals(state);
expect(visuals.tintColor).toBe(MYCOSIS_CONFIG.tintColor);
expect(visuals.tintAlpha).toBeGreaterThan(0);
expect(visuals.tintAlpha).toBeLessThanOrEqual(MYCOSIS_CONFIG.maxTintAlpha);
expect(visuals.distortionStrength).toBeGreaterThanOrEqual(0);
});
it('should have zero visuals when mycosis is zero', () => {
const visuals = getMycosisVisuals(state);
expect(visuals.tintAlpha).toBe(0);
expect(visuals.distortionStrength).toBe(0);
});
});
// ─── Spore Shop Tests ────────────────────────────────────────────
describe('Spore Shop', () => {
it('should list available bonuses', () => {
const bonuses = getAvailableBonuses();
expect(bonuses.length).toBeGreaterThan(0);
// Each bonus should have required fields
for (const bonus of bonuses) {
expect(bonus.id).toBeTruthy();
expect(bonus.nameRu).toBeTruthy();
expect(bonus.cost).toBeGreaterThan(0);
expect(bonus.effect).toBeDefined();
}
});
it('should check affordability correctly', () => {
const meta = createTestMeta();
meta.spores = 15;
const bonuses = getAvailableBonuses();
const cheapBonus = bonuses.find(b => b.cost <= 15);
const expensiveBonus = bonuses.find(b => b.cost > 15);
if (cheapBonus) {
expect(canAffordBonus(meta, cheapBonus.id)).toBe(true);
}
if (expensiveBonus) {
expect(canAffordBonus(meta, expensiveBonus.id)).toBe(false);
}
});
it('should deduct spores on purchase', () => {
const meta = createTestMeta();
const initialSpores = meta.spores;
const bonuses = getAvailableBonuses();
const bonus = bonuses[0];
const result = purchaseBonus(meta, bonus.id);
expect(result).not.toBeNull();
expect(meta.spores).toBe(initialSpores - bonus.cost);
});
it('should return the purchased effect', () => {
const meta = createTestMeta();
const bonuses = getAvailableBonuses();
const bonus = bonuses.find(b => b.effect.type === 'extra_health');
expect(bonus).toBeDefined();
const result = purchaseBonus(meta, bonus!.id);
expect(result).not.toBeNull();
expect(result!.type).toBe('extra_health');
});
it('should fail to purchase with insufficient spores', () => {
const meta = createTestMeta();
meta.spores = 0;
const bonuses = getAvailableBonuses();
const result = purchaseBonus(meta, bonuses[0].id);
expect(result).toBeNull();
expect(meta.spores).toBe(0); // unchanged
});
it('should not allow non-repeatable bonuses to be purchased twice', () => {
const meta = createTestMeta();
meta.spores = 200;
const bonuses = getAvailableBonuses();
const nonRepeatable = bonuses.find(b => !b.repeatable);
if (!nonRepeatable) return; // skip if all are repeatable
const result1 = purchaseBonus(meta, nonRepeatable.id);
expect(result1).not.toBeNull();
const result2 = purchaseBonus(meta, nonRepeatable.id);
expect(result2).toBeNull();
});
});
// ─── Fungal Node Spawning Tests ──────────────────────────────────
describe('Fungal Node Spawning', () => {
// Build a simple 20x20 grid with walkable ground
const biome = {
id: 'test',
name: 'Test',
nameRu: 'Тест',
description: '',
descriptionRu: '',
tileSize: 16,
mapWidth: 20,
mapHeight: 20,
tiles: [
{ id: 0, name: 'ground', nameRu: 'земля', color: '#555', walkable: true, damage: 0, interactive: false, resource: false },
{ id: 1, name: 'acid-deep', nameRu: 'кислота', color: '#0f0', walkable: false, damage: 5, interactive: false, resource: false },
{ id: 2, name: 'scorched-earth', nameRu: 'выжженная', color: '#333', walkable: true, damage: 0, interactive: false, resource: false },
],
generation: {
elevationScale: 0.1,
detailScale: 0.2,
elevationRules: [{ below: 0.5, tileId: 0 }],
geyserThreshold: 0.9,
mineralThreshold: 0.8,
geyserOnTile: 0,
mineralOnTiles: [0],
},
};
function createTestGrid(): number[][] {
const grid: number[][] = [];
for (let y = 0; y < 20; y++) {
const row: number[] = [];
for (let x = 0; x < 20; x++) {
// Mostly ground, some acid
row.push(x === 0 && y === 0 ? 1 : 0);
}
grid.push(row);
}
return grid;
}
it('should spawn fungal nodes on walkable tiles', async () => {
const { createWorld } = await import('bitecs');
const world = createWorld();
const grid = createTestGrid();
const nodeData = spawnFungalNodes(world, grid, biome, 42);
expect(nodeData.size).toBeGreaterThan(0);
});
it('should not spawn on non-walkable tiles', async () => {
const { createWorld } = await import('bitecs');
const world = createWorld();
// Make grid entirely non-walkable
const grid: number[][] = [];
for (let y = 0; y < 20; y++) {
grid.push(Array(20).fill(1)); // all acid
}
const nodeData = spawnFungalNodes(world, grid, biome, 42);
expect(nodeData.size).toBe(0);
});
it('should use deterministic placement based on seed', async () => {
const { createWorld } = await import('bitecs');
const grid = createTestGrid();
const world1 = createWorld();
const data1 = spawnFungalNodes(world1, grid, biome, 42);
const world2 = createWorld();
const data2 = spawnFungalNodes(world2, grid, biome, 42);
// Same seed → same positions
const positions1 = [...data1.values()].map(d => `${d.tileX},${d.tileY}`).sort();
const positions2 = [...data2.values()].map(d => `${d.tileX},${d.tileY}`).sort();
expect(positions1).toEqual(positions2);
});
it('should maintain minimum spacing between nodes', async () => {
const { createWorld } = await import('bitecs');
const world = createWorld();
const grid = createTestGrid();
const nodeData = spawnFungalNodes(world, grid, biome, 42);
const positions = [...nodeData.values()].map(d => ({ x: d.tileX, y: d.tileY }));
// No two nodes should be closer than minSpacing
// (on a 20x20 grid with spacing 8, there can be only a few nodes)
for (let i = 0; i < positions.length; i++) {
for (let j = i + 1; j < positions.length; j++) {
const dx = positions[i].x - positions[j].x;
const dy = positions[i].y - positions[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
expect(dist).toBeGreaterThanOrEqual(7); // slight tolerance
}
}
});
});

409
tests/player.test.ts Normal file
View File

@@ -0,0 +1,409 @@
/**
* Player Systems Tests — Phase 4.1
*
* Tests pure logic functions: input → velocity, tile collision,
* spawn position finding, and player entity creation.
*/
import { describe, it, expect } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import { Position, Velocity, PlayerTag, Health, SpriteRef } from '../src/ecs/components';
import { calculatePlayerVelocity, playerInputSystem } from '../src/player/input';
import { PLAYER_SPEED, PLAYER_COLLISION_RADIUS } from '../src/player/types';
import {
isTileWalkable,
isAreaWalkable,
resolveCollision,
buildWalkableSet,
tileCollisionSystem,
} from '../src/player/collision';
import { findSpawnPosition } from '../src/player/spawn';
import { createPlayerEntity } from '../src/player/factory';
import type { InputState } from '../src/player/types';
// === Helpers ===
function makeInput(moveX = 0, moveY = 0, interact = false): InputState {
return { moveX, moveY, interact };
}
// === Input: calculatePlayerVelocity ===
describe('calculatePlayerVelocity', () => {
it('moves right when moveX = 1', () => {
const v = calculatePlayerVelocity(makeInput(1, 0), PLAYER_SPEED);
expect(v.vx).toBe(PLAYER_SPEED);
expect(v.vy).toBe(0);
});
it('moves left when moveX = -1', () => {
const v = calculatePlayerVelocity(makeInput(-1, 0), PLAYER_SPEED);
expect(v.vx).toBe(-PLAYER_SPEED);
expect(v.vy).toBe(0);
});
it('moves down when moveY = 1', () => {
const v = calculatePlayerVelocity(makeInput(0, 1), PLAYER_SPEED);
expect(v.vx).toBe(0);
expect(v.vy).toBe(PLAYER_SPEED);
});
it('moves up when moveY = -1', () => {
const v = calculatePlayerVelocity(makeInput(0, -1), PLAYER_SPEED);
expect(v.vx).toBe(0);
expect(v.vy).toBe(-PLAYER_SPEED);
});
it('stops when no input', () => {
const v = calculatePlayerVelocity(makeInput(0, 0), PLAYER_SPEED);
expect(v.vx).toBe(0);
expect(v.vy).toBe(0);
});
it('normalizes diagonal movement to same speed', () => {
const v = calculatePlayerVelocity(makeInput(1, 1), PLAYER_SPEED);
const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
expect(mag).toBeCloseTo(PLAYER_SPEED);
});
it('normalizes all four diagonal directions', () => {
for (const [mx, my] of [[-1, -1], [-1, 1], [1, -1], [1, 1]]) {
const v = calculatePlayerVelocity(makeInput(mx, my), PLAYER_SPEED);
const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
expect(mag).toBeCloseTo(PLAYER_SPEED);
}
});
it('diagonal components are equal in magnitude', () => {
const v = calculatePlayerVelocity(makeInput(1, -1), PLAYER_SPEED);
expect(Math.abs(v.vx)).toBeCloseTo(Math.abs(v.vy));
});
});
// === Input: playerInputSystem ===
describe('playerInputSystem', () => {
it('sets player velocity from input', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, PlayerTag);
playerInputSystem(world, makeInput(1, 0));
expect(Velocity.vx[eid]).toBe(PLAYER_SPEED);
expect(Velocity.vy[eid]).toBe(0);
});
it('does not affect non-player entities', () => {
const world = createWorld();
// Player
const player = addEntity(world);
addComponent(world, player, Position);
addComponent(world, player, Velocity);
addComponent(world, player, PlayerTag);
// Non-player with existing velocity
const other = addEntity(world);
addComponent(world, other, Position);
addComponent(world, other, Velocity);
Velocity.vx[other] = 50;
Velocity.vy[other] = -30;
playerInputSystem(world, makeInput(0, 1));
expect(Velocity.vx[player]).toBe(0);
expect(Velocity.vy[player]).toBe(PLAYER_SPEED);
// Non-player unchanged
expect(Velocity.vx[other]).toBe(50);
expect(Velocity.vy[other]).toBe(-30);
});
it('stops player when input is zero', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, PlayerTag);
Velocity.vx[eid] = 999;
Velocity.vy[eid] = 999;
playerInputSystem(world, makeInput(0, 0));
expect(Velocity.vx[eid]).toBe(0);
expect(Velocity.vy[eid]).toBe(0);
});
});
// === Collision: isTileWalkable ===
describe('isTileWalkable', () => {
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
const walkable = new Set([0]);
const ts = 32;
it('returns true for walkable tile', () => {
expect(isTileWalkable(16, 16, grid, ts, walkable)).toBe(true);
});
it('returns false for non-walkable tile', () => {
expect(isTileWalkable(48, 48, grid, ts, walkable)).toBe(false);
});
it('returns false for negative coordinates', () => {
expect(isTileWalkable(-1, 16, grid, ts, walkable)).toBe(false);
expect(isTileWalkable(16, -1, grid, ts, walkable)).toBe(false);
});
it('returns false for out-of-bounds coordinates', () => {
expect(isTileWalkable(96, 16, grid, ts, walkable)).toBe(false);
expect(isTileWalkable(16, 96, grid, ts, walkable)).toBe(false);
});
it('handles tile edge boundaries', () => {
expect(isTileWalkable(31.9, 16, grid, ts, walkable)).toBe(true); // still tile 0
expect(isTileWalkable(32, 16, grid, ts, walkable)).toBe(true); // tile 1 column, row 0 = walkable
});
});
// === Collision: isAreaWalkable ===
describe('isAreaWalkable', () => {
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
const walkable = new Set([0]);
const ts = 32;
const r = 6;
it('returns true when area is fully on walkable tiles', () => {
expect(isAreaWalkable(16, 16, r, grid, ts, walkable)).toBe(true);
});
it('returns false when area overlaps non-walkable tile', () => {
// (26, 26) with r=6 → corner at (32, 32) enters tile (1,1) which is non-walkable
expect(isAreaWalkable(26, 26, r, grid, ts, walkable)).toBe(false);
});
it('returns true when near wall but not overlapping', () => {
// (24, 16) with r=6 → rightmost point at 30, still in tile 0 column
expect(isAreaWalkable(24, 16, r, grid, ts, walkable)).toBe(true);
});
});
// === Collision: resolveCollision ===
describe('resolveCollision', () => {
const ts = 32;
const r = 6;
const walkable = new Set([0]);
it('allows movement to valid position', () => {
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable);
expect(result).toEqual({ x: 48, y: 48 });
});
it('blocks movement into non-walkable tile (slides X when Y row is clear)', () => {
// Center tile non-walkable but row 0 at X=48 is walkable → slides X
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable);
expect(result).toEqual({ x: 48, y: 16 }); // slides along X
});
it('fully blocks when both axes lead to non-walkable', () => {
// "+" shaped wall: column 1 and row 1 are non-walkable
const grid = [[0, 3, 0], [3, 3, 3], [0, 3, 0]];
const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable);
expect(result).toEqual({ x: 16, y: 16 }); // can't move at all
});
it('wall-slides along X when Y is blocked', () => {
// Top row is wall, rows 1-2 walkable
const grid = [[3, 3, 3], [0, 0, 0], [0, 0, 0]];
// From (48, 48) try to go to (60, 22) — Y into wall, X along wall
// Full: blocked (y-r=16 hits row 0)
// X-only: (60, 48) → all in row 1 → valid
const result = resolveCollision(60, 22, 48, 48, r, grid, ts, walkable);
expect(result).toEqual({ x: 60, y: 48 });
});
it('wall-slides along Y when X is blocked', () => {
// Left column is wall, columns 1-2 walkable
const grid = [[3, 0, 0], [3, 0, 0], [3, 0, 0]];
// From (48, 48) try to go to (22, 60) — X into wall, Y along wall
// Full: blocked (x-r=16 hits col 0)
// X-only: (22, 48) → blocked
// Y-only: (48, 60) → all in col 1 → valid
const result = resolveCollision(22, 60, 48, 48, r, grid, ts, walkable);
expect(result).toEqual({ x: 48, y: 60 });
});
it('reverts completely when both axes blocked', () => {
// Only center tile walkable, rest walls
const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]];
// From (48, 48) try to go to (80, 80) — off walkable area
const result = resolveCollision(80, 80, 48, 48, r, grid, ts, walkable);
expect(result).toEqual({ x: 48, y: 48 });
});
it('blocks out-of-bounds movement', () => {
const grid = [[0, 0], [0, 0]];
const result = resolveCollision(-5, 16, 16, 16, r, grid, ts, walkable);
expect(result).toEqual({ x: 16, y: 16 });
});
});
// === Collision: tileCollisionSystem ===
describe('tileCollisionSystem', () => {
it('corrects player position after invalid movement', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, PlayerTag);
// Player at (48, 48) with velocity that would move into wall
// After movementSystem: Position was updated. We simulate the post-movement state.
Velocity.vx[eid] = 150; // moving right
Velocity.vy[eid] = 0;
// After 0.2s movement: moved 30px right to x=78 which is in tile (2,1)
Position.x[eid] = 78;
Position.y[eid] = 48;
// Grid where tile (2,1) is non-walkable
const grid = [[0, 0, 3], [0, 0, 3], [0, 0, 3]];
const walkable = new Set([0]);
tileCollisionSystem(world, 200, grid, 32, walkable);
// Should revert X (back to 48)
expect(Position.x[eid]).toBe(48);
expect(Position.y[eid]).toBe(48);
});
});
// === Collision: buildWalkableSet ===
describe('buildWalkableSet', () => {
it('includes walkable tiles', () => {
const tiles = [
{ id: 0, walkable: true },
{ id: 1, walkable: true },
{ id: 3, walkable: false },
];
const set = buildWalkableSet(tiles);
expect(set.has(0)).toBe(true);
expect(set.has(1)).toBe(true);
expect(set.has(3)).toBe(false);
});
it('returns empty set when no walkable tiles', () => {
const tiles = [{ id: 0, walkable: false }];
const set = buildWalkableSet(tiles);
expect(set.size).toBe(0);
});
});
// === Spawn Position ===
describe('findSpawnPosition', () => {
const walkable = new Set([0]);
const ts = 32;
it('finds walkable spawn position', () => {
const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]];
const pos = findSpawnPosition(grid, ts, walkable);
expect(pos).not.toBeNull();
const tx = Math.floor(pos!.x / ts);
const ty = Math.floor(pos!.y / ts);
expect(walkable.has(grid[ty][tx])).toBe(true);
});
it('prefers center of map', () => {
const grid = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
];
const pos = findSpawnPosition(grid, ts, walkable);
expect(pos).not.toBeNull();
const tx = Math.floor(pos!.x / ts);
const ty = Math.floor(pos!.y / ts);
expect(tx).toBe(2);
expect(ty).toBe(2);
});
it('returns null for fully non-walkable grid', () => {
const grid = [[3, 3], [3, 3]];
const pos = findSpawnPosition(grid, ts, walkable);
expect(pos).toBeNull();
});
it('spirals outward to find walkable tile', () => {
// Center (2,2) is non-walkable, but neighbors are
const grid = [
[0, 0, 0, 0, 0],
[0, 3, 3, 3, 0],
[0, 3, 3, 3, 0],
[0, 3, 3, 3, 0],
[0, 0, 0, 0, 0],
];
const pos = findSpawnPosition(grid, ts, walkable);
expect(pos).not.toBeNull();
// Should find one of the ring-1 tiles (first walkable on spiral)
const tx = Math.floor(pos!.x / ts);
const ty = Math.floor(pos!.y / ts);
expect(walkable.has(grid[ty][tx])).toBe(true);
});
it('returns center of tile in pixels', () => {
const grid = [[0]];
const pos = findSpawnPosition(grid, ts, walkable);
expect(pos).toEqual({ x: 16, y: 16 }); // center of tile (0,0) = (16, 16)
});
});
// === Player Entity Factory ===
describe('createPlayerEntity', () => {
it('creates entity with correct position', () => {
const world = createWorld();
const eid = createPlayerEntity(world, 100, 200);
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
});
it('creates entity with health', () => {
const world = createWorld();
const eid = createPlayerEntity(world, 0, 0);
expect(Health.current[eid]).toBeGreaterThan(0);
expect(Health.max[eid]).toBeGreaterThan(0);
expect(Health.current[eid]).toBe(Health.max[eid]);
});
it('creates entity with sprite', () => {
const world = createWorld();
const eid = createPlayerEntity(world, 0, 0);
expect(SpriteRef.color[eid]).toBeDefined();
expect(SpriteRef.radius[eid]).toBeGreaterThan(0);
});
it('creates entity queryable with PlayerTag', () => {
const world = createWorld();
const eid = createPlayerEntity(world, 50, 50);
const players = query(world, [PlayerTag]);
expect(players).toContain(eid);
expect(players.length).toBe(1);
});
it('creates entity with velocity component', () => {
const world = createWorld();
const eid = createPlayerEntity(world, 0, 0);
expect(Velocity.vx[eid]).toBe(0);
expect(Velocity.vy[eid]).toBe(0);
});
});

188
tests/projectile.test.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Projectile System Tests — Phase 4.5
*
* Tests: projectile creation, direction calculation,
* lifetime expiry, tile collision removal.
*/
import { describe, it, expect } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import { Position, Velocity, Projectile, SpriteRef } from '../src/ecs/components';
import {
launchProjectile,
projectileSystem,
calculateDirection,
type ProjectileData,
PROJECTILE_SPEED,
PROJECTILE_LIFETIME,
} from '../src/player/projectile';
// === Direction Calculation ===
describe('calculateDirection', () => {
it('normalizes direction vector', () => {
const d = calculateDirection(0, 0, 100, 0);
expect(d.dx).toBeCloseTo(1);
expect(d.dy).toBeCloseTo(0);
});
it('handles diagonal direction', () => {
const d = calculateDirection(0, 0, 100, 100);
const mag = Math.sqrt(d.dx * d.dx + d.dy * d.dy);
expect(mag).toBeCloseTo(1);
});
it('handles negative direction', () => {
const d = calculateDirection(100, 100, 0, 0);
expect(d.dx).toBeCloseTo(-1 / Math.SQRT2);
expect(d.dy).toBeCloseTo(-1 / Math.SQRT2);
});
it('returns zero for same position', () => {
const d = calculateDirection(50, 50, 50, 50);
expect(d.dx).toBe(1); // default to right
expect(d.dy).toBe(0);
});
});
// === Launch Projectile ===
describe('launchProjectile', () => {
it('creates entity at source position', () => {
const world = createWorld();
const projData = new Map<number, ProjectileData>();
const eid = launchProjectile(world, projData, 100, 200, 200, 200, 'Na');
expect(Position.x[eid]).toBe(100);
expect(Position.y[eid]).toBe(200);
});
it('sets velocity toward target', () => {
const world = createWorld();
const projData = new Map<number, ProjectileData>();
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
expect(Velocity.vx[eid]).toBeCloseTo(PROJECTILE_SPEED);
expect(Velocity.vy[eid]).toBeCloseTo(0);
});
it('stores chemical data', () => {
const world = createWorld();
const projData = new Map<number, ProjectileData>();
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
expect(projData.get(eid)).toBeDefined();
expect(projData.get(eid)!.itemId).toBe('Na');
});
it('sets lifetime', () => {
const world = createWorld();
const projData = new Map<number, ProjectileData>();
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
expect(Projectile.lifetime[eid]).toBe(PROJECTILE_LIFETIME);
});
it('sets sprite with element color', () => {
const world = createWorld();
const projData = new Map<number, ProjectileData>();
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
expect(SpriteRef.radius[eid]).toBeGreaterThan(0);
expect(SpriteRef.color[eid]).toBeDefined();
});
});
// === Projectile System ===
describe('projectileSystem', () => {
it('decrements lifetime', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, Projectile);
Position.x[eid] = 48;
Position.y[eid] = 48;
Velocity.vx[eid] = 100;
Velocity.vy[eid] = 0;
Projectile.lifetime[eid] = 1000;
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const walkable = new Set([0]);
const projData = new Map<number, ProjectileData>();
projData.set(eid, { itemId: 'Na' });
projectileSystem(world, 100, grid, 32, walkable, projData);
expect(Projectile.lifetime[eid]).toBe(900);
});
it('removes expired projectiles', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, Projectile);
Position.x[eid] = 48;
Position.y[eid] = 48;
Projectile.lifetime[eid] = 50;
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const walkable = new Set([0]);
const projData = new Map<number, ProjectileData>();
projData.set(eid, { itemId: 'Na' });
projectileSystem(world, 100, grid, 32, walkable, projData);
expect(query(world, [Projectile]).length).toBe(0);
expect(projData.size).toBe(0);
});
it('removes projectiles on non-walkable tile', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, Projectile);
// Position on non-walkable tile (1,1) = tile 3
Position.x[eid] = 48;
Position.y[eid] = 48;
Projectile.lifetime[eid] = 5000;
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
const walkable = new Set([0]);
const projData = new Map<number, ProjectileData>();
projData.set(eid, { itemId: 'Na' });
projectileSystem(world, 16, grid, 32, walkable, projData);
expect(query(world, [Projectile]).length).toBe(0);
});
it('keeps valid projectiles alive', () => {
const world = createWorld();
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Velocity);
addComponent(world, eid, Projectile);
Position.x[eid] = 48;
Position.y[eid] = 48;
Projectile.lifetime[eid] = 5000;
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const walkable = new Set([0]);
const projData = new Map<number, ProjectileData>();
projData.set(eid, { itemId: 'Na' });
projectileSystem(world, 16, grid, 32, walkable, projData);
expect(query(world, [Projectile]).length).toBe(1);
expect(projData.size).toBe(1);
});
});

131
tests/quickslots.test.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Quick Slots Tests — Phase 4.6
*
* Tests: slot assignment, active selection, key switching.
*/
import { describe, it, expect } from 'vitest';
import { QuickSlots, SLOT_COUNT } from '../src/player/quickslots';
describe('QuickSlots — creation', () => {
it('starts with all slots empty', () => {
const qs = new QuickSlots();
for (let i = 0; i < SLOT_COUNT; i++) {
expect(qs.getSlot(i)).toBeNull();
}
});
it('starts with slot 0 active', () => {
const qs = new QuickSlots();
expect(qs.activeIndex).toBe(0);
});
it('has 4 slots', () => {
expect(SLOT_COUNT).toBe(4);
const qs = new QuickSlots();
expect(qs.getAll()).toHaveLength(4);
});
});
describe('QuickSlots — assignment', () => {
it('assigns item to slot', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
expect(qs.getSlot(0)).toBe('Na');
});
it('replaces existing item in slot', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
qs.assign(0, 'Fe');
expect(qs.getSlot(0)).toBe('Fe');
});
it('clears slot with null', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
qs.assign(0, null);
expect(qs.getSlot(0)).toBeNull();
});
it('ignores invalid index', () => {
const qs = new QuickSlots();
qs.assign(-1, 'Na');
qs.assign(4, 'Na');
// No crash, no change
expect(qs.getAll()).toEqual([null, null, null, null]);
});
});
describe('QuickSlots — active slot', () => {
it('returns active slot item', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
expect(qs.getActive()).toBe('Na');
});
it('returns null for empty active slot', () => {
const qs = new QuickSlots();
expect(qs.getActive()).toBeNull();
});
it('switches active slot', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
qs.assign(1, 'Fe');
qs.assign(2, 'Cu');
qs.setActive(1);
expect(qs.activeIndex).toBe(1);
expect(qs.getActive()).toBe('Fe');
qs.setActive(2);
expect(qs.getActive()).toBe('Cu');
});
it('clamps active index to valid range', () => {
const qs = new QuickSlots();
qs.setActive(-1);
expect(qs.activeIndex).toBe(0);
qs.setActive(10);
expect(qs.activeIndex).toBe(3);
});
});
describe('QuickSlots — getAll', () => {
it('returns snapshot of all slots', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
qs.assign(2, 'Fe');
expect(qs.getAll()).toEqual(['Na', null, 'Fe', null]);
});
});
describe('QuickSlots — auto-assign', () => {
it('assigns to first empty slot', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
const index = qs.autoAssign('Fe');
expect(index).toBe(1);
expect(qs.getSlot(1)).toBe('Fe');
});
it('returns -1 when all slots full', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
qs.assign(1, 'Fe');
qs.assign(2, 'Cu');
qs.assign(3, 'S');
const index = qs.autoAssign('H');
expect(index).toBe(-1);
});
it('does not duplicate item already in a slot', () => {
const qs = new QuickSlots();
qs.assign(0, 'Na');
const index = qs.autoAssign('Na');
expect(index).toBe(-1); // already assigned
expect(qs.getAll().filter(s => s === 'Na')).toHaveLength(1);
});
});

627
tests/run-cycle.test.ts Normal file
View File

@@ -0,0 +1,627 @@
import { describe, it, expect, beforeEach } from 'vitest';
import schoolsData from '../src/data/schools.json';
import elementsData from '../src/data/elements.json';
import type { SchoolData, RunState, MetaState, CrisisConfig } from '../src/run/types';
import {
RunPhase,
RUN_PHASE_NAMES,
BODY_COMPOSITION,
SPORE_REWARDS,
PHASE_DURATIONS,
ESCALATION_RATE,
} from '../src/run/types';
import {
createRunState,
advancePhase,
updateEscalation,
recordDiscovery,
calculateSpores,
} from '../src/run/state';
import {
createMetaState,
applyRunResults,
isSchoolUnlocked,
getCodexEntries,
getCodexCount,
checkSchoolUnlocks,
getSchoolBonuses,
} from '../src/run/meta';
import {
createCrisisState,
applyCrisisDamage,
attemptNeutralize,
isCrisisResolved,
CHEMICAL_PLAGUE,
} from '../src/run/crisis';
// ─── School Data ─────────────────────────────────────────────────
describe('School Data', () => {
const schools = schoolsData as SchoolData[];
it('should have exactly 4 schools', () => {
expect(schools.length).toBe(4);
});
it('Alchemist has correct starting elements', () => {
const alchemist = schools.find(s => s.id === 'alchemist');
expect(alchemist).toBeDefined();
expect(alchemist!.startingElements).toEqual(['H', 'O', 'C', 'Na', 'S', 'Fe']);
});
it('Mechanic has correct starting elements', () => {
const mechanic = schools.find(s => s.id === 'mechanic');
expect(mechanic).toBeDefined();
expect(mechanic!.startingElements).toEqual(['Fe', 'Cu', 'Sn', 'Si', 'C']);
});
it('Naturalist has correct starting elements', () => {
const naturalist = schools.find(s => s.id === 'naturalist');
expect(naturalist).toBeDefined();
expect(naturalist!.startingElements).toEqual(['C', 'N', 'O', 'P', 'K']);
});
it('Navigator has correct starting elements', () => {
const navigator = schools.find(s => s.id === 'navigator');
expect(navigator).toBeDefined();
expect(navigator!.startingElements).toEqual(['Si', 'Fe', 'C', 'H', 'O']);
});
it('all starting elements exist in element registry', () => {
const symbols = new Set(elementsData.map(e => e.symbol));
for (const school of schools) {
for (const elem of school.startingElements) {
expect(symbols.has(elem), `Element ${elem} not found in registry`).toBe(true);
}
}
});
it('starting quantities match starting elements', () => {
for (const school of schools) {
for (const elem of school.startingElements) {
expect(
school.startingQuantities[elem],
`Missing quantity for ${elem} in school ${school.id}`,
).toBeGreaterThan(0);
}
}
});
it('each school has bilingual fields', () => {
for (const school of schools) {
expect(school.name.length).toBeGreaterThan(0);
expect(school.nameRu.length).toBeGreaterThan(0);
expect(school.description.length).toBeGreaterThan(0);
expect(school.descriptionRu.length).toBeGreaterThan(0);
expect(school.principle.length).toBeGreaterThan(0);
expect(school.principleRu.length).toBeGreaterThan(0);
}
});
it('each school has a valid hex color', () => {
for (const school of schools) {
expect(school.color).toMatch(/^#[0-9a-fA-F]{6}$/);
}
});
it('each school has unique id', () => {
const ids = schools.map(s => s.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('each school has unique color', () => {
const colors = schools.map(s => s.color);
expect(new Set(colors).size).toBe(colors.length);
});
it('each school has bonuses defined', () => {
for (const school of schools) {
expect(school.bonuses).toBeDefined();
expect(Object.keys(school.bonuses).length).toBeGreaterThan(0);
}
});
it('locked schools have unlock conditions', () => {
for (const school of schools) {
if (school.id === 'alchemist') {
expect(school.unlockCondition).toBeUndefined();
} else {
expect(school.unlockCondition).toBeDefined();
expect(school.unlockCondition!.threshold).toBeGreaterThan(0);
expect(school.unlockCondition!.hint.length).toBeGreaterThan(0);
expect(school.unlockCondition!.hintRu.length).toBeGreaterThan(0);
}
}
});
it('each school has a real scientific principle', () => {
const alchemist = schools.find(s => s.id === 'alchemist')!;
expect(alchemist.principle).toBe('Chemical Equilibrium');
const mechanic = schools.find(s => s.id === 'mechanic')!;
expect(mechanic.principle).toBe('Lever & Moment of Force');
const naturalist = schools.find(s => s.id === 'naturalist')!;
expect(naturalist.principle).toBe('Photosynthesis');
const navigator = schools.find(s => s.id === 'navigator')!;
expect(navigator.principle).toBe('Angular Measurement');
});
});
// ─── Run State ───────────────────────────────────────────────────
describe('Run State', () => {
let state: RunState;
beforeEach(() => {
state = createRunState(1, 'alchemist');
});
it('creates initial state in Awakening phase', () => {
expect(state.runId).toBe(1);
expect(state.schoolId).toBe('alchemist');
expect(state.phase).toBe(RunPhase.Awakening);
expect(state.elapsed).toBe(0);
expect(state.escalation).toBe(0);
expect(state.crisisActive).toBe(false);
expect(state.alive).toBe(true);
});
it('advances phases in order', () => {
advancePhase(state);
expect(state.phase).toBe(RunPhase.Exploration);
advancePhase(state);
expect(state.phase).toBe(RunPhase.Escalation);
advancePhase(state);
expect(state.phase).toBe(RunPhase.Crisis);
advancePhase(state);
expect(state.phase).toBe(RunPhase.Resolution);
});
it('does not advance past Resolution', () => {
state.phase = RunPhase.Resolution;
advancePhase(state);
expect(state.phase).toBe(RunPhase.Resolution);
});
it('resets phase timer on advance', () => {
state.phaseTimer = 5000;
advancePhase(state);
expect(state.phaseTimer).toBe(0);
});
it('records element discovery', () => {
recordDiscovery(state, 'element', 'Na');
expect(state.discoveries.elements.has('Na')).toBe(true);
});
it('records reaction discovery', () => {
recordDiscovery(state, 'reaction', 'Na+Cl');
expect(state.discoveries.reactions.has('Na+Cl')).toBe(true);
});
it('records compound discovery', () => {
recordDiscovery(state, 'compound', 'NaCl');
expect(state.discoveries.compounds.has('NaCl')).toBe(true);
});
it('records creature discovery', () => {
recordDiscovery(state, 'creature', 'crystallid');
expect(state.discoveries.creatures.has('crystallid')).toBe(true);
});
it('does not duplicate discoveries', () => {
recordDiscovery(state, 'element', 'Na');
recordDiscovery(state, 'element', 'Na');
expect(state.discoveries.elements.size).toBe(1);
});
it('calculates spores from discoveries', () => {
recordDiscovery(state, 'element', 'Na');
recordDiscovery(state, 'element', 'Cl');
recordDiscovery(state, 'reaction', 'Na+Cl');
recordDiscovery(state, 'compound', 'NaCl');
recordDiscovery(state, 'creature', 'crystallid');
const spores = calculateSpores(state);
const expected = 2 * SPORE_REWARDS.element
+ 1 * SPORE_REWARDS.reaction
+ 1 * SPORE_REWARDS.compound
+ 1 * SPORE_REWARDS.creature;
expect(spores).toBe(expected);
});
it('adds crisis resolved bonus to spores', () => {
recordDiscovery(state, 'element', 'Na');
state.crisisResolved = true;
const spores = calculateSpores(state);
expect(spores).toBe(SPORE_REWARDS.element + SPORE_REWARDS.crisisResolved);
});
});
// ─── Escalation ──────────────────────────────────────────────────
describe('Escalation', () => {
let state: RunState;
beforeEach(() => {
state = createRunState(1, 'alchemist');
state.phase = RunPhase.Escalation;
});
it('increases escalation over time', () => {
updateEscalation(state, 1000); // 1 second
expect(state.escalation).toBeCloseTo(ESCALATION_RATE);
});
it('clamps escalation to 1.0', () => {
updateEscalation(state, 300_000); // 5 minutes
expect(state.escalation).toBeLessThanOrEqual(1.0);
});
it('does not escalate during Exploration', () => {
state.phase = RunPhase.Exploration;
updateEscalation(state, 10_000);
expect(state.escalation).toBe(0);
});
it('continues escalation during Crisis', () => {
state.phase = RunPhase.Crisis;
state.escalation = 0.5;
updateEscalation(state, 1000);
expect(state.escalation).toBeGreaterThan(0.5);
});
});
// ─── Meta-Progression ────────────────────────────────────────────
describe('Meta-Progression', () => {
let meta: MetaState;
beforeEach(() => {
meta = createMetaState();
});
it('creates empty meta state', () => {
expect(meta.spores).toBe(0);
expect(meta.codex).toEqual([]);
expect(meta.totalRuns).toBe(0);
expect(meta.totalDeaths).toBe(0);
expect(meta.unlockedSchools).toEqual(['alchemist']);
});
it('alchemist is unlocked by default', () => {
expect(isSchoolUnlocked(meta, 'alchemist')).toBe(true);
});
it('other schools are locked by default', () => {
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
});
it('applies run results — adds spores', () => {
const run = createRunState(1, 'alchemist');
recordDiscovery(run, 'element', 'Na');
recordDiscovery(run, 'element', 'Cl');
applyRunResults(meta, run);
expect(meta.spores).toBe(2 * SPORE_REWARDS.element);
expect(meta.totalRuns).toBe(1);
expect(meta.totalDeaths).toBe(1);
});
it('applies run results — adds codex entries', () => {
const run = createRunState(1, 'alchemist');
recordDiscovery(run, 'element', 'Na');
recordDiscovery(run, 'reaction', 'Na+Cl');
applyRunResults(meta, run);
expect(meta.codex.length).toBe(2);
expect(getCodexEntries(meta, 'element')).toHaveLength(1);
expect(getCodexEntries(meta, 'reaction')).toHaveLength(1);
});
it('does not duplicate codex entries across runs', () => {
const run1 = createRunState(1, 'alchemist');
recordDiscovery(run1, 'element', 'Na');
applyRunResults(meta, run1);
const run2 = createRunState(2, 'alchemist');
recordDiscovery(run2, 'element', 'Na');
applyRunResults(meta, run2);
expect(getCodexCount(meta)).toBe(1);
});
it('accumulates spores across runs', () => {
const run1 = createRunState(1, 'alchemist');
recordDiscovery(run1, 'element', 'Na');
applyRunResults(meta, run1);
const run2 = createRunState(2, 'alchemist');
recordDiscovery(run2, 'element', 'Cl');
applyRunResults(meta, run2);
expect(meta.spores).toBe(2 * SPORE_REWARDS.element);
expect(meta.totalRuns).toBe(2);
});
it('stores run history summaries', () => {
const run = createRunState(1, 'alchemist');
run.phase = RunPhase.Escalation;
run.elapsed = 120_000;
recordDiscovery(run, 'element', 'Na');
recordDiscovery(run, 'compound', 'NaCl');
applyRunResults(meta, run);
expect(meta.runHistory).toHaveLength(1);
expect(meta.runHistory[0].schoolId).toBe('alchemist');
expect(meta.runHistory[0].discoveries).toBe(2);
expect(meta.runHistory[0].duration).toBe(120_000);
});
});
// ─── Crisis: Chemical Plague ─────────────────────────────────────
describe('Crisis: Chemical Plague', () => {
it('defines chemical plague config', () => {
expect(CHEMICAL_PLAGUE.type).toBe('chemical-plague');
expect(CHEMICAL_PLAGUE.neutralizer).toBeDefined();
expect(CHEMICAL_PLAGUE.neutralizeAmount).toBeGreaterThan(0);
});
it('creates crisis state', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
expect(crisis.active).toBe(true);
expect(crisis.progress).toBe(0);
expect(crisis.resolved).toBe(false);
});
it('applies crisis damage over time', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
applyCrisisDamage(crisis, 1000);
expect(crisis.progress).toBeGreaterThan(0);
});
it('crisis progress clamps at 1.0', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
applyCrisisDamage(crisis, 999_999);
expect(crisis.progress).toBeLessThanOrEqual(1.0);
});
it('neutralize reduces progress', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.5;
const result = attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, 1);
expect(result).toBe(true);
expect(crisis.progress).toBeLessThan(0.5);
});
it('wrong compound does not neutralize', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.5;
const result = attemptNeutralize(crisis, 'WrongCompound', 1);
expect(result).toBe(false);
expect(crisis.progress).toBe(0.5);
});
it('sufficient neutralization resolves crisis', () => {
const crisis = createCrisisState(CHEMICAL_PLAGUE);
crisis.progress = 0.1;
attemptNeutralize(crisis, CHEMICAL_PLAGUE.neutralizer, CHEMICAL_PLAGUE.neutralizeAmount);
expect(isCrisisResolved(crisis)).toBe(true);
expect(crisis.resolved).toBe(true);
});
});
// ─── School Unlock System ────────────────────────────────────────
describe('School Unlock System', () => {
let meta: MetaState;
beforeEach(() => {
meta = createMetaState();
});
it('only alchemist is unlocked at start', () => {
expect(meta.unlockedSchools).toEqual(['alchemist']);
});
it('mechanic unlocks after discovering 10 elements', () => {
const run = createRunState(1, 'alchemist');
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
recordDiscovery(run, 'element', sym);
}
applyRunResults(meta, run);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
});
it('mechanic does NOT unlock with fewer than 10 elements', () => {
const run = createRunState(1, 'alchemist');
for (const sym of ['H', 'O', 'C', 'Na', 'S']) {
recordDiscovery(run, 'element', sym);
}
applyRunResults(meta, run);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
});
it('naturalist unlocks after discovering 3 creatures', () => {
const run = createRunState(1, 'alchemist');
recordDiscovery(run, 'creature', 'crystallid');
recordDiscovery(run, 'creature', 'acidophile');
recordDiscovery(run, 'creature', 'reagent');
applyRunResults(meta, run);
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true);
});
it('naturalist does NOT unlock with fewer than 3 creatures', () => {
const run = createRunState(1, 'alchemist');
recordDiscovery(run, 'creature', 'crystallid');
recordDiscovery(run, 'creature', 'acidophile');
applyRunResults(meta, run);
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(false);
});
it('navigator unlocks after 3 completed runs', () => {
for (let i = 1; i <= 3; i++) {
const run = createRunState(i, 'alchemist');
recordDiscovery(run, 'element', `elem${i}`);
applyRunResults(meta, run);
}
expect(isSchoolUnlocked(meta, 'navigator')).toBe(true);
});
it('navigator does NOT unlock with only 2 runs', () => {
for (let i = 1; i <= 2; i++) {
const run = createRunState(i, 'alchemist');
recordDiscovery(run, 'element', `elem${i}`);
applyRunResults(meta, run);
}
expect(isSchoolUnlocked(meta, 'navigator')).toBe(false);
});
it('unlocks persist across subsequent runs', () => {
// Unlock mechanic with 10 element discoveries
const run1 = createRunState(1, 'alchemist');
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
recordDiscovery(run1, 'element', sym);
}
applyRunResults(meta, run1);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
// Next run with no discoveries — mechanic still unlocked
const run2 = createRunState(2, 'alchemist');
applyRunResults(meta, run2);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
});
it('multiple schools can unlock in the same run', () => {
const run = createRunState(1, 'alchemist');
// 10 elements → mechanic
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe', 'Cu', 'Si', 'Sn', 'N']) {
recordDiscovery(run, 'element', sym);
}
// 3 creatures → naturalist
recordDiscovery(run, 'creature', 'crystallid');
recordDiscovery(run, 'creature', 'acidophile');
recordDiscovery(run, 'creature', 'reagent');
applyRunResults(meta, run);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
expect(isSchoolUnlocked(meta, 'naturalist')).toBe(true);
});
it('cumulative codex entries across runs unlock schools', () => {
// Run 1: discover 6 elements
const run1 = createRunState(1, 'alchemist');
for (const sym of ['H', 'O', 'C', 'Na', 'S', 'Fe']) {
recordDiscovery(run1, 'element', sym);
}
applyRunResults(meta, run1);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(false);
// Run 2: discover 4 more → total 10
const run2 = createRunState(2, 'alchemist');
for (const sym of ['Cu', 'Si', 'Sn', 'N']) {
recordDiscovery(run2, 'element', sym);
}
applyRunResults(meta, run2);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
});
it('checkSchoolUnlocks can be called standalone', () => {
// Manually populate codex with 10 elements
for (let i = 0; i < 10; i++) {
meta.codex.push({ id: `elem${i}`, type: 'element', discoveredOnRun: 1 });
}
checkSchoolUnlocks(meta);
expect(isSchoolUnlocked(meta, 'mechanic')).toBe(true);
});
});
// ─── School Bonuses ──────────────────────────────────────────────
describe('School Bonuses', () => {
it('alchemist has reaction efficiency bonus', () => {
const bonus = getSchoolBonuses('alchemist');
expect(bonus.reactionEfficiency).toBe(1.25);
expect(bonus.projectileDamage).toBe(1.0);
expect(bonus.movementSpeed).toBe(1.0);
expect(bonus.creatureAggroRange).toBe(1.0);
});
it('mechanic has projectile damage bonus', () => {
const bonus = getSchoolBonuses('mechanic');
expect(bonus.projectileDamage).toBe(1.3);
expect(bonus.movementSpeed).toBe(1.0);
});
it('naturalist has creature aggro reduction', () => {
const bonus = getSchoolBonuses('naturalist');
expect(bonus.creatureAggroRange).toBe(0.6);
expect(bonus.projectileDamage).toBe(1.0);
});
it('navigator has movement speed bonus', () => {
const bonus = getSchoolBonuses('navigator');
expect(bonus.movementSpeed).toBe(1.2);
expect(bonus.creatureAggroRange).toBe(1.0);
});
it('unknown school returns all-default bonuses', () => {
const bonus = getSchoolBonuses('nonexistent');
expect(bonus.projectileDamage).toBe(1.0);
expect(bonus.movementSpeed).toBe(1.0);
expect(bonus.creatureAggroRange).toBe(1.0);
expect(bonus.reactionEfficiency).toBe(1.0);
});
});
// ─── Body Composition ────────────────────────────────────────────
describe('Body Composition', () => {
it('fractions sum to approximately 1.0', () => {
const sum = BODY_COMPOSITION.reduce((acc, e) => acc + e.fraction, 0);
// Allow some margin since we simplified (real body has trace elements)
expect(sum).toBeGreaterThan(0.85);
expect(sum).toBeLessThanOrEqual(1.0);
});
it('all body elements exist in element registry', () => {
const symbols = new Set(elementsData.map(e => e.symbol));
for (const entry of BODY_COMPOSITION) {
expect(symbols.has(entry.symbol), `${entry.symbol} not in registry`).toBe(true);
}
});
it('oxygen is the largest fraction', () => {
const oxygen = BODY_COMPOSITION.find(e => e.symbol === 'O');
expect(oxygen).toBeDefined();
expect(oxygen!.fraction).toBe(0.65);
});
});
// ─── Run Phase Names ─────────────────────────────────────────────
describe('Run Phase Names', () => {
it('has names for all phases', () => {
expect(RUN_PHASE_NAMES[RunPhase.Awakening]).toBe('Awakening');
expect(RUN_PHASE_NAMES[RunPhase.Exploration]).toBe('Exploration');
expect(RUN_PHASE_NAMES[RunPhase.Escalation]).toBe('Escalation');
expect(RUN_PHASE_NAMES[RunPhase.Crisis]).toBe('Crisis');
expect(RUN_PHASE_NAMES[RunPhase.Resolution]).toBe('Resolution');
});
it('phases are numbered 0-4', () => {
expect(RunPhase.Awakening).toBe(0);
expect(RunPhase.Resolution).toBe(4);
});
it('phase durations are defined', () => {
expect(PHASE_DURATIONS[RunPhase.Exploration]).toBeGreaterThan(0);
expect(PHASE_DURATIONS[RunPhase.Escalation]).toBeGreaterThan(0);
});
});

Some files were not shown because too many files have changed in this diff Show More