phase 9: biome expansion — 3 biomes, 40 elements, 119 reactions, 9 species

Expand beyond vertical slice with two new biomes and massive chemistry expansion:

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

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

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

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

487 tests passing (32 new).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 17:27:15 +03:00
parent 3c24205e72
commit 6ba0746bb9
16 changed files with 2176 additions and 39 deletions

View File

@@ -8,8 +8,8 @@ import { ReactionEngine } from '../src/chemistry/engine';
// =============================================================================
describe('ElementRegistry', () => {
it('should load all 20 elements', () => {
expect(ElementRegistry.count()).toBe(20);
it('should load all 40 elements', () => {
expect(ElementRegistry.count()).toBe(40);
});
it('should look up elements by symbol', () => {
@@ -59,6 +59,39 @@ describe('ElementRegistry', () => {
expect(ElementRegistry.isElement('NaCl')).toBe(false);
expect(ElementRegistry.isElement('H2O')).toBe(false);
});
it('should have all 20 new Phase 9 elements', () => {
const newSymbols = ['Li', 'B', 'F', 'Ne', 'Ar', 'Ti', 'Cr', 'Mn', 'Co', 'Ni', 'As', 'Br', 'Ag', 'I', 'Ba', 'W', 'Pt', 'Pb', 'Bi', 'U'];
for (const sym of newSymbols) {
expect(ElementRegistry.has(sym), `Element ${sym} not found`).toBe(true);
}
});
it('should have correct data for new elements (real periodic table)', () => {
const li = ElementRegistry.getBySymbol('Li')!;
expect(li.atomicNumber).toBe(3);
expect(li.category).toBe('alkali-metal');
const f = ElementRegistry.getBySymbol('F')!;
expect(f.atomicNumber).toBe(9);
expect(f.category).toBe('halogen');
expect(f.state).toBe('gas');
expect(f.electronegativity).toBeCloseTo(3.98, 1); // Most electronegative
const br = ElementRegistry.getBySymbol('Br')!;
expect(br.state).toBe('liquid'); // Only liquid non-metal at room temp
const w = ElementRegistry.getBySymbol('W')!;
expect(w.atomicNumber).toBe(74);
expect(w.name).toBe('Tungsten');
const u = ElementRegistry.getBySymbol('U')!;
expect(u.atomicNumber).toBe(92);
expect(u.category).toBe('actinide');
const pt = ElementRegistry.getBySymbol('Pt')!;
expect(pt.category).toBe('transition-metal');
});
});
// =============================================================================
@@ -106,6 +139,32 @@ describe('CompoundRegistry', () => {
expect(CompoundRegistry.isCompound('H2O')).toBe(true);
expect(CompoundRegistry.isCompound('Na')).toBe(false);
});
it('should load all 64 compounds', () => {
expect(CompoundRegistry.count()).toBe(64);
});
it('should have new Phase 9 compounds', () => {
const newIds = ['NH3', 'HF', 'HBr', 'TiO2', 'MnO2', 'As2O3', 'H2SO4', 'HNO3', 'CuO', 'FeCl3', 'CaCl2', 'NH4Cl', 'C6H12O6', 'CH3COOH'];
for (const id of newIds) {
expect(CompoundRegistry.has(id), `Compound ${id} not found`).toBe(true);
}
});
it('should correctly flag new dangerous compounds', () => {
const hf = CompoundRegistry.getById('HF')!;
expect(hf.properties.acidic).toBe(true);
expect(hf.properties.corrosive).toBe(true);
expect(hf.properties.toxic).toBe(true);
const h2so4 = CompoundRegistry.getById('H2SO4')!;
expect(h2so4.properties.acidic).toBe(true);
expect(h2so4.properties.oxidizer).toBe(true);
const as2o3 = CompoundRegistry.getById('As2O3')!;
expect(as2o3.properties.toxic).toBe(true);
expect(as2o3.name).toContain('Arsenic');
});
});
// =============================================================================
@@ -204,6 +263,104 @@ describe('ReactionEngine — success', () => {
expect(result.reaction!.energyChange).toBeGreaterThan(0); // Endothermic
});
it('should produce HF from H + F', () => {
const result = ReactionEngine.react([
{ id: 'H', count: 1 },
{ id: 'F', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'HF', count: 1 }]);
});
it('should produce NH3 via Haber process (N + 3H with Fe catalyst + heat)', () => {
const result = ReactionEngine.react(
[
{ id: 'N', count: 1 },
{ id: 'H', count: 3 },
],
{ minTemp: 500, catalyst: 'Fe' },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'NH3', count: 1 }]);
});
it('should produce TiO2 from Ti + 2O with extreme heat', () => {
const result = ReactionEngine.react(
[
{ id: 'Ti', count: 1 },
{ id: 'O', count: 2 },
],
{ minTemp: 1000 },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'TiO2', count: 1 }]);
});
it('should produce tungsten via WO3 + Al redox', () => {
const result = ReactionEngine.react(
[
{ id: 'WO3', count: 1 },
{ id: 'Al', count: 2 },
],
{ minTemp: 1000 },
);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'W', count: 1 });
expect(result.products).toContainEqual({ id: 'Al2O3', count: 1 });
});
it('should neutralize HF with NaOH (acid-base)', () => {
const result = ReactionEngine.react([
{ id: 'NaOH', count: 1 },
{ id: 'HF', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'NaF', count: 1 });
expect(result.products).toContainEqual({ id: 'H2O', count: 1 });
});
it('should dissolve Zn in HCl (single-replacement)', () => {
const result = ReactionEngine.react([
{ id: 'Zn', count: 1 },
{ id: 'HCl', count: 2 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'ZnCl2', count: 1 });
expect(result.products).toContainEqual({ id: 'H', count: 2 });
});
it('should displace Cu with Fe from CuCl2', () => {
const result = ReactionEngine.react([
{ id: 'Fe', count: 1 },
{ id: 'CuCl2', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'Cu', count: 1 });
expect(result.products).toContainEqual({ id: 'FeCl2', count: 1 });
});
it('should ferment glucose into ethanol + CO2', () => {
const result = ReactionEngine.react([
{ id: 'C6H12O6', count: 1 },
]);
expect(result.success).toBe(true);
expect(result.products).toContainEqual({ id: 'C2H5OH', count: 2 });
expect(result.products).toContainEqual({ id: 'CO2', count: 2 });
});
it('should produce H2SO4 via Contact process (Pt catalyst)', () => {
const result = ReactionEngine.react(
[
{ id: 'S', count: 1 },
{ id: 'O', count: 3 },
{ id: 'H', count: 2 },
],
{ catalyst: 'Pt' },
);
expect(result.success).toBe(true);
expect(result.products).toEqual([{ id: 'H2SO4', count: 1 }]);
});
it('reactant order should not matter (key is sorted)', () => {
const r1 = ReactionEngine.react([
{ id: 'Cl', count: 1 },
@@ -234,6 +391,16 @@ describe('ReactionEngine — failures', () => {
expect(result.failureReasonRu).toContain('благородный газ');
});
it('should reject reactions with new noble gases (Ne, Ar)', () => {
const ne = ReactionEngine.react([{ id: 'Ne', count: 1 }, { id: 'F', count: 1 }]);
expect(ne.success).toBe(false);
expect(ne.failureReason).toContain('noble gas');
const ar = ReactionEngine.react([{ id: 'Ar', count: 1 }, { id: 'Cl', count: 1 }]);
expect(ar.success).toBe(false);
expect(ar.failureReason).toContain('noble gas');
});
it('should reject gold reactions with explanation', () => {
const result = ReactionEngine.react([
{ id: 'Au', count: 1 },
@@ -309,8 +476,8 @@ describe('ReactionEngine — failures', () => {
// =============================================================================
describe('ReactionEngine — metadata', () => {
it('should have 30+ registered reactions', () => {
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(30);
it('should have 100+ registered reactions', () => {
expect(ReactionEngine.count()).toBeGreaterThanOrEqual(100);
});
it('should look up reactions by id', () => {

View File

@@ -71,8 +71,8 @@ function createPlayerEntity(world: World, x: number, y: number): number {
// ─── Species Data ────────────────────────────────────────────────
describe('Species Data', () => {
it('loads 3 species from JSON', () => {
expect(allSpecies).toHaveLength(3);
it('loads 9 species from JSON (3 per biome)', () => {
expect(allSpecies).toHaveLength(9);
});
it('has Crystallid with correct properties', () => {
@@ -128,7 +128,7 @@ describe('Species Registry', () => {
});
it('has correct count', () => {
expect(registry.count).toBe(3);
expect(registry.count).toBe(9);
});
it('looks up by string ID', () => {
@@ -148,7 +148,27 @@ describe('Species Registry', () => {
it('returns all species', () => {
const all = registry.getAll();
expect(all).toHaveLength(3);
expect(all).toHaveLength(9);
});
it('can look up new Phase 9 species', () => {
expect(registry.get('pendulum')?.biome).toBe('kinetic-mountains');
expect(registry.get('mechanoid')?.biome).toBe('kinetic-mountains');
expect(registry.get('resonator')?.biome).toBe('kinetic-mountains');
expect(registry.get('symbiote')?.biome).toBe('verdant-forests');
expect(registry.get('mimic')?.biome).toBe('verdant-forests');
expect(registry.get('spore-bearer')?.biome).toBe('verdant-forests');
});
it('each biome has exactly 3 species', () => {
const all = registry.getAll();
const byBiome = new Map<string, number>();
for (const s of all) {
byBiome.set(s.biome, (byBiome.get(s.biome) ?? 0) + 1);
}
expect(byBiome.get('catalytic-wastes')).toBe(3);
expect(byBiome.get('kinetic-mountains')).toBe(3);
expect(byBiome.get('verdant-forests')).toBe(3);
});
});

View File

@@ -4,8 +4,9 @@ import { createSeededNoise, sampleNoise } from '../src/world/noise';
import { generateWorld } from '../src/world/generator';
import type { BiomeData } from '../src/world/types';
const allBiomes = biomeDataArray as BiomeData[];
// Load the first biome — structural compatibility with BiomeData
const biome = biomeDataArray[0] as BiomeData;
const biome = allBiomes[0];
// ─── Noise ──────────────────────────────────────────────────────
@@ -181,3 +182,139 @@ describe('World Generation', () => {
}
});
});
// ─── Multi-Biome Support (Phase 9) ──────────────────────────────
describe('Multi-Biome Data', () => {
it('has 3 biomes loaded', () => {
expect(allBiomes).toHaveLength(3);
});
it('each biome has a unique id', () => {
const ids = allBiomes.map(b => b.id);
expect(new Set(ids).size).toBe(3);
expect(ids).toContain('catalytic-wastes');
expect(ids).toContain('kinetic-mountains');
expect(ids).toContain('verdant-forests');
});
it('each biome has 8 tile types with sequential IDs', () => {
for (const b of allBiomes) {
expect(b.tiles).toHaveLength(8);
b.tiles.forEach((tile, index) => {
expect(tile.id, `${b.id}: tile ${index}`).toBe(index);
});
}
});
it('each biome has an interactive tile and a resource tile', () => {
for (const b of allBiomes) {
const interactive = b.tiles.find(t => t.interactive);
const resource = b.tiles.find(t => t.resource);
expect(interactive, `${b.id}: no interactive tile`).toBeDefined();
expect(resource, `${b.id}: no resource tile`).toBeDefined();
}
});
it('each biome has valid elevation rules covering [0, 1]', () => {
for (const b of allBiomes) {
const rules = b.generation.elevationRules;
expect(rules.length).toBeGreaterThan(0);
expect(rules[rules.length - 1].below).toBe(1);
const validIds = new Set(b.tiles.map(t => t.id));
for (const rule of rules) {
expect(validIds.has(rule.tileId), `${b.id}: invalid tileId ${rule.tileId}`).toBe(true);
}
}
});
});
describe('Kinetic Mountains Generation', () => {
const mtns = allBiomes.find(b => b.id === 'kinetic-mountains')!;
it('generates correct-size grid', () => {
const world = generateWorld(mtns, 42);
expect(world.grid).toHaveLength(mtns.mapHeight);
expect(world.grid[0]).toHaveLength(mtns.mapWidth);
});
it('all tile IDs are valid', () => {
const world = generateWorld(mtns, 42);
const validIds = new Set(mtns.tiles.map(t => t.id));
for (const row of world.grid) {
for (const tileId of row) {
expect(validIds.has(tileId)).toBe(true);
}
}
});
it('has diverse tiles with no single type > 60%', () => {
const world = generateWorld(mtns, 42);
const counts = new Map<number, number>();
for (const row of world.grid) {
for (const t of row) counts.set(t, (counts.get(t) ?? 0) + 1);
}
const total = mtns.mapWidth * mtns.mapHeight;
expect(counts.size).toBeGreaterThanOrEqual(4);
for (const count of counts.values()) {
expect(count / total).toBeLessThan(0.6);
}
});
it('generates chasms (low elevation)', () => {
const world = generateWorld(mtns, 42);
const chasmId = mtns.tiles.find(t => t.name === 'chasm')?.id;
expect(world.grid.some(row => row.includes(chasmId!))).toBe(true);
});
it('generates ore deposits (resources)', () => {
const world = generateWorld(mtns, 42);
const oreId = mtns.tiles.find(t => t.resource)?.id;
expect(world.grid.some(row => row.includes(oreId!))).toBe(true);
});
});
describe('Verdant Forests Generation', () => {
const forest = allBiomes.find(b => b.id === 'verdant-forests')!;
it('generates correct-size grid', () => {
const world = generateWorld(forest, 42);
expect(world.grid).toHaveLength(forest.mapHeight);
expect(world.grid[0]).toHaveLength(forest.mapWidth);
});
it('all tile IDs are valid', () => {
const world = generateWorld(forest, 42);
const validIds = new Set(forest.tiles.map(t => t.id));
for (const row of world.grid) {
for (const tileId of row) {
expect(validIds.has(tileId)).toBe(true);
}
}
});
it('has diverse tiles with no single type > 60%', () => {
const world = generateWorld(forest, 42);
const counts = new Map<number, number>();
for (const row of world.grid) {
for (const t of row) counts.set(t, (counts.get(t) ?? 0) + 1);
}
const total = forest.mapWidth * forest.mapHeight;
expect(counts.size).toBeGreaterThanOrEqual(4);
for (const count of counts.values()) {
expect(count / total).toBeLessThan(0.6);
}
});
it('generates bogs (low elevation)', () => {
const world = generateWorld(forest, 42);
const bogId = forest.tiles.find(t => t.name === 'bog')?.id;
expect(world.grid.some(row => row.includes(bogId!))).toBe(true);
});
it('generates herb patches (resources)', () => {
const world = generateWorld(forest, 42);
const herbId = forest.tiles.find(t => t.resource)?.id;
expect(world.grid.some(row => row.includes(herbId!))).toBe(true);
});
});