diff --git a/CHANGELOG.md b/CHANGELOG.md index b280eb6..cfdd794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. - Inventory lifecycle guards for issue #219 now clear body carry, equipped items, and temporary run loot on body death, preserve account stash through reinhabitation, and reject inventory mutations while the current body is dead. +- Inventory validation coverage now asserts rejected slot equips, full body + carry capacity, and denied model inventory intents do not partially mutate + inventory state. - Inventory and equipment design refinements covering custody lifecycle states, hostile / monster / boss body loadout rules, alpha starter item definitions, starter body loadouts, first reward tables, compact AI inventory context, and diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index 04efcc8..376dc0e 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -1567,6 +1567,30 @@ assert.equal(salvagedBlade.inventory.materials.length, 1); assert.equal(salvagedBlade.inventory.materials[0].item_def_id, "material.relay_scrap.common"); assert.equal(salvagedBlade.inventory.materials[0].quantity, 5); +const offHandStaff = JSON.parse(inventoryHarness.registeredRpcs.get("secondspawn_inventory_pickup")( + { userId: "inventory-user", env: {} }, + inventoryHarness.logger, + inventoryHarness.nk, + JSON.stringify({ drop_id: "drop.relay_staff", idempotency_key: "slot-guard-staff" }) +)); +const slotGuardSnapshot = JSON.stringify(offHandStaff.inventory); +assert.throws( + () => inventoryHarness.registeredRpcs.get("secondspawn_inventory_equip")( + { userId: "inventory-user", env: {} }, + inventoryHarness.logger, + inventoryHarness.nk, + JSON.stringify({ item_instance_id: offHandStaff.item.item_instance_id, slot_id: "off_hand" }) + ), + /item cannot be equipped/ +); +const inventoryAfterRejectedSlotEquip = JSON.parse(inventoryHarness.registeredRpcs.get("secondspawn_inventory_get")( + { userId: "inventory-user", env: {} }, + inventoryHarness.logger, + inventoryHarness.nk, + "" +)); +assert.equal(JSON.stringify(inventoryAfterRejectedSlotEquip.inventory), slotGuardSnapshot); + assert.throws( () => inventoryHarness.registeredRpcs.get("secondspawn_inventory_stash_move")( { userId: "inventory-user", env: {} }, @@ -1591,6 +1615,71 @@ assert.ok(inventoryLedger.entries.some((entry) => entry.category === "inventory" assert.ok(inventoryLedger.entries.some((entry) => entry.category === "inventory" && entry.kind === "inventory_unequip")); assert.ok(inventoryLedger.entries.some((entry) => entry.category === "inventory" && entry.kind === "inventory_salvage")); +const slotConflictHarness = createRuntimeHarness(module); +slotConflictHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "slot-conflict-user", env: {} }, + slotConflictHarness.logger, + slotConflictHarness.nk, + "" +); +const slotConflictBlade = JSON.parse(slotConflictHarness.registeredRpcs.get("secondspawn_inventory_pickup")( + { userId: "slot-conflict-user", env: {} }, + slotConflictHarness.logger, + slotConflictHarness.nk, + JSON.stringify({ drop_id: "drop.training_blade", idempotency_key: "slot-conflict-blade" }) +)); +JSON.parse(slotConflictHarness.registeredRpcs.get("secondspawn_inventory_equip")( + { userId: "slot-conflict-user", env: {} }, + slotConflictHarness.logger, + slotConflictHarness.nk, + JSON.stringify({ item_instance_id: slotConflictBlade.item.item_instance_id, slot_id: "main_hand" }) +)); +const slotConflictStaff = JSON.parse(slotConflictHarness.registeredRpcs.get("secondspawn_inventory_pickup")( + { userId: "slot-conflict-user", env: {} }, + slotConflictHarness.logger, + slotConflictHarness.nk, + JSON.stringify({ drop_id: "drop.relay_staff", idempotency_key: "slot-conflict-staff" }) +)); +const resolvedSlotConflict = JSON.parse(slotConflictHarness.registeredRpcs.get("secondspawn_inventory_equip")( + { userId: "slot-conflict-user", env: {} }, + slotConflictHarness.logger, + slotConflictHarness.nk, + JSON.stringify({ item_instance_id: slotConflictStaff.item.item_instance_id, slot_id: "main_hand" }) +)); +assert.equal(resolvedSlotConflict.inventory.equipment.items.length, 1); +assert.equal(resolvedSlotConflict.inventory.equipment.items[0].item_def_id, "weapon.relay_staff.common"); +assert.equal(resolvedSlotConflict.inventory.body_carry.items.length, 1); +assert.equal(resolvedSlotConflict.inventory.body_carry.items[0].item_def_id, "weapon.relay_blade.common"); +assert.equal(resolvedSlotConflict.inventory.body_carry.items[0].slot_id, ""); + +const fullInventoryHarness = createRuntimeHarness(module); +fullInventoryHarness.registeredRpcs.get("secondspawn_profile_get")( + { userId: "full-inventory-user", env: {} }, + fullInventoryHarness.logger, + fullInventoryHarness.nk, + "" +); +JSON.parse(fullInventoryHarness.registeredRpcs.get("secondspawn_inventory_pickup")( + { userId: "full-inventory-user", env: {} }, + fullInventoryHarness.logger, + fullInventoryHarness.nk, + JSON.stringify({ drop_id: "drop.training_blade", idempotency_key: "full-capacity-blade" }) +)); +const fullInventoryState = fullInventoryHarness.storage.get(storageKey("full-inventory-user", "secondspawn_inventory", "state")); +fullInventoryState.value.body_carry.capacity = 1; +const fullInventorySnapshot = JSON.stringify(fullInventoryState.value); +assert.throws( + () => fullInventoryHarness.registeredRpcs.get("secondspawn_inventory_pickup")( + { userId: "full-inventory-user", env: {} }, + fullInventoryHarness.logger, + fullInventoryHarness.nk, + JSON.stringify({ drop_id: "drop.relay_staff", idempotency_key: "full-capacity-staff" }) + ), + /body carry inventory is full/ +); +const fullInventoryAfterRejectedPickup = fullInventoryHarness.storage.get(storageKey("full-inventory-user", "secondspawn_inventory", "state")); +assert.equal(JSON.stringify(fullInventoryAfterRejectedPickup.value), fullInventorySnapshot); + const inventoryDeathHarness = createRuntimeHarness(module); inventoryDeathHarness.registeredRpcs.get("secondspawn_profile_get")( { userId: "inventory-death-user", env: {} }, @@ -2915,6 +3004,64 @@ assert.ok(promptTraces.traces[0].prompt_components.knowledge_pack_ids.includes(" assert.deepEqual(promptTraces.traces[0].prompt_components.inventory_summary.current_loadout, ["Relay Blade"]); assert.equal(JSON.stringify(promptTraces.traces[0].prompt_components.inventory_summary).includes(modelBladeId), false); +const inventoryIntentDeniedHarness = createRuntimeHarness(module); +JSON.parse(inventoryIntentDeniedHarness.registeredRpcs.get("secondspawn_inventory_pickup")( + { userId: "inventory-intent-denied-user", env: {} }, + inventoryIntentDeniedHarness.logger, + inventoryIntentDeniedHarness.nk, + JSON.stringify({ drop_id: "drop.training_blade", idempotency_key: "intent-denied-blade" }) +)); +const inventoryIntentBefore = JSON.stringify(inventoryIntentDeniedHarness.storage.get( + storageKey("inventory-intent-denied-user", "secondspawn_inventory", "state") +).value); +inventoryIntentDeniedHarness.nk.httpRequest = () => ({ + code: 200, + body: JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + action: "inventory_pickup", + drop_id: "drop.relay_staff", + reason: "model tried to mutate inventory directly", + confidence: 0.8 + }) + } + }] + }) +}); +const inventoryIntentDeniedDecision = JSON.parse(inventoryIntentDeniedHarness.registeredRpcs.get("secondspawn_agent_decide")( + { + userId: "inventory-intent-denied-user", + env: { + DOS_AI_API_KEY: "dos-ai-test-key", + DOS_AI_BASE_URL: "https://api.dos.ai/v1", + AGENT_DECISION_MODEL: "dos-ai" + } + }, + inventoryIntentDeniedHarness.logger, + inventoryIntentDeniedHarness.nk, + JSON.stringify({ + world_snapshot: { + position: { x: 2, z: 3 }, + body_time_seconds: 3600 + }, + allowed: ["say", "stop"] + }) +)); +assert.equal(inventoryIntentDeniedDecision.source, "fallback"); +assert.equal(inventoryIntentDeniedDecision.source_reason, "dos_ai_validate_error"); +const inventoryIntentAfter = JSON.stringify(inventoryIntentDeniedHarness.storage.get( + storageKey("inventory-intent-denied-user", "secondspawn_inventory", "state") +).value); +assert.equal(inventoryIntentAfter, inventoryIntentBefore); +assert.ok(inventoryIntentDeniedHarness.structuredLogs().some((log) => { + return log.event === "secondspawn.ai_decision" && + log.owner_id === "inventory-intent-denied-user" && + log.source === "fallback" && + log.source_reason === "dos_ai_validate_error" && + log.validation_result === "action is not allowed"; +})); + const repeatedSpeechHarness = createRuntimeHarness(module); repeatedSpeechHarness.nk.httpRequest = () => ({ code: 200, diff --git a/docs/design/45-inventory-and-equipment-system.md b/docs/design/45-inventory-and-equipment-system.md index d408239..726c044 100644 --- a/docs/design/45-inventory-and-equipment-system.md +++ b/docs/design/45-inventory-and-equipment-system.md @@ -951,11 +951,11 @@ Cut line for all three packets: - [x] Add inventory mutation ledger rows. - [x] Add RPCs for get, pickup, equip, unequip, stash move, salvage, use, and run loot claim. -- [ ] Add unit tests for slot conflicts, full inventory, body death loss, - account stash survival, and LLM intent denial. Body death loss, account - stash survival, and post-error no-mutation checks are now covered; track - remaining slot/full-capacity and LLM denial coverage in - [#219](https://github.com/DOS/Second-Spawn/issues/219). +- [x] Add unit tests for slot conflicts, full inventory, body death loss, + account stash survival, and LLM intent denial. Covered cases include + rejected slot equip, full body carry, body death loss, account stash + survival, denied model inventory intent, and post-error no-mutation + checks. - [ ] Add custody lifecycle transition guards for world drop, run loot, body carry, body equip, Yard storage, account stash, entitlement, destroyed, and recoverable wreck states. Dead-body mutation guards now cover the