From 7a15a3257a7984ecb4a9b66657b79599d64a1cea Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 21:47:27 +0000 Subject: [PATCH] docs: carry capacity and dropoff implementation plan Co-Authored-By: Claude Opus 4.6 --- ...9-carry-capacity-dropoff-implementation.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/plans/2026-03-09-carry-capacity-dropoff-implementation.md diff --git a/docs/plans/2026-03-09-carry-capacity-dropoff-implementation.md b/docs/plans/2026-03-09-carry-capacity-dropoff-implementation.md new file mode 100644 index 0000000..925d74b --- /dev/null +++ b/docs/plans/2026-03-09-carry-capacity-dropoff-implementation.md @@ -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(); + expect(getTotalItemCount(inv)).toBe(0); + }); + + it('getTotalItemCount sums all item quantities', () => { + const inv = new Map(); + 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(sEntity, 'structure')!; + if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue; + const sPos = world.getComponent(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>(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(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>(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(e, 'gatheringState')!; + gs.ticksRemaining = 1; + gatheringSystem(world, map); + const brain = world.getComponent(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(e, 'gatheringState')!; + gs.ticksRemaining = 1; + gatheringSystem(world, map); + const brain = world.getComponent(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** + +---