diff --git a/src/player/crafting.ts b/src/player/crafting.ts new file mode 100644 index 0000000..e49feb3 --- /dev/null +++ b/src/player/crafting.ts @@ -0,0 +1,102 @@ +/** + * Crafting System — Chemistry Engine Integration + * + * Combines items from inventory using the reaction engine. + * On success: consumes reagents, adds products. + * On failure: returns educational explanation of why it didn't work. + */ + +import { ReactionEngine } from '../chemistry/engine'; +import type { ReactionConditions, ReactionData } from '../chemistry/types'; +import type { Inventory } from './inventory'; + +/** What the player wants to combine */ +export interface CraftInput { + id: string; // element symbol or compound id + count: number; // quantity to use +} + +/** Result of a crafting attempt */ +export interface CraftResult { + success: boolean; + /** Products added to inventory (count may differ from reaction output if inventory full) */ + products?: Array<{ id: string; count: number }>; + /** What was consumed from inventory */ + consumedReactants?: CraftInput[]; + /** Reaction data (for codex, description display) */ + reaction?: ReactionData; + /** Why it failed (English) */ + failureReason?: string; + /** Why it failed (Russian) */ + failureReasonRu?: string; +} + +/** + * Attempt to craft from inventory items. + * + * 1. Checks inventory has all reagents + * 2. Runs reaction engine (O(1) lookup + condition check) + * 3. On success: consumes reagents, adds products + * 4. On failure: returns educational reason + * + * Reagents are only consumed on success — failed attempts are free. + */ +export function craftFromInventory( + inventory: Inventory, + reactants: CraftInput[], + conditions?: Partial, +): CraftResult { + // Empty input + if (reactants.length === 0) { + return { + success: false, + failureReason: 'Select at least one substance to combine.', + failureReasonRu: 'Выберите хотя бы одно вещество для комбинирования.', + }; + } + + // 1. Check inventory has all reagents + for (const r of reactants) { + const have = inventory.getCount(r.id); + if (have < r.count) { + return { + success: false, + failureReason: `Not enough ${r.id} — have ${have}, need ${r.count}.`, + failureReasonRu: `Недостаточно ${r.id} — есть ${have}, нужно ${r.count}.`, + }; + } + } + + // 2. Run reaction engine + const result = ReactionEngine.react( + reactants.map(r => ({ id: r.id, count: r.count })), + conditions, + ); + + if (!result.success) { + return { + success: false, + failureReason: result.failureReason, + failureReasonRu: result.failureReasonRu, + }; + } + + // 3. Consume reagents + for (const r of reactants) { + inventory.removeItem(r.id, r.count); + } + + // 4. Add products to inventory + const addedProducts: Array<{ id: string; count: number }> = []; + for (const p of result.products!) { + const added = inventory.addItem(p.id, p.count); + addedProducts.push({ id: p.id, count: added }); + } + + return { + success: true, + products: addedProducts, + consumedReactants: reactants, + reaction: result.reaction, + }; +} diff --git a/tests/crafting.test.ts b/tests/crafting.test.ts new file mode 100644 index 0000000..db4076e --- /dev/null +++ b/tests/crafting.test.ts @@ -0,0 +1,189 @@ +/** + * Crafting System Tests — Phase 4.4 + * + * Tests: crafting from inventory, reagent consumption, + * product addition, failure reasons, condition checks. + */ + +import { describe, it, expect } from 'vitest'; +import { Inventory } from '../src/player/inventory'; +import { craftFromInventory, type CraftInput } from '../src/player/crafting'; + +describe('craftFromInventory — success', () => { + it('crafts NaCl from Na + Cl', () => { + const inv = new Inventory(); + inv.addItem('Na', 3); + inv.addItem('Cl', 2); + + const inputs: CraftInput[] = [ + { id: 'Na', count: 1 }, + { id: 'Cl', count: 1 }, + ]; + const result = craftFromInventory(inv, inputs); + + expect(result.success).toBe(true); + expect(result.products).toContainEqual({ id: 'NaCl', count: 1 }); + // Reagents consumed + expect(inv.getCount('Na')).toBe(2); + expect(inv.getCount('Cl')).toBe(1); + // Product added + expect(inv.getCount('NaCl')).toBe(1); + }); + + it('returns reaction data on success', () => { + const inv = new Inventory(); + inv.addItem('Na', 1); + inv.addItem('Cl', 1); + + const result = craftFromInventory(inv, [ + { id: 'Na', count: 1 }, + { id: 'Cl', count: 1 }, + ]); + + expect(result.success).toBe(true); + expect(result.reaction).toBeDefined(); + expect(result.reaction!.id).toBe('synth_nacl'); + }); + + it('reports consumed reactants', () => { + const inv = new Inventory(); + inv.addItem('Na', 5); + inv.addItem('Cl', 5); + + const inputs: CraftInput[] = [ + { id: 'Na', count: 1 }, + { id: 'Cl', count: 1 }, + ]; + const result = craftFromInventory(inv, inputs); + + expect(result.consumedReactants).toEqual(inputs); + }); +}); + +describe('craftFromInventory — insufficient materials', () => { + it('fails when missing reagent', () => { + const inv = new Inventory(); + inv.addItem('Na', 1); + // No Cl in inventory + + const result = craftFromInventory(inv, [ + { id: 'Na', count: 1 }, + { id: 'Cl', count: 1 }, + ]); + + expect(result.success).toBe(false); + expect(result.failureReason).toContain('Cl'); + // Na not consumed + expect(inv.getCount('Na')).toBe(1); + }); + + it('fails when not enough of a reagent', () => { + const inv = new Inventory(); + inv.addItem('Na', 1); + inv.addItem('Cl', 1); + + const result = craftFromInventory(inv, [ + { id: 'Na', count: 5 }, // need 5, have 1 + { id: 'Cl', count: 1 }, + ]); + + expect(result.success).toBe(false); + expect(result.failureReason).toContain('Na'); + }); +}); + +describe('craftFromInventory — unknown reaction', () => { + it('fails with educational reason for no known reaction', () => { + const inv = new Inventory(5000); // large capacity for heavy elements + inv.addItem('Fe', 5); + inv.addItem('Au', 5); + + const result = craftFromInventory(inv, [ + { id: 'Fe', count: 1 }, + { id: 'Au', count: 1 }, + ]); + + expect(result.success).toBe(false); + expect(result.failureReason).toBeDefined(); + expect(result.failureReason!.length).toBeGreaterThan(10); // educational reason + // Reagents not consumed + expect(inv.getCount('Fe')).toBe(5); + expect(inv.getCount('Au')).toBe(5); + }); + + it('explains noble gas inertness', () => { + const inv = new Inventory(); + inv.addItem('He', 5); + inv.addItem('O', 5); + + const result = craftFromInventory(inv, [ + { id: 'He', count: 1 }, + { id: 'O', count: 1 }, + ]); + + expect(result.success).toBe(false); + expect(result.failureReason).toContain('noble gas'); + }); +}); + +describe('craftFromInventory — conditions', () => { + it('fails when temperature requirement not met', () => { + const inv = new Inventory(); + inv.addItem('Fe', 5); + inv.addItem('S', 5); + + // Fe + S → FeS requires minTemp: 500 + const result = craftFromInventory(inv, [ + { id: 'Fe', count: 1 }, + { id: 'S', count: 1 }, + ]); + + expect(result.success).toBe(false); + expect(result.failureReason).toContain('temperature'); + // Reagents not consumed + expect(inv.getCount('Fe')).toBe(5); + }); + + it('succeeds when conditions are met', () => { + const inv = new Inventory(); + inv.addItem('Fe', 5); + inv.addItem('S', 5); + + const result = craftFromInventory(inv, [ + { id: 'Fe', count: 1 }, + { id: 'S', count: 1 }, + ], { minTemp: 500 }); + + expect(result.success).toBe(true); + expect(result.products).toContainEqual({ id: 'FeS', count: 1 }); + expect(inv.getCount('Fe')).toBe(4); + expect(inv.getCount('S')).toBe(4); + }); +}); + +describe('craftFromInventory — edge cases', () => { + it('handles empty reactants list', () => { + const inv = new Inventory(); + inv.addItem('Na', 5); + + const result = craftFromInventory(inv, []); + + expect(result.success).toBe(false); + }); + + it('handles inventory full for products', () => { + const inv = new Inventory(100, 2); // Only 2 slots + inv.addItem('Na', 1); + inv.addItem('Cl', 1); + + // After crafting: Na consumed (slot freed), Cl consumed (slot freed), NaCl added + // This should work since slots are freed before products are added + const result = craftFromInventory(inv, [ + { id: 'Na', count: 1 }, + { id: 'Cl', count: 1 }, + ]); + + expect(result.success).toBe(true); + expect(inv.getCount('NaCl')).toBe(1); + }); +});