Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions backend/nakama/tests/supabase_custom_auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand All @@ -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: {} },
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions docs/design/45-inventory-and-equipment-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading