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>
This commit is contained in:
Денис Шкабатур
2026-02-12 12:16:08 +03:00
parent 10bd67c951
commit 7aabb8b4fc
11 changed files with 1550 additions and 11 deletions

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