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:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user