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:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user