Compare commits

..

4 Commits

Author SHA1 Message Date
Денис Шкабатур
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
32 changed files with 3291 additions and 31 deletions

View File

@@ -1,27 +1,37 @@
--- ---
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
## Commit Discipline
- Commit after EVERY successful step (tests green or visual verified)
- Small, focused commits — one concern per commit
- Never accumulate uncommitted 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 see result
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. Update `PROGRESS.md` — mark task complete
4. 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

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

@@ -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 3 ✅ → Ready for Phase 4
--- ---
@@ -19,23 +19,52 @@
- [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`)
--- ---
## In Progress ## In Progress
_None — ready to begin Phase 1_ _None — ready to begin Phase 4_
--- ---
## Up Next: Phase 1Chemistry Engine ## Up Next: Phase 4Player Systems
- [ ] 1.1 Types and interfaces (`Element`, `Reaction`, `Compound`) - [ ] 4.1 Player entity + WASD controller
- [ ] 1.2 Element data — 20 real elements (JSON) - [ ] 4.2 Inventory (weight-based, element stacking)
- [ ] 1.3 Element registry with lookup - [ ] 4.3 Element collection from world objects
- [ ] 1.4 Reaction engine core - [ ] 4.4 Crafting (chemistry engine integration)
- [ ] 1.5 Reaction data — 50 real reactions (JSON) - [ ] 4.5 Projectile system (throw elements/compounds)
- [ ] 1.6 Compound properties - [ ] 4.6 Quick slots (1-2-3-4 hotkeys)
- [ ] 1.7 Unit tests (vitest) - [ ] 4.7 HUD (UIScene: health ring, inventory bar, element info)
--- ---
@@ -49,4 +78,7 @@ 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) |

BIN
boot-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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",

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;

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

@@ -0,0 +1,102 @@
// === Element Types ===
export type ElementCategory =
| 'alkali-metal'
| 'alkaline-earth'
| 'transition-metal'
| 'post-transition-metal'
| 'metalloid'
| 'nonmetal'
| 'halogen'
| 'noble-gas';
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,6 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene'; import { BootScene } from './scenes/BootScene';
import { GameScene } from './scenes/GameScene';
export const GAME_WIDTH = 1280; export const GAME_WIDTH = 1280;
export const GAME_HEIGHT = 720; export const GAME_HEIGHT = 720;
@@ -10,7 +11,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, GameScene],
physics: { physics: {
default: 'arcade', default: 'arcade',
arcade: { arcade: {

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

@@ -0,0 +1,38 @@
[
{
"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]
}
}
]

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

@@ -0,0 +1,202 @@
[
{
"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"]
}
]

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

@@ -0,0 +1,262 @@
[
{
"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": "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": "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": "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": "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": "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": "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": "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": "Единственный металл, жидкий при комнатной температуре. Крайне токсичен — поражает мозг и почки. Обращаться с предельной осторожностью."
}
]

326
src/data/reactions.json Normal file
View File

@@ -0,0 +1,326 @@
[
{
"id": "synth_nacl", "type": "synthesis",
"reactants": [{ "id": "Na", "count": 1 }, { "id": "Cl", "count": 1 }],
"products": [{ "id": "NaCl", "count": 1 }],
"energyChange": -40,
"description": "Sodium and chlorine combine in a vigorous exothermic reaction to form table salt.",
"descriptionRu": "Натрий и хлор соединяются в бурной экзотермической реакции, образуя поваренную соль.",
"difficulty": 1
},
{
"id": "synth_kcl", "type": "synthesis",
"reactants": [{ "id": "K", "count": 1 }, { "id": "Cl", "count": 1 }],
"products": [{ "id": "KCl", "count": 1 }],
"energyChange": -44,
"description": "Potassium bonds with chlorine to form potassium chloride — a valuable fertilizer salt.",
"descriptionRu": "Калий связывается с хлором, образуя хлорид калия — ценную удобрительную соль.",
"difficulty": 1
},
{
"id": "synth_fes", "type": "synthesis",
"reactants": [{ "id": "Fe", "count": 1 }, { "id": "S", "count": 1 }],
"products": [{ "id": "FeS", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -10,
"description": "Iron and sulfur combine when heated to form iron sulfide (pyrite) — fool's gold that sparks when struck.",
"descriptionRu": "Железо и сера при нагревании образуют сульфид железа (пирит) — золото дураков, искрящее при ударе.",
"difficulty": 2
},
{
"id": "synth_cus", "type": "synthesis",
"reactants": [{ "id": "Cu", "count": 1 }, { "id": "S", "count": 1 }],
"products": [{ "id": "CuS", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -5,
"description": "Copper reacts with sulfur when heated, forming dark copper sulfide.",
"descriptionRu": "Медь реагирует с серой при нагревании, образуя тёмный сульфид меди.",
"difficulty": 2
},
{
"id": "synth_zns", "type": "synthesis",
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "S", "count": 1 }],
"products": [{ "id": "ZnS", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -20,
"description": "Zinc and sulfur combine exothermically to form zinc sulfide — a phosphorescent compound that glows in the dark.",
"descriptionRu": "Цинк и сера экзотермически образуют сульфид цинка — фосфоресцирующее соединение, светящееся в темноте.",
"difficulty": 2
},
{
"id": "synth_h2o", "type": "synthesis",
"reactants": [{ "id": "H", "count": 2 }, { "id": "O", "count": 1 }],
"products": [{ "id": "H2O", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -70,
"description": "Hydrogen combustion. Two parts hydrogen + one part oxygen ignite to produce water, releasing tremendous energy.",
"descriptionRu": "Горение водорода. Два объёма водорода + один объём кислорода воспламеняются, образуя воду и выделяя огромную энергию.",
"difficulty": 2
},
{
"id": "synth_hcl", "type": "synthesis",
"reactants": [{ "id": "H", "count": 1 }, { "id": "Cl", "count": 1 }],
"products": [{ "id": "HCl", "count": 1 }],
"energyChange": -9,
"description": "Hydrogen and chlorine combine to form hydrochloric acid — a strong acid that dissolves metals.",
"descriptionRu": "Водород и хлор образуют соляную кислоту — сильную кислоту, растворяющую металлы.",
"difficulty": 1
},
{
"id": "comb_co2", "type": "combustion",
"reactants": [{ "id": "C", "count": 1 }, { "id": "O", "count": 2 }],
"products": [{ "id": "CO2", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -40,
"description": "Carbon burns in excess oxygen to produce carbon dioxide. Complete combustion.",
"descriptionRu": "Углерод горит в избытке кислорода, образуя углекислый газ. Полное сгорание.",
"difficulty": 1
},
{
"id": "comb_co", "type": "combustion",
"reactants": [{ "id": "C", "count": 1 }, { "id": "O", "count": 1 }],
"products": [{ "id": "CO", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -11,
"description": "Carbon burns in limited oxygen → deadly carbon monoxide. Odorless killer.",
"descriptionRu": "Углерод горит при недостатке кислорода → смертельный угарный газ. Убийца без запаха.",
"difficulty": 2
},
{
"id": "comb_so2", "type": "combustion",
"reactants": [{ "id": "S", "count": 1 }, { "id": "O", "count": 2 }],
"products": [{ "id": "SO2", "count": 1 }],
"conditions": { "minTemp": 300 },
"energyChange": -30,
"description": "Sulfur burns with a blue flame, producing choking sulfur dioxide gas.",
"descriptionRu": "Сера горит синим пламенем, выделяя удушливый сернистый газ.",
"difficulty": 1
},
{
"id": "comb_mgo", "type": "combustion",
"reactants": [{ "id": "Mg", "count": 1 }, { "id": "O", "count": 1 }],
"products": [{ "id": "MgO", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -60,
"description": "Magnesium burns with an intensely bright white flame — blinding! Produces white magnesium oxide ash.",
"descriptionRu": "Магний горит ослепительно ярким белым пламенем! Образует белый пепел оксида магния.",
"difficulty": 1
},
{
"id": "comb_fe2o3", "type": "combustion",
"reactants": [{ "id": "Fe", "count": 2 }, { "id": "O", "count": 3 }],
"products": [{ "id": "Fe2O3", "count": 1 }],
"conditions": { "minTemp": 1000 },
"energyChange": -25,
"description": "Iron oxidation. At furnace temperatures, iron combines with oxygen to form iron oxide (rust).",
"descriptionRu": "Окисление железа. При температуре печи железо соединяется с кислородом, образуя оксид железа (ржавчину).",
"difficulty": 3
},
{
"id": "comb_cao", "type": "combustion",
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "O", "count": 1 }],
"products": [{ "id": "CaO", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -63,
"description": "Calcium burns in oxygen to form quicklime — a powerful desiccant and heat source when mixed with water.",
"descriptionRu": "Кальций горит в кислороде, образуя негашёную известь — мощный осушитель и источник тепла при смешении с водой.",
"difficulty": 2
},
{
"id": "comb_zno", "type": "combustion",
"reactants": [{ "id": "Zn", "count": 1 }, { "id": "O", "count": 1 }],
"products": [{ "id": "ZnO", "count": 1 }],
"conditions": { "minTemp": 500 },
"energyChange": -35,
"description": "Zinc burns with a blue-green flame, producing zinc oxide — a healing white powder.",
"descriptionRu": "Цинк горит сине-зелёным пламенем, образуя оксид цинка — целебный белый порошок.",
"difficulty": 2
},
{
"id": "comb_sio2", "type": "combustion",
"reactants": [{ "id": "Si", "count": 1 }, { "id": "O", "count": 2 }],
"products": [{ "id": "SiO2", "count": 1 }],
"conditions": { "minTemp": 1000 },
"energyChange": -90,
"description": "Silicon combines with oxygen at extreme heat to form silica (quartz) — the basis of glass.",
"descriptionRu": "Кремний соединяется с кислородом при экстремальном нагреве, образуя кремнезём (кварц) — основу стекла.",
"difficulty": 4
},
{
"id": "comb_al2o3", "type": "combustion",
"reactants": [{ "id": "Al", "count": 2 }, { "id": "O", "count": 3 }],
"products": [{ "id": "Al2O3", "count": 1 }],
"conditions": { "minTemp": 1000 },
"energyChange": -84,
"description": "Aluminum combustion produces alumina (corundum) — nearly as hard as diamond.",
"descriptionRu": "Горение алюминия производит корунд — почти такой же твёрдый, как алмаз.",
"difficulty": 4
},
{
"id": "synth_ch4", "type": "synthesis",
"reactants": [{ "id": "C", "count": 1 }, { "id": "H", "count": 4 }],
"products": [{ "id": "CH4", "count": 1 }],
"conditions": { "minTemp": 500, "catalyst": "Fe" },
"energyChange": -8,
"description": "Carbon + hydrogen with iron catalyst → methane. Simplest hydrocarbon fuel.",
"descriptionRu": "Углерод + водород с железным катализатором → метан. Простейшее углеводородное топливо.",
"difficulty": 3
},
{
"id": "synth_naoh", "type": "synthesis",
"reactants": [{ "id": "Na", "count": 1 }, { "id": "O", "count": 1 }, { "id": "H", "count": 1 }],
"products": [{ "id": "NaOH", "count": 1 }],
"energyChange": -45,
"description": "Direct synthesis of sodium hydroxide (lye). Extremely caustic — handle carefully!",
"descriptionRu": "Прямой синтез гидроксида натрия (щёлочи). Крайне едкий — обращаться осторожно!",
"difficulty": 2
},
{
"id": "synth_koh", "type": "synthesis",
"reactants": [{ "id": "K", "count": 1 }, { "id": "O", "count": 1 }, { "id": "H", "count": 1 }],
"products": [{ "id": "KOH", "count": 1 }],
"energyChange": -49,
"description": "Direct synthesis of potassium hydroxide. Even more reactive than NaOH.",
"descriptionRu": "Прямой синтез гидроксида калия. Ещё активнее, чем NaOH.",
"difficulty": 2
},
{
"id": "synth_caco3", "type": "synthesis",
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "C", "count": 1 }, { "id": "O", "count": 3 }],
"products": [{ "id": "CaCO3", "count": 1 }],
"energyChange": -12,
"description": "Calcium, carbon, and oxygen form calcium carbonate — the building block of limestone and shells.",
"descriptionRu": "Кальций, углерод и кислород образуют карбонат кальция — строительный блок известняка и ракушек.",
"difficulty": 3
},
{
"id": "synth_nahco3", "type": "synthesis",
"reactants": [{ "id": "Na", "count": 1 }, { "id": "H", "count": 1 }, { "id": "C", "count": 1 }, { "id": "O", "count": 3 }],
"products": [{ "id": "NaHCO3", "count": 1 }],
"energyChange": -10,
"description": "Synthesis of sodium bicarbonate (baking soda). Versatile acid neutralizer.",
"descriptionRu": "Синтез гидрокарбоната натрия (пищевая сода). Универсальный нейтрализатор кислот.",
"difficulty": 3
},
{
"id": "synth_kno3", "type": "synthesis",
"reactants": [{ "id": "K", "count": 1 }, { "id": "N", "count": 1 }, { "id": "O", "count": 3 }],
"products": [{ "id": "KNO3", "count": 1 }],
"energyChange": -5,
"description": "Potassium nitrate (saltpeter). A powerful oxidizer — one third of gunpowder.",
"descriptionRu": "Нитрат калия (селитра). Мощный окислитель — треть состава пороха.",
"difficulty": 3
},
{
"id": "synth_caoh2", "type": "synthesis",
"reactants": [{ "id": "Ca", "count": 1 }, { "id": "O", "count": 2 }, { "id": "H", "count": 2 }],
"products": [{ "id": "CaOH2", "count": 1 }],
"energyChange": -15,
"description": "Calcium hydroxide (slaked lime). Essential for mortar and water purification.",
"descriptionRu": "Гидроксид кальция (гашёная известь). Необходим для строительного раствора и очистки воды.",
"difficulty": 3
},
{
"id": "repl_na_h2o", "type": "single-replacement",
"reactants": [{ "id": "Na", "count": 1 }, { "id": "H2O", "count": 1 }],
"products": [{ "id": "NaOH", "count": 1 }, { "id": "H", "count": 1 }],
"energyChange": -80,
"description": "VIOLENT! Sodium reacts explosively with water → lye + hydrogen gas + fire. Never drop sodium in water!",
"descriptionRu": "ОПАСНО! Натрий взрывоподобно реагирует с водой → щёлочь + водород + пламя. Никогда не бросайте натрий в воду!",
"difficulty": 1
},
{
"id": "repl_k_h2o", "type": "single-replacement",
"reactants": [{ "id": "K", "count": 1 }, { "id": "H2O", "count": 1 }],
"products": [{ "id": "KOH", "count": 1 }, { "id": "H", "count": 1 }],
"energyChange": -95,
"description": "EXPLOSIVE! Potassium reacts even more violently than sodium with water — purple flame, detonation!",
"descriptionRu": "ВЗРЫВ! Калий реагирует с водой ещё бурнее натрия — фиолетовое пламя, детонация!",
"difficulty": 1
},
{
"id": "repl_cao_h2o", "type": "single-replacement",
"reactants": [{ "id": "CaO", "count": 1 }, { "id": "H2O", "count": 1 }],
"products": [{ "id": "CaOH2", "count": 1 }],
"energyChange": -65,
"description": "Quicklime + water → slaked lime + intense heat. Can boil water! Used in ancient warfare.",
"descriptionRu": "Негашёная известь + вода → гашёная известь + сильный нагрев. Может вскипятить воду! Использовалось в древних войнах.",
"difficulty": 1
},
{
"id": "ab_naoh_hcl", "type": "acid-base",
"reactants": [{ "id": "NaOH", "count": 1 }, { "id": "HCl", "count": 1 }],
"products": [{ "id": "NaCl", "count": 1 }, { "id": "H2O", "count": 1 }],
"energyChange": -57,
"description": "Classic neutralization: acid + base → salt + water. NaOH + HCl → NaCl + H₂O.",
"descriptionRu": "Классическая нейтрализация: кислота + основание → соль + вода. NaOH + HCl → NaCl + H₂O.",
"difficulty": 2
},
{
"id": "ab_koh_hcl", "type": "acid-base",
"reactants": [{ "id": "KOH", "count": 1 }, { "id": "HCl", "count": 1 }],
"products": [{ "id": "KCl", "count": 1 }, { "id": "H2O", "count": 1 }],
"energyChange": -57,
"description": "Neutralization: potassium hydroxide + hydrochloric acid → potassium chloride + water.",
"descriptionRu": "Нейтрализация: гидроксид калия + соляная кислота → хлорид калия + вода.",
"difficulty": 2
},
{
"id": "ab_nahco3_hcl", "type": "acid-base",
"reactants": [{ "id": "NaHCO3", "count": 1 }, { "id": "HCl", "count": 1 }],
"products": [{ "id": "NaCl", "count": 1 }, { "id": "H2O", "count": 1 }, { "id": "CO2", "count": 1 }],
"energyChange": -12,
"description": "Baking soda fizzes violently with acid! Produces salt, water, and carbon dioxide gas. Classic volcano reaction.",
"descriptionRu": "Сода бурно шипит при контакте с кислотой! Образуется соль, вода и углекислый газ. Классическая реакция «вулкан».",
"difficulty": 1
},
{
"id": "synth_gunpowder", "type": "synthesis",
"reactants": [{ "id": "KNO3", "count": 1 }, { "id": "S", "count": 1 }, { "id": "C", "count": 1 }],
"products": [{ "id": "GUNPOWDER", "count": 1 }],
"energyChange": 0,
"description": "The legendary mixture: saltpeter + sulfur + charcoal = black gunpowder. Handle with extreme care.",
"descriptionRu": "Легендарная смесь: селитра + сера + уголь = чёрный порох. Обращаться с предельной осторожностью.",
"difficulty": 4
},
{
"id": "redox_thermite", "type": "redox",
"reactants": [{ "id": "Fe2O3", "count": 1 }, { "id": "Al", "count": 2 }],
"products": [{ "id": "Fe", "count": 2 }, { "id": "Al2O3", "count": 1 }],
"conditions": { "minTemp": 1000 },
"energyChange": -100,
"description": "THERMITE! Iron oxide + aluminum → molten iron + alumina. Temperature exceeds 2500°C. Melts through anything.",
"descriptionRu": "ТЕРМИТ! Оксид железа + алюминий → расплавленное железо + корунд. Температура превышает 2500°C. Прожигает всё.",
"difficulty": 5
},
{
"id": "synth_ethanol", "type": "synthesis",
"reactants": [{ "id": "C", "count": 2 }, { "id": "H", "count": 6 }, { "id": "O", "count": 1 }],
"products": [{ "id": "C2H5OH", "count": 1 }],
"conditions": { "catalyst": "Cu" },
"energyChange": -3,
"description": "Ethanol synthesis requires a copper catalyst. The original chemistry of civilization — fermentation.",
"descriptionRu": "Синтез этанола требует медного катализатора. Изначальная химия цивилизации — ферментация.",
"difficulty": 4
},
{
"id": "decomp_h2o", "type": "decomposition",
"reactants": [{ "id": "H2O", "count": 1 }],
"products": [{ "id": "H", "count": 2 }, { "id": "O", "count": 1 }],
"conditions": { "requiresEnergy": true },
"energyChange": 70,
"description": "Electrolysis: electric current splits water into hydrogen and oxygen. Endothermic — needs energy input.",
"descriptionRu": "Электролиз: электрический ток разлагает воду на водород и кислород. Эндотермическая — требует энергии.",
"difficulty": 3
},
{
"id": "decomp_caco3", "type": "decomposition",
"reactants": [{ "id": "CaCO3", "count": 1 }],
"products": [{ "id": "CaO", "count": 1 }, { "id": "CO2", "count": 1 }],
"conditions": { "minTemp": 1000 },
"energyChange": 18,
"description": "Lime burning: heating limestone drives off CO₂, leaving quicklime. Ancient building technology.",
"descriptionRu": "Обжиг извести: нагрев известняка выгоняет CO₂, оставляя негашёную известь. Древняя строительная технология.",
"difficulty": 3
}
]

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

@@ -0,0 +1,80 @@
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];
}
}
/** 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();
}
}

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

@@ -0,0 +1,36 @@
/**
* 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)
};

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

@@ -30,29 +30,33 @@ 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.2.0 — Phase 2: ECS Foundation', {
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', () => {
this.scene.start('GameScene');
});
} }
} }

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

@@ -0,0 +1,83 @@
import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
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 type { BiomeData } from '../world/types';
import { generateWorld } from '../world/generator';
import { createWorldTilemap } from '../world/tilemap';
import { setupCamera, updateCamera, type CameraKeys } from '../world/camera';
import { Minimap } from '../world/minimap';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
private cameraKeys!: CameraKeys;
private minimap!: Minimap;
private statsText!: Phaser.GameObjects.Text;
private worldSeed!: number;
constructor() {
super({ key: 'GameScene' });
}
create(): void {
// 1. Initialize ECS (needed for future entity systems)
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
// 2. Generate world
const biome = biomeDataArray[0] as BiomeData;
this.worldSeed = Date.now() % 1000000;
const worldData = generateWorld(biome, this.worldSeed);
// 3. Create tilemap
createWorldTilemap(this, worldData);
// 4. Camera with bounds and WASD controls
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
this.cameraKeys = setupCamera(this, worldPixelW, worldPixelH);
// 5. Minimap
this.minimap = new Minimap(this, worldData);
// 6. 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);
}
update(_time: number, delta: number): void {
// 1. Update world time
updateTime(this.gameWorld, delta);
// 2. Camera movement
updateCamera(this, this.cameraKeys, delta);
// 3. ECS systems (no entities yet — future phases will add player, creatures)
movementSystem(this.gameWorld.world, delta);
const dead = healthSystem(this.gameWorld.world);
for (const eid of dead) {
removeGameEntity(this.gameWorld.world, eid);
}
this.bridge.sync(this.gameWorld.world);
// 4. Minimap viewport
this.minimap.update(this.cameras.main);
// 5. Stats
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
this.statsText.setText(
`seed: ${this.worldSeed} | ${fps} fps | WASD move, scroll zoom`,
);
}
}

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

@@ -0,0 +1,69 @@
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;
}

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

@@ -0,0 +1,67 @@
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;
}
}
// Geyser overlay: on acid-shallow + very high detail noise
if (baseTileId === gen.geyserOnTile && detail > gen.geyserThreshold) {
return findTileIdByName(biome, 'geyser');
}
// Mineral overlay: on walkable ground + high detail noise
if (gen.mineralOnTiles.includes(baseTileId) && detail > gen.mineralThreshold) {
return findTileIdByName(biome, 'mineral-vein');
}
return baseTileId;
}
/** Find tile ID by name, falling back to 0 if not found */
function findTileIdByName(biome: BiomeData, name: string): number {
const tile = biome.tiles.find(t => t.name === name);
return tile ? tile.id : 0;
}

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

@@ -0,0 +1,126 @@
import Phaser from 'phaser';
import type { TileData, TileGrid, WorldData } from './types';
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;
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 {
const screenW = camera.width;
const padding = 10;
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;
}

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

@@ -0,0 +1,102 @@
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. 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));
}

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;
}

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

@@ -0,0 +1,347 @@
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 20 elements', () => {
expect(ElementRegistry.count()).toBe(20);
});
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);
});
});
// =============================================================================
// 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);
});
});
// =============================================================================
// 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('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 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 30+ registered reactions', () => {
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(30);
});
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);
}
}
});
});

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);
});
});

183
tests/world.test.ts Normal file
View File

@@ -0,0 +1,183 @@
import { describe, it, expect } from 'vitest';
import biomeDataArray from '../src/data/biomes.json';
import { createSeededNoise, sampleNoise } from '../src/world/noise';
import { generateWorld } from '../src/world/generator';
import type { BiomeData } from '../src/world/types';
// Load the first biome — structural compatibility with BiomeData
const biome = biomeDataArray[0] as BiomeData;
// ─── Noise ──────────────────────────────────────────────────────
describe('Seeded Noise', () => {
it('is deterministic with same seed', () => {
const n1 = createSeededNoise(42);
const n2 = createSeededNoise(42);
expect(n1(0.5, 0.5)).toBe(n2(0.5, 0.5));
});
it('produces different results with different seeds', () => {
const n1 = createSeededNoise(42);
const n2 = createSeededNoise(99);
expect(n1(0.5, 0.5)).not.toBe(n2(0.5, 0.5));
});
it('normalizes to [0, 1] via sampleNoise', () => {
const noise = createSeededNoise(42);
for (let i = 0; i < 200; i++) {
const val = sampleNoise(noise, i * 0.3, i * 0.7, 0.1);
expect(val).toBeGreaterThanOrEqual(0);
expect(val).toBeLessThanOrEqual(1);
}
});
it('varies across coordinates', () => {
const noise = createSeededNoise(42);
const values = new Set<number>();
for (let i = 0; i < 50; i++) {
values.add(sampleNoise(noise, i, 0, 0.1));
}
// Should have significant variety (not all same value)
expect(values.size).toBeGreaterThan(20);
});
});
// ─── Biome Data ─────────────────────────────────────────────────
describe('Biome Data', () => {
it('has valid structure', () => {
expect(biome.id).toBe('catalytic-wastes');
expect(biome.tileSize).toBe(32);
expect(biome.mapWidth).toBe(80);
expect(biome.mapHeight).toBe(80);
});
it('has 8 tile types', () => {
expect(biome.tiles).toHaveLength(8);
});
it('tile IDs are sequential starting from 0', () => {
biome.tiles.forEach((tile, index) => {
expect(tile.id).toBe(index);
});
});
it('has both walkable and non-walkable tiles', () => {
const walkable = biome.tiles.filter(t => t.walkable);
const blocked = biome.tiles.filter(t => !t.walkable);
expect(walkable.length).toBeGreaterThan(0);
expect(blocked.length).toBeGreaterThan(0);
});
it('elevation rules cover full [0, 1] range', () => {
const rules = biome.generation.elevationRules;
expect(rules.length).toBeGreaterThan(0);
// Last rule should cover up to 1.0
expect(rules[rules.length - 1].below).toBe(1);
// Rules should be sorted ascending
for (let i = 1; i < rules.length; i++) {
expect(rules[i].below).toBeGreaterThan(rules[i - 1].below);
}
});
it('all elevation rule tileIds reference valid tiles', () => {
const validIds = new Set(biome.tiles.map(t => t.id));
for (const rule of biome.generation.elevationRules) {
expect(validIds.has(rule.tileId)).toBe(true);
}
});
});
// ─── World Generation ───────────────────────────────────────────
describe('World Generation', () => {
it('generates grid with correct dimensions', () => {
const world = generateWorld(biome, 42);
expect(world.grid).toHaveLength(biome.mapHeight);
for (const row of world.grid) {
expect(row).toHaveLength(biome.mapWidth);
}
});
it('all tile IDs in grid are valid', () => {
const world = generateWorld(biome, 42);
const validIds = new Set(biome.tiles.map(t => t.id));
for (const row of world.grid) {
for (const tileId of row) {
expect(validIds.has(tileId)).toBe(true);
}
}
});
it('is deterministic — same seed same map', () => {
const w1 = generateWorld(biome, 42);
const w2 = generateWorld(biome, 42);
expect(w1.grid).toEqual(w2.grid);
});
it('different seeds produce different maps', () => {
const w1 = generateWorld(biome, 42);
const w2 = generateWorld(biome, 99);
expect(w1.grid).not.toEqual(w2.grid);
});
it('stores seed and biome reference', () => {
const world = generateWorld(biome, 12345);
expect(world.seed).toBe(12345);
expect(world.biome).toBe(biome);
});
it('has diverse tile distribution (no single tile > 60%)', () => {
const world = generateWorld(biome, 42);
const counts = new Map<number, number>();
for (const row of world.grid) {
for (const tileId of row) {
counts.set(tileId, (counts.get(tileId) ?? 0) + 1);
}
}
const total = biome.mapWidth * biome.mapHeight;
// At least 4 different tile types present
expect(counts.size).toBeGreaterThanOrEqual(4);
// No single type dominates
for (const count of counts.values()) {
expect(count / total).toBeLessThan(0.6);
}
});
it('generates acid pools (low elevation)', () => {
const world = generateWorld(biome, 42);
const acidId = biome.tiles.find(t => t.name === 'acid-pool')?.id;
const hasAcid = world.grid.some(row => row.includes(acidId!));
expect(hasAcid).toBe(true);
});
it('generates crystal formations (high elevation)', () => {
const world = generateWorld(biome, 42);
const crystalId = biome.tiles.find(t => t.name === 'crystal')?.id;
const hasCrystals = world.grid.some(row => row.includes(crystalId!));
expect(hasCrystals).toBe(true);
});
it('generates mineral veins (overlay on ground)', () => {
const world = generateWorld(biome, 42);
const mineralId = biome.tiles.find(t => t.name === 'mineral-vein')?.id;
const hasMinerals = world.grid.some(row => row.includes(mineralId!));
expect(hasMinerals).toBe(true);
});
it('generates geysers (overlay near acid)', () => {
const world = generateWorld(biome, 42);
const geyserId = biome.tiles.find(t => t.name === 'geyser')?.id;
const hasGeysers = world.grid.some(row => row.includes(geyserId!));
expect(hasGeysers).toBe(true);
});
it('produces unique map every seed (sample 5 seeds)', () => {
const grids = [1, 2, 3, 4, 5].map(s => generateWorld(biome, s).grid);
for (let i = 0; i < grids.length; i++) {
for (let j = i + 1; j < grids.length; j++) {
expect(grids[i]).not.toEqual(grids[j]);
}
}
});
});