feat(systems): add industrySystem with craft/build/gather decision tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 23:25:39 +00:00
parent 9247a8c6e4
commit e0e5c6615e
2 changed files with 240 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
import {
PRODUCTIVITY_THRESHOLD,
type Needs, type NPCBrain, type Position,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import type { GatheringState } from './gatheringSystem.js';
import type { CraftingState } from './craftingSystem.js';
import type { BuildingState, StructureData } from './buildingSystem.js';
import { RecipeRegistry } from '../industry/recipeRegistry.js';
import { getEffectiveStat } from './statHelpers.js';
import { industryConfig } from '../config/industryConfig.js';
const BUILD_RECIPE_MAP: Record<string, { type: 'stockpile' | 'workshop'; subtype: string }> = {
stockpile: { type: 'stockpile', subtype: 'general' },
workbench: { type: 'workshop', subtype: 'workbench' },
};
function structureExists(world: World, structureType: string, subtype: string): boolean {
for (const entity of world.query('structure')) {
const s = world.getComponent<StructureData>(entity, 'structure')!;
if (s.type === structureType && s.subtype === subtype && s.buildProgress === undefined) {
return true;
}
}
return false;
}
function findNearbyWalkable(map: GameMap, from: Position): Position | null {
for (let r = 1; r <= 3; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
const x = from.x + dx;
const y = from.y + dy;
if (map.isWalkable(x, y)) return { x, y };
}
}
}
return null;
}
export function industrySystem(world: World, map: GameMap): void {
const registry = world.getSingleton<RecipeRegistry>('recipeRegistry');
if (!registry) return;
for (const entity of world.query('npcBrain', 'needs', 'inventory', 'position')) {
const needs = world.getComponent<Needs>(entity, 'needs')!;
if (needs.productivity >= PRODUCTIVITY_THRESHOLD) continue;
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
// Skip if already actively doing something productive
if (brain.currentGoal === 'craft' && world.getComponent<CraftingState>(entity, 'craftingState')) continue;
if (brain.currentGoal === 'build' && world.getComponent<BuildingState>(entity, 'buildingState')) continue;
if (brain.currentGoal === 'gather' && world.getComponent<GatheringState>(entity, 'gatheringState')) continue;
if (brain.currentGoal === 'gather' || brain.currentGoal === 'eat' || brain.currentGoal === 'rest') continue;
if (brain.currentGoal === 'dropoff') continue;
const inv = world.getComponent<Map<string, number>>(entity, 'inventory')!;
const pos = world.getComponent<Position>(entity, 'position')!;
// Try craft first
const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_'));
if (craftRecipes.length > 0) {
const recipe = craftRecipes[0];
const dex = getEffectiveStat(world, entity, 'dexterity');
const baseTicks = industryConfig.craftBaseTicks;
const ticks = Math.max(1, Math.round(baseTicks * (1 - (dex - 10) * industryConfig.craftDexterityModifier)));
brain.currentGoal = 'craft';
world.addComponent<CraftingState>(entity, 'craftingState', {
recipeId: recipe.id,
ticksRemaining: ticks,
});
continue;
}
// Try build
const buildRecipes = registry.findCraftable(inv).filter(r => r.id.startsWith('build_'));
let didBuild = false;
for (const recipe of buildRecipes) {
const mapping = BUILD_RECIPE_MAP[recipe.outputItemId];
if (!mapping) continue;
if (structureExists(world, mapping.type, mapping.subtype)) continue;
const buildPos = findNearbyWalkable(map, pos);
if (!buildPos) continue;
// Consume inputs
for (const input of recipe.inputs) {
const current = inv.get(input.itemId) ?? 0;
const remaining = current - input.quantity;
if (remaining <= 0) inv.delete(input.itemId);
else inv.set(input.itemId, remaining);
}
const structureEntity = world.createEntity();
world.addComponent<Position>(structureEntity, 'position', buildPos);
world.addComponent<StructureData>(structureEntity, 'structure', {
type: mapping.type,
subtype: mapping.subtype,
inventory: new Map(),
buildProgress: 0,
builderEntityId: entity,
});
brain.currentGoal = 'build';
world.addComponent<BuildingState>(entity, 'buildingState', {
structureEntityId: structureEntity,
recipeId: recipe.id,
});
didBuild = true;
break;
}
if (didBuild) continue;
// Fallback: gather
brain.currentGoal = 'gather';
}
}