feat(systems): add craftingSystem with input consumption and output production
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { World } from '../../ecs/World.js';
|
||||
import { craftingSystem, type CraftingState } from '../craftingSystem.js';
|
||||
import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared';
|
||||
import { PRODUCTIVITY_RECOVERY_RATE } from '@dflike/shared';
|
||||
import { RecipeRegistry } from '../../industry/recipeRegistry.js';
|
||||
|
||||
function createCraftingWorld(): World {
|
||||
const world = new World();
|
||||
world.setSingleton('recipeRegistry', RecipeRegistry.createDefault());
|
||||
return world;
|
||||
}
|
||||
|
||||
function createCraftingNPC(world: World, overrides?: { dexterity?: number; productivity?: number }) {
|
||||
const e = world.createEntity();
|
||||
world.addComponent<Position>(e, 'position', { x: 5, y: 5 });
|
||||
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80, productivity: overrides?.productivity ?? 30 });
|
||||
world.addComponent<Movement>(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 });
|
||||
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: 'craft', goalQueue: [] });
|
||||
world.addComponent<Map<string, number>>(e, 'inventory', new Map([['log', 5], ['stone', 3]]));
|
||||
const dex = overrides?.dexterity ?? 10;
|
||||
world.addComponent<Stats>(e, 'stats', {
|
||||
strength: 10, dexterity: dex, constitution: 10, intelligence: 10, perception: 10,
|
||||
sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10,
|
||||
});
|
||||
world.addComponent<StatModifiers>(e, 'statModifiers', { modifiers: [] });
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('craftingSystem', () => {
|
||||
it('decrements timer each tick while crafting', () => {
|
||||
const world = createCraftingWorld();
|
||||
const e = createCraftingNPC(world);
|
||||
world.addComponent<CraftingState>(e, 'craftingState', {
|
||||
recipeId: 'craft_wooden_axe',
|
||||
ticksRemaining: 40,
|
||||
});
|
||||
craftingSystem(world);
|
||||
const cs = world.getComponent<CraftingState>(e, 'craftingState')!;
|
||||
expect(cs.ticksRemaining).toBe(39);
|
||||
});
|
||||
|
||||
it('consumes inputs and produces output on completion', () => {
|
||||
const world = createCraftingWorld();
|
||||
const e = createCraftingNPC(world);
|
||||
world.addComponent<CraftingState>(e, 'craftingState', {
|
||||
recipeId: 'craft_wooden_axe',
|
||||
ticksRemaining: 1,
|
||||
});
|
||||
craftingSystem(world);
|
||||
const inv = world.getComponent<Map<string, number>>(e, 'inventory')!;
|
||||
expect(inv.get('wooden_axe')).toBe(1);
|
||||
expect(inv.get('log')).toBe(3); // 5 - 2
|
||||
expect(inv.get('stone')).toBe(2); // 3 - 1
|
||||
});
|
||||
|
||||
it('clears craftingState on completion', () => {
|
||||
const world = createCraftingWorld();
|
||||
const e = createCraftingNPC(world);
|
||||
world.addComponent<CraftingState>(e, 'craftingState', {
|
||||
recipeId: 'craft_wooden_axe',
|
||||
ticksRemaining: 1,
|
||||
});
|
||||
craftingSystem(world);
|
||||
expect(world.getComponent<CraftingState>(e, 'craftingState')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recovers productivity while crafting', () => {
|
||||
const world = createCraftingWorld();
|
||||
const e = createCraftingNPC(world, { productivity: 30 });
|
||||
world.addComponent<CraftingState>(e, 'craftingState', {
|
||||
recipeId: 'craft_wooden_axe',
|
||||
ticksRemaining: 10,
|
||||
});
|
||||
craftingSystem(world);
|
||||
const needs = world.getComponent<Needs>(e, 'needs')!;
|
||||
expect(needs.productivity).toBeCloseTo(30 + PRODUCTIVITY_RECOVERY_RATE);
|
||||
});
|
||||
|
||||
it('skips NPC whose goal is not craft', () => {
|
||||
const world = createCraftingWorld();
|
||||
const e = createCraftingNPC(world);
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
brain.currentGoal = 'wander';
|
||||
world.addComponent<CraftingState>(e, 'craftingState', {
|
||||
recipeId: 'craft_wooden_axe',
|
||||
ticksRemaining: 10,
|
||||
});
|
||||
craftingSystem(world);
|
||||
const cs = world.getComponent<CraftingState>(e, 'craftingState')!;
|
||||
expect(cs.ticksRemaining).toBe(10);
|
||||
});
|
||||
|
||||
it('resets goal to wander when productivity satisfied after crafting', () => {
|
||||
const world = createCraftingWorld();
|
||||
const e = createCraftingNPC(world, { productivity: 70 });
|
||||
world.addComponent<CraftingState>(e, 'craftingState', {
|
||||
recipeId: 'craft_wooden_axe',
|
||||
ticksRemaining: 1,
|
||||
});
|
||||
craftingSystem(world);
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('wander');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
PRODUCTIVITY_RECOVERY_RATE, PRODUCTIVITY_THRESHOLD,
|
||||
type Needs, type NPCBrain,
|
||||
} from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
import { addItem, removeItem } from '../industry/inventoryHelpers.js';
|
||||
import { RecipeRegistry } from '../industry/recipeRegistry.js';
|
||||
|
||||
export interface CraftingState {
|
||||
recipeId: string;
|
||||
ticksRemaining: number;
|
||||
}
|
||||
|
||||
export function craftingSystem(world: World): void {
|
||||
for (const entity of world.query('npcBrain', 'needs', 'inventory')) {
|
||||
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
|
||||
if (brain.currentGoal !== 'craft') continue;
|
||||
|
||||
const cs = world.getComponent<CraftingState>(entity, 'craftingState');
|
||||
if (!cs) continue;
|
||||
|
||||
const needs = world.getComponent<Needs>(entity, 'needs')!;
|
||||
const inv = world.getComponent<Map<string, number>>(entity, 'inventory')!;
|
||||
|
||||
cs.ticksRemaining--;
|
||||
needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE);
|
||||
|
||||
if (cs.ticksRemaining <= 0) {
|
||||
const registry = world.getSingleton<RecipeRegistry>('recipeRegistry');
|
||||
const recipe = registry?.get(cs.recipeId);
|
||||
if (recipe) {
|
||||
for (const input of recipe.inputs) {
|
||||
removeItem(inv, input.itemId, input.quantity);
|
||||
}
|
||||
addItem(inv, recipe.outputItemId, recipe.outputQuantity);
|
||||
}
|
||||
world.removeComponent(entity, 'craftingState');
|
||||
|
||||
if (needs.productivity >= PRODUCTIVITY_THRESHOLD) {
|
||||
brain.currentGoal = 'wander';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user