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:
35
src/chemistry/compounds.ts
Normal file
35
src/chemistry/compounds.ts
Normal 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
45
src/chemistry/elements.ts
Normal 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
209
src/chemistry/engine.ts
Normal 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
102
src/chemistry/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user