docs: carry capacity and dropoff implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
# Carry Capacity & Dropoff Priority Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix NPCs never dropping off gathered resources by adding strength-based carry capacity and fixing dropoff goal priority in the brain system.
|
||||
|
||||
**Architecture:** Add `getTotalItemCount()` to inventory helpers and `getCarryCapacity()` to stat helpers. Fix the missing `continue` in npcBrainSystem's dropoff block and add capacity-aware goal priority. Update gatheringSystem to use carry capacity instead of productivity threshold for dropoff decisions.
|
||||
|
||||
**Tech Stack:** TypeScript, vitest, ECS component system
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `getTotalItemCount` helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/industry/inventoryHelpers.ts:25` (add after `getItemCount`)
|
||||
- Modify: `server/src/industry/__tests__/inventoryHelpers.test.ts:64` (add tests)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add to end of `server/src/industry/__tests__/inventoryHelpers.test.ts` (before the closing `});`):
|
||||
|
||||
```typescript
|
||||
it('getTotalItemCount returns 0 for empty inventory', () => {
|
||||
const inv = new Map<string, number>();
|
||||
expect(getTotalItemCount(inv)).toBe(0);
|
||||
});
|
||||
|
||||
it('getTotalItemCount sums all item quantities', () => {
|
||||
const inv = new Map<string, number>();
|
||||
addItem(inv, 'log', 3);
|
||||
addItem(inv, 'stone', 2);
|
||||
expect(getTotalItemCount(inv)).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
Update the import on line 2 to include `getTotalItemCount`:
|
||||
```typescript
|
||||
import { addItem, removeItem, hasItem, getItemCount, getTotalItemCount } from '../inventoryHelpers.js';
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm -w server run test -- --run src/industry/__tests__/inventoryHelpers.test.ts`
|
||||
Expected: FAIL — `getTotalItemCount` is not exported
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add to end of `server/src/industry/inventoryHelpers.ts`:
|
||||
|
||||
```typescript
|
||||
export function getTotalItemCount(inv: Inventory): number {
|
||||
let total = 0;
|
||||
for (const qty of inv.values()) total += qty;
|
||||
return total;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm -w server run test -- --run src/industry/__tests__/inventoryHelpers.test.ts`
|
||||
Expected: PASS (all 10 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: add getTotalItemCount inventory helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `getCarryCapacity` helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/systems/statHelpers.ts:15` (add after `getEffectiveStat`)
|
||||
- Modify: `server/src/systems/__tests__/statHelpers.test.ts:82` (add tests)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add to end of `server/src/systems/__tests__/statHelpers.test.ts` (before the closing `});`):
|
||||
|
||||
```typescript
|
||||
it('getCarryCapacity returns floor(strength / 3)', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
addStats(world, e, { strength: 10 });
|
||||
expect(getCarryCapacity(world, e)).toBe(3);
|
||||
});
|
||||
|
||||
it('getCarryCapacity minimum is 1 at STR 3', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
addStats(world, e, { strength: 3 });
|
||||
expect(getCarryCapacity(world, e)).toBe(1);
|
||||
});
|
||||
|
||||
it('getCarryCapacity maximum is 6 at STR 18', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
addStats(world, e, { strength: 18 });
|
||||
expect(getCarryCapacity(world, e)).toBe(6);
|
||||
});
|
||||
```
|
||||
|
||||
Update the import on line 3 to include `getCarryCapacity`:
|
||||
```typescript
|
||||
import { getEffectiveStat, getCarryCapacity } from '../statHelpers.js';
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm -w server run test -- --run src/systems/__tests__/statHelpers.test.ts`
|
||||
Expected: FAIL — `getCarryCapacity` is not exported
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add to end of `server/src/systems/statHelpers.ts`:
|
||||
|
||||
```typescript
|
||||
export function getCarryCapacity(world: World, entity: number): number {
|
||||
return Math.floor(getEffectiveStat(world, entity, 'strength') / 3);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm -w server run test -- --run src/systems/__tests__/statHelpers.test.ts`
|
||||
Expected: PASS (all 10 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: add getCarryCapacity stat helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix npcBrainSystem dropoff preservation and add capacity-aware priority
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/systems/npcBrainSystem.ts:1-235`
|
||||
|
||||
This is the core bug fix + new behavior. Three changes in npcBrainSystem:
|
||||
|
||||
**Step 1: Fix dropoff preservation — add pathfinding and `continue`**
|
||||
|
||||
In `server/src/systems/npcBrainSystem.ts`, add imports at top:
|
||||
|
||||
```typescript
|
||||
import { getCarryCapacity } from './statHelpers.js';
|
||||
import { getTotalItemCount } from '../industry/inventoryHelpers.js';
|
||||
```
|
||||
|
||||
Replace the dropoff preservation block (lines 120-135):
|
||||
|
||||
```typescript
|
||||
// Preserve dropoff goal — NPC should finish delivering items before switching
|
||||
if (brain.currentGoal === 'dropoff') {
|
||||
// Check if any completed stockpile exists
|
||||
let target: Position | null = null;
|
||||
for (const sEntity of world.query('structure', 'position')) {
|
||||
const sData = world.getComponent<StructureData>(sEntity, 'structure')!;
|
||||
if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue;
|
||||
const sPos = world.getComponent<Position>(sEntity, 'position')!;
|
||||
const d = Math.abs(pos.x - sPos.x) + Math.abs(pos.y - sPos.y);
|
||||
if (!target || d < (Math.abs(pos.x - target.x) + Math.abs(pos.y - target.y))) {
|
||||
target = { x: sPos.x, y: sPos.y };
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
const path = findPath(map, pos, target);
|
||||
if (path && path.length > 0) {
|
||||
movement.state = 'walking';
|
||||
movement.target = target;
|
||||
movement.path = path;
|
||||
movement.direction = directionTo(pos, path[0]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
brain.currentGoal = 'wander';
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add capacity-based dropoff priority in goal assignment**
|
||||
|
||||
Replace the goal assignment block (lines 155-163, after the pickup `continue`):
|
||||
|
||||
```typescript
|
||||
// Determine new goal based on needs priority
|
||||
const prevGoal = brain.currentGoal;
|
||||
let goal: GoalType = 'wander';
|
||||
if (needs.energy < ENERGY_THRESHOLD) goal = 'sleep';
|
||||
else if (isNighttime(gameTime) && needs.energy < SLEEP_VOLUNTARY_ENERGY_THRESHOLD) goal = 'sleep';
|
||||
else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat';
|
||||
else {
|
||||
// Check if NPC is carrying items at or over capacity → dropoff priority
|
||||
const inv = world.getComponent<Map<string, number>>(entity, 'inventory');
|
||||
const totalItems = inv ? getTotalItemCount(inv) : 0;
|
||||
if (totalItems > 0) {
|
||||
const capacity = getCarryCapacity(world, entity);
|
||||
if (totalItems >= capacity) {
|
||||
goal = 'dropoff';
|
||||
} else if (needs.productivity < PRODUCTIVITY_THRESHOLD) {
|
||||
goal = 'gather';
|
||||
} else {
|
||||
// Productivity satisfied, drop off remaining items before wandering
|
||||
goal = 'dropoff';
|
||||
}
|
||||
} else if (needs.productivity < PRODUCTIVITY_THRESHOLD) {
|
||||
goal = 'gather';
|
||||
}
|
||||
}
|
||||
|
||||
brain.currentGoal = goal;
|
||||
```
|
||||
|
||||
**Step 3: Run all tests**
|
||||
|
||||
Run: `npm -w server run test -- --run`
|
||||
Expected: Some gathering tests may fail (Task 4 will update them). npcBrain has no dedicated test file, so no direct failures there.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
fix: preserve dropoff goal in npcBrainSystem and add carry capacity priority
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update gatheringSystem to use carry capacity
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/systems/gatheringSystem.ts:1-82`
|
||||
- Modify: `server/src/systems/__tests__/gatheringSystem.test.ts:115-126`
|
||||
|
||||
**Step 1: Update gatheringSystem**
|
||||
|
||||
Add imports to `server/src/systems/gatheringSystem.ts`:
|
||||
|
||||
```typescript
|
||||
import { getCarryCapacity } from './statHelpers.js';
|
||||
import { getTotalItemCount } from '../industry/inventoryHelpers.js';
|
||||
```
|
||||
|
||||
Replace the post-gathering decision block (lines 41-55) with:
|
||||
|
||||
```typescript
|
||||
// Decide next action: drop off if at carry capacity
|
||||
let hasStockpile = false;
|
||||
for (const sEntity of world.query('structure')) {
|
||||
const sData = world.getComponent<StructureData>(sEntity, 'structure');
|
||||
if (sData && sData.type === 'stockpile' && sData.buildProgress === undefined) {
|
||||
hasStockpile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const totalItems = inv ? getTotalItemCount(inv) : 0;
|
||||
const capacity = getCarryCapacity(world, entity);
|
||||
if (hasStockpile && totalItems >= capacity) {
|
||||
brain.currentGoal = 'dropoff';
|
||||
}
|
||||
// else: stay on 'gather', npcBrain will pick new target or handle dropoff
|
||||
```
|
||||
|
||||
**Step 2: Update the gathering test that checks for wander transition**
|
||||
|
||||
Replace the test at line 115 in `server/src/systems/__tests__/gatheringSystem.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('sets dropoff goal when at carry capacity after gathering', () => {
|
||||
const world = new World();
|
||||
const map = new GameMap(10, 10);
|
||||
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
|
||||
// STR 6 → capacity = floor(6/3) = 2, pre-fill inventory with 1 log
|
||||
const e = createGatheringNPC(world, 3, 3, { strength: 6, productivity: 30 });
|
||||
const inv = world.getComponent<Map<string, number>>(e, 'inventory')!;
|
||||
inv.set('log', 1); // already carrying 1
|
||||
// Create a completed stockpile
|
||||
const s = world.createEntity();
|
||||
world.addComponent(s, 'position', { x: 5, y: 5 });
|
||||
world.addComponent(s, 'structure', { type: 'stockpile', subtype: 'general', inventory: new Map() });
|
||||
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('dropoff');
|
||||
});
|
||||
|
||||
it('stays on gather when under carry capacity', () => {
|
||||
const world = new World();
|
||||
const map = new GameMap(10, 10);
|
||||
map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }];
|
||||
// STR 10 → capacity = floor(10/3) = 3, will have 1 item after gather
|
||||
const e = createGatheringNPC(world, 3, 3, { strength: 10, productivity: 30 });
|
||||
const s = world.createEntity();
|
||||
world.addComponent(s, 'position', { x: 5, y: 5 });
|
||||
world.addComponent(s, 'structure', { type: 'stockpile', subtype: 'general', inventory: new Map() });
|
||||
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('gather');
|
||||
});
|
||||
```
|
||||
|
||||
Note: the `createGatheringNPC` helper needs updating to accept `strength` — it already does via `overrides.strength`.
|
||||
|
||||
**Step 3: Add import for StructureData in gathering test**
|
||||
|
||||
Update imports at top of `server/src/systems/__tests__/gatheringSystem.test.ts` to include:
|
||||
```typescript
|
||||
import type { StructureData } from '../buildingSystem.js';
|
||||
```
|
||||
(Note: it's not imported in the existing test file but is needed for the stockpile structure component.)
|
||||
|
||||
Wait — the test helper uses `world.addComponent` with inline type. Since `StructureData` is only used for the type assertion, we can use the inline object directly without importing. Actually, looking at the `dropoffSystem.test.ts` pattern, it does import `StructureData`. Follow that pattern.
|
||||
|
||||
**Step 4: Run all tests**
|
||||
|
||||
Run: `npm -w server run test -- --run`
|
||||
Expected: PASS (all tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: gatheringSystem uses carry capacity for dropoff decision
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run full test suite and verify
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
Run: `npm -w server run test -- --run`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 2: Verify no regressions with a quick manual check**
|
||||
|
||||
Run: `npm -w server run dev -- --new-world` and observe NPC behavior for a few seconds to confirm NPCs are gathering, filling up, and dropping off.
|
||||
|
||||
**Step 3: Final commit if any cleanup needed**
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user