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