feat: Phase A — harvestable map + gathering system

Adds resource generation (water ponds, stone deposits, trees), NPC gathering
behavior driven by a new productivity need, inventory system, and client-side
stone tile rendering. 304 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 22:54:51 +00:00
27 changed files with 847 additions and 21 deletions
+27 -1
View File
@@ -403,7 +403,7 @@ export class GameScene extends Phaser.Scene {
// Water layer: watergrass edges on GRASS tiles, inner corners or fill on WATER tiles
if (t === Terrain.WATER) {
waterRow.push(pickWaterInnerCorner(x, y));
} else if (t === Terrain.GRASS) {
} else if (t === Terrain.GRASS || t === Terrain.STONE) {
waterRow.push(pickWaterEdge(x, y));
} else {
waterRow.push(-1);
@@ -510,6 +510,32 @@ export class GameScene extends Phaser.Scene {
TILE_SIZE - 16,
);
}
// Stone deposit markers
const stoneGraphics = this.add.graphics();
stoneGraphics.setDepth(-6);
for (let y = 0; y < worldHeight; y++) {
for (let x = 0; x < worldWidth; x++) {
if (terrain[y * worldWidth + x] === Terrain.STONE) {
// Gray stone tile with slight variation
stoneGraphics.fillStyle(0x888888, 0.8);
stoneGraphics.fillRect(
x * TILE_SIZE,
y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
);
// Darker border for definition
stoneGraphics.lineStyle(1, 0x666666, 0.6);
stoneGraphics.strokeRect(
x * TILE_SIZE + 1,
y * TILE_SIZE + 1,
TILE_SIZE - 2,
TILE_SIZE - 2,
);
}
}
}
}
private async spawnEntities(entities: EntityState[]): Promise<void> {
@@ -35,7 +35,7 @@ This system is split into four independent phases. Each phase is self-contained
**Goal:** NPCs walk to resource tiles, gather materials, and carry them in inventory. A new `productivity` need motivates this behavior.
**Status:** NOT STARTED
**Status:** COMPLETE
### A.1 Map Generation — Resource Tiles
+12
View File
@@ -0,0 +1,12 @@
export const industryConfig = {
// Gathering
gatherBaseTicks: 30,
gatherStrengthModifier: 0.03, // per point from 10
gatherYield: 1,
// Map generation
waterPondCount: { min: 2, max: 4 },
waterPondSize: { min: 3, max: 6 },
stoneClusterCount: { min: 3, max: 5 },
stoneClusterSize: { min: 2, max: 4 },
} as const;
+7
View File
@@ -8,7 +8,9 @@ import { movementSystem } from '../systems/movementSystem.js';
import { socialSystem } from '../systems/socialSystem.js';
import { statModifierSystem } from '../systems/statModifierSystem.js';
import { relationshipSystem } from '../systems/relationshipSystem.js';
import { gatheringSystem } from '../systems/gatheringSystem.js';
import { createBondRegistry } from '../systems/bondRegistry.js';
import { ItemRegistry } from '../industry/itemRegistry.js';
import { spawnNPC } from './spawner.js';
import { createLlmService, type LlmService } from '../llm/llmService.js';
import { generateBackstory } from '../llm/backstoryGenerator.js';
@@ -33,6 +35,7 @@ export class GameLoop {
constructor() {
this.world = new World();
this.world.setSingleton('bondRegistry', createBondRegistry());
this.world.setSingleton('itemRegistry', ItemRegistry.createDefault());
this.map = new GameMap();
this.llmService = createLlmService();
this.narrationService = createNarrationService(this.llmService);
@@ -51,6 +54,9 @@ export class GameLoop {
this.map.trunkDecorations = generated.trunkDecorations;
this.map.loadObstacles(generated.obstacles);
// Load resource tiles
this.map.resourceTiles = generated.resourceTiles;
// Points of interest from generator
for (const pos of generated.foodPositions) {
this.map.addPointOfInterest({ type: 'food', position: pos });
@@ -98,6 +104,7 @@ export class GameLoop {
socialSystem(this.world, this.eventMemoryService);
narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService);
relationshipSystem(this.world, this.eventMemoryService);
gatheringSystem(this.world, this.map);
movementSystem(this.world);
this.thoughtSystem.update(this.world, this.followedEntityIds, this.tick);
+20 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { World } from '../../ecs/World.js';
import { GameMap } from '../../map/GameMap.js';
import { spawnNPC } from '../spawner.js';
import type { Position, Stats } from '@dflike/shared';
import type { Position, Stats, Needs } from '@dflike/shared';
describe('spawnNPC', () => {
it('spawns at random position when no hint given', () => {
@@ -49,6 +49,25 @@ describe('spawnNPC', () => {
expect(world.getComponent(entity, 'backstory')).toBe('');
});
it('spawned NPC has productivity need', () => {
const world = new World();
const map = new GameMap();
const entity = spawnNPC(world, map);
const needs = world.getComponent<Needs>(entity, 'needs')!;
expect(needs.productivity).toBeDefined();
expect(needs.productivity).toBeGreaterThanOrEqual(60);
});
it('spawned NPC has empty inventory', () => {
const world = new World();
const map = new GameMap();
const entity = spawnNPC(world, map);
const inventory = world.getComponent<Map<string, number>>(entity, 'inventory')!;
expect(inventory).toBeDefined();
expect(inventory).toBeInstanceOf(Map);
expect(inventory.size).toBe(0);
});
it('generates stats in 3-18 range', () => {
const world = new World();
const map = new GameMap();
+2
View File
@@ -16,6 +16,7 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position, ev
world.addComponent<Needs>(entity, 'needs', {
hunger: 40 + Math.random() * 40, // 40-80
energy: 40 + Math.random() * 40,
productivity: 60 + Math.random() * 40, // 60-100
});
world.addComponent<Movement>(entity, 'movement', {
state: 'idle',
@@ -46,6 +47,7 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position, ev
world.addComponent<StatModifiers>(entity, 'statModifiers', { modifiers: [] });
world.addComponent<Relationships>(entity, 'relationships', new Map());
world.addComponent<string>(entity, 'backstory', '');
world.addComponent<Map<string, number>>(entity, 'inventory', new Map());
eventMemoryService?.record(entity, {
type: 'spawned',
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { addItem, removeItem, hasItem, getItemCount } from '../inventoryHelpers.js';
describe('inventoryHelpers', () => {
it('addItem adds to empty inventory', () => {
const inv = new Map<string, number>();
addItem(inv, 'log', 1);
expect(inv.get('log')).toBe(1);
});
it('addItem stacks quantities', () => {
const inv = new Map<string, number>();
addItem(inv, 'log', 2);
addItem(inv, 'log', 3);
expect(inv.get('log')).toBe(5);
});
it('removeItem subtracts quantity', () => {
const inv = new Map<string, number>();
addItem(inv, 'log', 5);
const removed = removeItem(inv, 'log', 3);
expect(removed).toBe(true);
expect(inv.get('log')).toBe(2);
});
it('removeItem removes entry when quantity reaches 0', () => {
const inv = new Map<string, number>();
addItem(inv, 'log', 3);
removeItem(inv, 'log', 3);
expect(inv.has('log')).toBe(false);
});
it('removeItem returns false when insufficient', () => {
const inv = new Map<string, number>();
addItem(inv, 'log', 1);
expect(removeItem(inv, 'log', 5)).toBe(false);
expect(inv.get('log')).toBe(1);
});
it('removeItem returns false for missing item', () => {
const inv = new Map<string, number>();
expect(removeItem(inv, 'log', 1)).toBe(false);
});
it('hasItem checks quantity', () => {
const inv = new Map<string, number>();
addItem(inv, 'log', 3);
expect(hasItem(inv, 'log', 2)).toBe(true);
expect(hasItem(inv, 'log', 3)).toBe(true);
expect(hasItem(inv, 'log', 4)).toBe(false);
});
it('hasItem defaults to checking for 1', () => {
const inv = new Map<string, number>();
expect(hasItem(inv, 'log')).toBe(false);
addItem(inv, 'log', 1);
expect(hasItem(inv, 'log')).toBe(true);
});
it('getItemCount returns 0 for missing item', () => {
const inv = new Map<string, number>();
expect(getItemCount(inv, 'log')).toBe(0);
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { ItemRegistry, type ItemDefinition } from '../itemRegistry.js';
describe('ItemRegistry', () => {
it('registers and retrieves an item', () => {
const registry = new ItemRegistry();
const item: ItemDefinition = {
id: 'log',
name: 'Log',
description: 'A wooden log',
category: 'resource',
source: 'seed',
};
registry.register(item);
expect(registry.get('log')).toEqual(item);
});
it('returns undefined for unknown items', () => {
const registry = new ItemRegistry();
expect(registry.get('nonexistent')).toBeUndefined();
});
it('prevents duplicate registration', () => {
const registry = new ItemRegistry();
const item: ItemDefinition = {
id: 'log',
name: 'Log',
description: 'A wooden log',
category: 'resource',
source: 'seed',
};
registry.register(item);
expect(() => registry.register(item)).toThrow('already registered');
});
it('lists all registered items', () => {
const registry = new ItemRegistry();
registry.register({ id: 'log', name: 'Log', description: '', category: 'resource', source: 'seed' });
registry.register({ id: 'stone', name: 'Stone', description: '', category: 'resource', source: 'seed' });
expect(registry.getAll()).toHaveLength(2);
});
it('finds items by category', () => {
const registry = new ItemRegistry();
registry.register({ id: 'log', name: 'Log', description: '', category: 'resource', source: 'seed' });
registry.register({ id: 'axe', name: 'Axe', description: '', category: 'tool', source: 'seed' });
expect(registry.getByCategory('resource')).toHaveLength(1);
expect(registry.getByCategory('resource')[0].id).toBe('log');
});
it('createDefault registers seed resources', () => {
const registry = ItemRegistry.createDefault();
expect(registry.get('log')).toBeDefined();
expect(registry.get('stone')).toBeDefined();
expect(registry.get('water')).toBeDefined();
});
});
+25
View File
@@ -0,0 +1,25 @@
export type Inventory = Map<string, number>;
export function addItem(inv: Inventory, itemId: string, quantity: number): void {
inv.set(itemId, (inv.get(itemId) ?? 0) + quantity);
}
export function removeItem(inv: Inventory, itemId: string, quantity: number): boolean {
const current = inv.get(itemId) ?? 0;
if (current < quantity) return false;
const remaining = current - quantity;
if (remaining === 0) {
inv.delete(itemId);
} else {
inv.set(itemId, remaining);
}
return true;
}
export function hasItem(inv: Inventory, itemId: string, quantity = 1): boolean {
return (inv.get(itemId) ?? 0) >= quantity;
}
export function getItemCount(inv: Inventory, itemId: string): number {
return inv.get(itemId) ?? 0;
}
+60
View File
@@ -0,0 +1,60 @@
import type { EntityId } from '@dflike/shared';
export interface ItemDefinition {
id: string;
name: string;
description: string;
category: 'resource' | 'tool' | 'material' | 'structure';
sourceTerrain?: number[];
source: 'seed' | 'invented';
inventedBy?: { entityId: EntityId; name: string; tick: number; day: number };
}
export class ItemRegistry {
private items = new Map<string, ItemDefinition>();
register(item: ItemDefinition): void {
if (this.items.has(item.id)) {
throw new Error(`Item '${item.id}' already registered`);
}
this.items.set(item.id, item);
}
get(id: string): ItemDefinition | undefined {
return this.items.get(id);
}
getAll(): ItemDefinition[] {
return [...this.items.values()];
}
getByCategory(category: ItemDefinition['category']): ItemDefinition[] {
return this.getAll().filter(i => i.category === category);
}
static createDefault(): ItemRegistry {
const registry = new ItemRegistry();
registry.register({
id: 'log',
name: 'Log',
description: 'A rough-hewn wooden log',
category: 'resource',
source: 'seed',
});
registry.register({
id: 'stone',
name: 'Stone',
description: 'A chunk of raw stone',
category: 'resource',
source: 'seed',
});
registry.register({
id: 'water',
name: 'Water',
description: 'Fresh water collected from a pond',
category: 'resource',
source: 'seed',
});
return registry;
}
}
+1
View File
@@ -13,6 +13,7 @@ export class GameMap {
terrain: number[] = []; // flat array of Terrain values
decorations: number[] = []; // flat array of canopy tile indices (-1 = none)
trunkDecorations: number[] = []; // flat array of trunk tile indices (-1 = none)
resourceTiles: Array<{ x: number; y: number; resourceType: string }> = [];
constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) {
this.width = width;
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { generateMap } from '../mapGenerator.js';
import { Terrain } from '@dflike/shared';
describe('generateMap — resource generation', () => {
it('generates water pond tiles', () => {
const map = generateMap(32, 32, 42);
const waterCount = map.terrain.filter(t => t === Terrain.WATER).length;
expect(waterCount).toBeGreaterThan(0);
});
it('water tiles are marked as obstacles', () => {
const map = generateMap(32, 32, 42);
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
if (map.terrain[y * 32 + x] === Terrain.WATER) {
expect(map.obstacles.has(`${x},${y}`)).toBe(true);
}
}
}
});
it('generates stone deposit tiles', () => {
const map = generateMap(32, 32, 42);
const stoneCount = map.terrain.filter(t => t === Terrain.STONE).length;
expect(stoneCount).toBeGreaterThan(0);
});
it('stone tiles are walkable (not in obstacles)', () => {
const map = generateMap(32, 32, 42);
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
if (map.terrain[y * 32 + x] === Terrain.STONE) {
expect(map.obstacles.has(`${x},${y}`)).toBe(false);
}
}
}
});
it('includes resourceTiles for all resource types', () => {
const map = generateMap(32, 32, 42);
const types = new Set(map.resourceTiles.map(r => r.resourceType));
expect(types.has('log')).toBe(true);
expect(types.has('stone')).toBe(true);
expect(types.has('water')).toBe(true);
});
it('water resource tiles are adjacent to water terrain, not on water', () => {
const map = generateMap(32, 32, 42);
const waterResources = map.resourceTiles.filter(r => r.resourceType === 'water');
for (const r of waterResources) {
expect(map.terrain[r.y * 32 + r.x]).not.toBe(Terrain.WATER);
const neighbors = [
{ x: r.x - 1, y: r.y }, { x: r.x + 1, y: r.y },
{ x: r.x, y: r.y - 1 }, { x: r.x, y: r.y + 1 },
].filter(n => n.x >= 0 && n.x < 32 && n.y >= 0 && n.y < 32);
const hasWaterNeighbor = neighbors.some(n => map.terrain[n.y * 32 + n.x] === Terrain.WATER);
expect(hasWaterNeighbor).toBe(true);
}
});
it('generates tree decorations', () => {
const map = generateMap(32, 32, 42);
const trunkCount = map.trunkDecorations.filter(t => t !== -1).length;
expect(trunkCount).toBeGreaterThan(0);
});
it('tree trunk tiles are marked as obstacles', () => {
const map = generateMap(32, 32, 42);
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
if (map.trunkDecorations[y * 32 + x] !== -1) {
expect(map.obstacles.has(`${x},${y}`)).toBe(true);
}
}
}
});
it('deterministic with same seed', () => {
const a = generateMap(32, 32, 123);
const b = generateMap(32, 32, 123);
expect(a.terrain).toEqual(b.terrain);
expect(a.resourceTiles).toEqual(b.resourceTiles);
});
});
+123 -5
View File
@@ -1,12 +1,14 @@
import { WORLD_WIDTH, WORLD_HEIGHT, Terrain } from '@dflike/shared';
import { industryConfig } from '../config/industryConfig.js';
export interface GeneratedMap {
terrain: number[]; // flat array, Terrain values
decorations: number[]; // flat array, canopy tile index (-1 = none)
trunkDecorations: number[]; // flat array, trunk tile index (-1 = none)
obstacles: Set<string>; // "x,y" keys for non-walkable tiles
terrain: number[];
decorations: number[];
trunkDecorations: number[];
obstacles: Set<string>;
foodPositions: { x: number; y: number }[];
restPositions: { x: number; y: number }[];
resourceTiles: Array<{ x: number; y: number; resourceType: string }>;
}
// Simple seeded pseudo-random (mulberry32)
@@ -20,15 +22,21 @@ function createRng(seed: number) {
};
}
function randRange(rng: () => number, min: number, max: number): number {
return min + Math.floor(rng() * (max - min + 1));
}
export function generateMap(
width = WORLD_WIDTH,
height = WORLD_HEIGHT,
_seed?: number,
): GeneratedMap {
const rng = _seed !== undefined ? createRng(_seed) : Math.random;
const terrain = new Array<number>(width * height).fill(Terrain.GRASS);
const decorations = new Array<number>(width * height).fill(-1);
const trunkDecorations = new Array<number>(width * height).fill(-1);
const obstacles = new Set<string>();
const resourceTiles: Array<{ x: number; y: number; resourceType: string }> = [];
const foodPositions = [
{ x: Math.floor(width * 0.25), y: Math.floor(height * 0.25) },
@@ -39,5 +47,115 @@ export function generateMap(
{ x: Math.floor(width * 0.25), y: Math.floor(height * 0.75) },
];
return { terrain, decorations, trunkDecorations, obstacles, foodPositions, restPositions };
// Reserve positions that must stay clear
const reserved = new Set<string>();
for (const p of [...foodPositions, ...restPositions]) {
reserved.add(`${p.x},${p.y}`);
}
// --- Water ponds ---
const pondCount = randRange(rng, industryConfig.waterPondCount.min, industryConfig.waterPondCount.max);
for (let p = 0; p < pondCount; p++) {
const cx = randRange(rng, 4, width - 5);
const cy = randRange(rng, 4, height - 5);
const size = randRange(rng, industryConfig.waterPondSize.min, industryConfig.waterPondSize.max);
// Random walk to create organic pond shape
const pondTiles = new Set<string>();
let wx = cx, wy = cy;
for (let s = 0; s < size * 3; s++) {
const key = `${wx},${wy}`;
if (!reserved.has(key) && wx > 1 && wx < width - 2 && wy > 1 && wy < height - 2) {
pondTiles.add(key);
}
const dir = Math.floor(rng() * 4);
if (dir === 0) wx++;
else if (dir === 1) wx--;
else if (dir === 2) wy++;
else wy--;
}
for (const key of pondTiles) {
const [x, y] = key.split(',').map(Number);
terrain[y * width + x] = Terrain.WATER;
obstacles.add(key);
}
// Water gather points: walkable tiles adjacent to water
const waterResourceSet = new Set<string>();
for (const key of pondTiles) {
const [x, y] = key.split(',').map(Number);
for (const [nx, ny] of [[x-1,y],[x+1,y],[x,y-1],[x,y+1]] as [number,number][]) {
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nk = `${nx},${ny}`;
if (!pondTiles.has(nk) && !obstacles.has(nk) && terrain[ny * width + nx] !== Terrain.WATER) {
if (!waterResourceSet.has(nk)) {
waterResourceSet.add(nk);
resourceTiles.push({ x: nx, y: ny, resourceType: 'water' });
}
}
}
}
}
}
// Post-process: remove water resource tiles that ended up under a later pond
for (let i = resourceTiles.length - 1; i >= 0; i--) {
const r = resourceTiles[i];
if (r.resourceType === 'water' && terrain[r.y * width + r.x] === Terrain.WATER) {
resourceTiles.splice(i, 1);
}
}
// --- Stone deposits ---
const stoneCount = randRange(rng, industryConfig.stoneClusterCount.min, industryConfig.stoneClusterCount.max);
for (let s = 0; s < stoneCount; s++) {
const cx = randRange(rng, 3, width - 4);
const cy = randRange(rng, 3, height - 4);
const size = randRange(rng, industryConfig.stoneClusterSize.min, industryConfig.stoneClusterSize.max);
for (let i = 0; i < size; i++) {
const ox = randRange(rng, -1, 1);
const oy = randRange(rng, -1, 1);
const x = cx + ox;
const y = cy + oy;
const key = `${x},${y}`;
if (!reserved.has(key) && !obstacles.has(key) && terrain[y * width + x] === Terrain.GRASS) {
terrain[y * width + x] = Terrain.STONE;
resourceTiles.push({ x, y, resourceType: 'stone' });
}
}
}
// --- Trees ---
const treeClusterCount = randRange(rng, 8, 14);
for (let c = 0; c < treeClusterCount; c++) {
const cx = randRange(rng, 3, width - 4);
const cy = randRange(rng, 3, height - 4);
const treeCount = randRange(rng, 2, 5);
for (let t = 0; t < treeCount; t++) {
const x = cx + randRange(rng, -2, 2);
const y = cy + randRange(rng, -2, 2);
if (x < 1 || x >= width - 1 || y < 1 || y >= height - 1) continue;
const key = `${x},${y}`;
if (reserved.has(key) || obstacles.has(key) || terrain[y * width + x] !== Terrain.GRASS) continue;
// Trunk at (x, y) — obstacle and resource tile
const trunkTile = randRange(rng, 0, 5);
trunkDecorations[y * width + x] = trunkTile;
obstacles.add(key);
// Canopy at (x, y-1) — visual only
if (y > 0) {
const canopyTile = randRange(rng, 0, 5);
decorations[(y - 1) * width + x] = canopyTile;
}
// Mark trunk tile as log resource
resourceTiles.push({ x, y, resourceType: 'log' });
}
}
return { terrain, decorations, trunkDecorations, obstacles, foodPositions, restPositions, resourceTiles };
}
+6
View File
@@ -50,6 +50,10 @@ export function serializeEntity(world: World, entityId: number): EntityState {
};
})
: undefined;
const inventoryComponent = world.getComponent<Map<string, number>>(entityId, 'inventory');
const inventory = inventoryComponent && inventoryComponent.size > 0
? Object.fromEntries(inventoryComponent)
: undefined;
return {
id: entityId,
position: world.getComponent<Position>(entityId, 'position')!,
@@ -67,6 +71,7 @@ export function serializeEntity(world: World, entityId: number): EntityState {
} : undefined,
stats,
relationships,
inventory,
};
}
@@ -82,6 +87,7 @@ export function serializeWorldState(world: World, map: GameMap): WorldState {
terrain: map.terrain,
decorations: map.decorations,
trunkDecorations: map.trunkDecorations,
resourceTiles: map.resourceTiles,
};
}
@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import { World } from '../../ecs/World.js';
import { GameMap } from '../../map/GameMap.js';
import { gatheringSystem, type GatheringState } from '../gatheringSystem.js';
import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared';
import { PRODUCTIVITY_RECOVERY_RATE } from '@dflike/shared';
function createGatheringNPC(world: World, x: number, y: number, overrides?: { strength?: number; productivity?: number }) {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x, y });
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: 'gather', goalQueue: [] });
world.addComponent<Map<string, number>>(e, 'inventory', new Map());
const str = overrides?.strength ?? 10;
world.addComponent<Stats>(e, 'stats', {
strength: str, dexterity: 10, 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('gatheringSystem', () => {
it('starts gathering when NPC arrives at resource tile', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3);
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState');
expect(gs).toBeDefined();
expect(gs!.resourceType).toBe('log');
expect(gs!.ticksRemaining).toBeGreaterThan(0);
});
it('does not start gathering when not at resource tile', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 5, 5);
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState');
expect(gs).toBeUndefined();
});
it('decrements timer each tick while gathering', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3);
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState')!;
const initial = gs.ticksRemaining;
gatheringSystem(world, map);
expect(gs.ticksRemaining).toBe(initial - 1);
});
it('recovers productivity while gathering', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3, { productivity: 30 });
gatheringSystem(world, map);
const needs = world.getComponent<Needs>(e, 'needs')!;
expect(needs.productivity).toBeCloseTo(30 + PRODUCTIVITY_RECOVERY_RATE);
});
it('adds item to inventory on completion', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3);
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState')!;
gs.ticksRemaining = 1;
gatheringSystem(world, map);
const inv = world.getComponent<Map<string, number>>(e, 'inventory')!;
expect(inv.get('log')).toBe(1);
});
it('clears gathering state on completion', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3);
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState')!;
gs.ticksRemaining = 1;
gatheringSystem(world, map);
expect(world.getComponent<GatheringState>(e, 'gatheringState')).toBeUndefined();
});
it('high strength reduces gather time', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3, { strength: 16 });
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState')!;
expect(gs.ticksRemaining).toBeLessThan(30);
});
it('skips NPC whose goal is not gather', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3);
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
brain.currentGoal = 'wander';
gatheringSystem(world, map);
expect(world.getComponent<GatheringState>(e, 'gatheringState')).toBeUndefined();
});
it('resets goal to wander when productivity is satisfied after gathering', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3, { productivity: 70 });
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState')!;
gs.ticksRemaining = 1;
gatheringSystem(world, map);
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
expect(brain.currentGoal).toBe('wander');
});
it('does not start gathering when NPC is still walking', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createGatheringNPC(world, 3, 3);
const movement = world.getComponent<Movement>(e, 'movement')!;
movement.state = 'walking';
movement.path = [{ x: 4, y: 3 }];
gatheringSystem(world, map);
expect(world.getComponent<GatheringState>(e, 'gatheringState')).toBeUndefined();
});
it('full cycle: NPC with low productivity gathers and gets item', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'stone' }];
const e = createGatheringNPC(world, 3, 3, { productivity: 30 });
// First tick: starts gathering
gatheringSystem(world, map);
const gs = world.getComponent<GatheringState>(e, 'gatheringState')!;
expect(gs).toBeDefined();
expect(gs.resourceType).toBe('stone');
// Run until completion
const totalTicks = gs.ticksRemaining;
for (let i = 0; i < totalTicks; i++) {
gatheringSystem(world, map);
}
// Verify item added
const inv = world.getComponent<Map<string, number>>(e, 'inventory')!;
expect(inv.get('stone')).toBe(1);
// Verify productivity recovered
const needs = world.getComponent<Needs>(e, 'needs')!;
expect(needs.productivity).toBeGreaterThan(30);
});
});
@@ -33,7 +33,7 @@ function createNPC(
): EntityId {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x: 0, y: 0 });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80 });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80, productivity: 80 });
world.addComponent<Movement>(e, 'movement', {
state: 'idle', target: null, path: [], direction: 0, moveProgress: 0,
});
@@ -14,7 +14,7 @@ function createNPC(
): EntityId {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x, y });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80 });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80, productivity: 80 });
world.addComponent<Movement>(e, 'movement', {
state: 'idle', target: null, path: [], direction: 0, moveProgress: 0,
});
@@ -22,6 +22,7 @@ function createNPC(
world.addComponent<Needs>(e, 'needs', {
hunger: opts?.hunger ?? 80,
energy: opts?.energy ?? 80,
productivity: 80,
});
world.addComponent<Movement>(e, 'movement', {
state: opts?.walking ? 'walking' : 'idle',
@@ -18,7 +18,7 @@ function setupEntity(
world.addComponent<Stats>(e, 'stats', base);
world.addComponent<StatModifiers>(e, 'statModifiers', { modifiers: [...modifiers] });
if (needs) {
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80, ...needs });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80, productivity: 80, ...needs });
}
return e;
}
+67 -2
View File
@@ -5,12 +5,12 @@ import { needsDecaySystem } from '../needsDecaySystem.js';
import { npcBrainSystem } from '../npcBrainSystem.js';
import { movementSystem } from '../movementSystem.js';
import type { Needs, Movement, NPCBrain, Position, SocialState, Stats, StatModifiers } from '@dflike/shared';
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, MOVE_SPEED } from '@dflike/shared';
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, PRODUCTIVITY_DECAY_PER_TICK, MOVE_SPEED } from '@dflike/shared';
function createNPC(world: World, x: number, y: number, hunger = 80, energy = 80) {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x, y });
world.addComponent<Needs>(e, 'needs', { hunger, energy });
world.addComponent<Needs>(e, 'needs', { hunger, energy, productivity: 80 });
world.addComponent<Movement>(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 });
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: null, goalQueue: [] });
return e;
@@ -79,6 +79,24 @@ describe('needsDecaySystem', () => {
expect(needs.hunger).toBeCloseTo(50 - HUNGER_DECAY_PER_TICK);
});
it('decays productivity each tick', () => {
const world = new World();
const e = createNPC(world, 0, 0, 50, 50);
needsDecaySystem(world);
const needs = world.getComponent<Needs>(e, 'needs')!;
expect(needs.productivity).toBeCloseTo(80 - PRODUCTIVITY_DECAY_PER_TICK);
});
it('productivity decay is not affected by constitution', () => {
const world = new World();
const e = createNPC(world, 0, 0, 50, 50);
addStats(world, e, { constitution: 18 });
needsDecaySystem(world);
const needs = world.getComponent<Needs>(e, 'needs')!;
// Same decay regardless of constitution
expect(needs.productivity).toBeCloseTo(80 - PRODUCTIVITY_DECAY_PER_TICK);
});
it('clamps needs at 0', () => {
const world = new World();
const e = createNPC(world, 0, 0, 0.01, 0.01);
@@ -130,6 +148,53 @@ describe('npcBrainSystem', () => {
expect(brain.currentGoal).toBe('rest');
});
it('sets gather goal when productivity is low', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createNPC(world, 5, 5, 80, 80);
const needs = world.getComponent<Needs>(e, 'needs')!;
needs.productivity = 30; // below threshold of 40
npcBrainSystem(world, map);
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
expect(brain.currentGoal).toBe('gather');
});
it('prioritizes rest and eat over gather', () => {
const world = new World();
const map = new GameMap(10, 10);
map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } });
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createNPC(world, 5, 5, 80, 10);
const needs = world.getComponent<Needs>(e, 'needs')!;
needs.productivity = 30;
npcBrainSystem(world, map);
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
expect(brain.currentGoal).toBe('rest');
});
it('sets wander when productivity is above threshold', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
const e = createNPC(world, 5, 5, 80, 80);
npcBrainSystem(world, map);
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
expect(brain.currentGoal).toBe('wander');
});
it('falls back to wander when no resource tiles exist', () => {
const world = new World();
const map = new GameMap(10, 10);
map.resourceTiles = [];
const e = createNPC(world, 5, 5, 80, 80);
const needs = world.getComponent<Needs>(e, 'needs')!;
needs.productivity = 30;
npcBrainSystem(world, map);
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
expect(brain.currentGoal).toBe('wander');
});
it('skips NPC when socialState phase is not none', () => {
const world = new World();
const map = new GameMap(10, 10);
@@ -29,7 +29,7 @@ function makeNpc(world: World, name: string, needs?: Partial<Needs>): number {
world.addComponent(entity, 'name', name);
world.addComponent(entity, 'npcBrain', { currentGoal: 'wander' });
world.addComponent<Stats>(entity, 'stats', makeStats());
world.addComponent<Needs>(entity, 'needs', { hunger: 80, energy: 80, ...needs });
world.addComponent<Needs>(entity, 'needs', { hunger: 80, energy: 80, productivity: 80, ...needs });
return entity;
}
+68
View File
@@ -0,0 +1,68 @@
import {
PRODUCTIVITY_RECOVERY_RATE, PRODUCTIVITY_THRESHOLD,
type Needs, type Movement, type NPCBrain, type Position,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import { getEffectiveStat } from './statHelpers.js';
import { addItem } from '../industry/inventoryHelpers.js';
import { industryConfig } from '../config/industryConfig.js';
export interface GatheringState {
resourceType: string;
ticksRemaining: number;
}
export function gatheringSystem(world: World, map: GameMap): void {
for (const entity of world.query('npcBrain', 'position', 'needs', 'movement')) {
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
if (brain.currentGoal !== 'gather') continue;
const pos = world.getComponent<Position>(entity, 'position')!;
const needs = world.getComponent<Needs>(entity, 'needs')!;
const movement = world.getComponent<Movement>(entity, 'movement')!;
const gs = world.getComponent<GatheringState>(entity, 'gatheringState');
// Already gathering — tick down
if (gs) {
gs.ticksRemaining--;
needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE);
if (gs.ticksRemaining <= 0) {
// Complete: add resource to inventory
const inv = world.getComponent<Map<string, number>>(entity, 'inventory');
if (inv) {
addItem(inv, gs.resourceType, industryConfig.gatherYield);
}
world.removeComponent(entity, 'gatheringState');
// Decide next action
if (needs.productivity >= PRODUCTIVITY_THRESHOLD) {
brain.currentGoal = 'wander';
}
// else brain stays on 'gather', npcBrain will pick new target next tick
}
continue;
}
// Not yet gathering — check if at resource tile and idle
if (movement.state !== 'idle') continue;
const resourceTile = map.resourceTiles.find(r => r.x === pos.x && r.y === pos.y);
if (!resourceTile) continue;
// Start gathering
const str = getEffectiveStat(world, entity, 'strength');
const baseTicks = industryConfig.gatherBaseTicks;
const ticks = Math.max(1, Math.round(baseTicks * (1 - (str - 10) * industryConfig.gatherStrengthModifier)));
world.addComponent<GatheringState>(entity, 'gatheringState', {
resourceType: resourceTile.resourceType,
ticksRemaining: ticks,
});
// Recover productivity on first tick too
needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE);
}
}
+4
View File
@@ -1,5 +1,6 @@
import { Direction, MOVE_SPEED, type Movement, type Position, type SocialState } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GatheringState } from './gatheringSystem.js';
function directionFromDelta(dx: number, dy: number): number {
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Direction.RIGHT : Direction.LEFT;
@@ -14,6 +15,9 @@ export function movementSystem(world: World): void {
const socialState = world.getComponent<SocialState>(entity, 'socialState');
if (socialState && socialState.phase !== 'none') continue;
const gatheringState = world.getComponent<GatheringState>(entity, 'gatheringState');
if (gatheringState) continue;
if (movement.state !== 'walking' || movement.path.length === 0) {
if (movement.state === 'walking' && movement.path.length === 0) {
movement.state = 'idle';
+12 -1
View File
@@ -1,4 +1,4 @@
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, type Needs } from '@dflike/shared';
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, PRODUCTIVITY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, type Needs } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
import type { EventMemoryService } from '../llm/eventMemoryService.js';
@@ -10,8 +10,10 @@ export function needsDecaySystem(world: World, eventMemoryService?: EventMemoryS
const conMultiplier = 1 - (con - 10) * 0.03;
const prevHunger = needs.hunger;
const prevEnergy = needs.energy;
const prevProductivity = needs.productivity;
needs.hunger = Math.max(0, needs.hunger - HUNGER_DECAY_PER_TICK * conMultiplier);
needs.energy = Math.max(0, needs.energy - ENERGY_DECAY_PER_TICK * conMultiplier);
needs.productivity = Math.max(0, needs.productivity - PRODUCTIVITY_DECAY_PER_TICK);
if (eventMemoryService) {
if (prevHunger >= HUNGER_THRESHOLD && needs.hunger < HUNGER_THRESHOLD) {
@@ -32,6 +34,15 @@ export function needsDecaySystem(world: World, eventMemoryService?: EventMemoryS
detail: `${name} became exhausted`,
});
}
if (prevProductivity >= PRODUCTIVITY_THRESHOLD && needs.productivity < PRODUCTIVITY_THRESHOLD) {
const name = world.getComponent<string>(entity, 'name') ?? 'Unknown';
eventMemoryService.record(entity, {
type: 'need_crisis',
tick: 0,
need: 'productivity',
detail: `${name} felt unproductive`,
});
}
}
}
}
+23 -4
View File
@@ -1,12 +1,24 @@
import {
HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction,
type Needs, type Movement, type NPCBrain, type Position, type SocialState,
HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, Direction,
type GoalType, type Needs, type Movement, type NPCBrain, type Position, type SocialState,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import { findPath } from '../map/pathfinding.js';
import type { EventMemoryService } from '../llm/eventMemoryService.js';
function findNearestResource(map: GameMap, from: Position): Position | null {
const tiles = map.resourceTiles;
if (tiles.length === 0) return null;
let best = tiles[0];
let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y);
for (let i = 1; i < tiles.length; i++) {
const d = Math.abs(from.x - tiles[i].x) + Math.abs(from.y - tiles[i].y);
if (d < bestDist) { best = tiles[i]; bestDist = d; }
}
return { x: best.x, y: best.y };
}
function closestPOI(map: GameMap, from: Position, type: 'food' | 'rest'): Position | null {
const pois = map.getPointsOfInterest(type);
if (pois.length === 0) return null;
@@ -71,15 +83,16 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?:
// Determine new goal based on needs priority
const prevGoal = brain.currentGoal;
let goal: 'rest' | 'eat' | 'wander' = 'wander';
let goal: GoalType = 'wander';
if (needs.energy < ENERGY_THRESHOLD) goal = 'rest';
else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat';
else if (needs.productivity < PRODUCTIVITY_THRESHOLD) goal = 'gather';
brain.currentGoal = goal;
if (eventMemoryService && brain.currentGoal !== prevGoal && prevGoal !== null) {
const name = world.getComponent<string>(entity, 'name') ?? 'Unknown';
const goalLabels: Record<string, string> = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest' };
const goalLabels: Record<string, string> = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources' };
eventMemoryService.record(entity, {
type: 'goal_change',
tick: 0,
@@ -95,6 +108,12 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?:
target = closestPOI(map, pos, 'food');
} else if (goal === 'rest') {
target = closestPOI(map, pos, 'rest');
} else if (goal === 'gather') {
target = findNearestResource(map, pos);
if (!target) {
goal = 'wander';
brain.currentGoal = 'wander';
}
}
if (!target) {
target = map.getRandomWalkable();
+6
View File
@@ -16,6 +16,11 @@ export const HUNGER_THRESHOLD = 30;
export const ENERGY_THRESHOLD = 20;
export const NEED_RECOVERY_RATE = 0.5;
// Productivity need
export const PRODUCTIVITY_DECAY_PER_TICK = 0.02;
export const PRODUCTIVITY_THRESHOLD = 40;
export const PRODUCTIVITY_RECOVERY_RATE = 0.5;
// Day/night cycle
export const DAY_NIGHT_RATIO = 2; // day is 2x as long as night
export const DAY_HOURS = 12; // game hours of daytime
@@ -78,6 +83,7 @@ export const Terrain = {
GRASS: 0,
WATER: 1,
DIRT: 2,
STONE: 3,
} as const;
export type Terrain = (typeof Terrain)[keyof typeof Terrain];
+6 -2
View File
@@ -23,7 +23,7 @@ export interface MemoryEvent {
detail: string;
oldTier?: string;
newTier?: string;
need?: 'hunger' | 'energy';
need?: 'hunger' | 'energy' | 'productivity';
oldGoal?: string;
newGoal?: string;
}
@@ -48,6 +48,7 @@ export interface Appearance {
export interface Needs {
hunger: number; // 0-100
energy: number; // 0-100
productivity: number; // 0-100
}
export interface Stats {
@@ -78,7 +79,7 @@ export interface StatModifiers {
}
export type MovementState = 'idle' | 'walking';
export type GoalType = 'wander' | 'eat' | 'rest';
export type GoalType = 'wander' | 'eat' | 'rest' | 'gather';
export interface Movement {
state: MovementState;
@@ -96,6 +97,7 @@ export interface PlayerControlled {
export interface NPCBrain {
currentGoal: GoalType | null;
goalQueue: GoalType[];
gatherTarget?: { x: number; y: number; resourceType: string } | null;
}
export type InteractionPhase = 'none' | 'facing' | 'pausing' | 'emoting' | 'proposing';
@@ -141,6 +143,7 @@ export interface EntityState {
playerControlled?: PlayerControlled;
name?: string; // NPC display name
backstory?: string;
inventory?: Record<string, number>;
socialState?: {
phase: InteractionPhase;
partnerId: EntityId | null;
@@ -166,6 +169,7 @@ export interface WorldState {
terrain: number[]; // flat array [y * width + x], terrain type per tile
decorations: number[]; // flat array [y * width + x], decoration tile index (-1 = none)
trunkDecorations: number[]; // flat array [y * width + x], trunk tile index (-1 = none)
resourceTiles: Array<{ x: number; y: number; resourceType: string }>;
}
export interface StateUpdate {