feat: crafting system with chemistry engine integration (Phase 4.4)
craftFromInventory checks reagent availability, runs ReactionEngine, consumes reagents on success, adds products. Failed attempts return educational reasons (noble gas inertness, missing conditions, etc.). 11 new tests (185 total). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
102
src/player/crafting.ts
Normal file
102
src/player/crafting.ts
Normal file
@@ -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<ReactionConditions>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
189
tests/crafting.test.ts
Normal file
189
tests/crafting.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user