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