NEO-69: Fix Bugbot pre-flight and output rollback issues.

Simulate input removal before output capacity checks, roll back partial
outputs on store-missing, and add tests for slot-freeing craft success.
pull/103/head
VinPropane 2026-05-24 17:28:43 -04:00
parent 51e9f1f5d0
commit 36adfd1897
5 changed files with 97 additions and 3 deletions

View File

@ -65,7 +65,7 @@
4. **Pre-flight inputs (read-only):** For each input row, required amount = **`row.Quantity * quantity`**. Sum held quantity in the correct container (bag vs equipment via catalog **`inventorySlotKind`**) from current snapshot. Any shortfall → **`insufficient_materials`** without mutations. 4. **Pre-flight inputs (read-only):** For each input row, required amount = **`row.Quantity * quantity`**. Sum held quantity in the correct container (bag vs equipment via catalog **`inventorySlotKind`**) from current snapshot. Any shortfall → **`insufficient_materials`** without mutations.
5. **Pre-flight outputs (read-only, kickoff decision):** Clone current snapshot; for each output row with scaled quantity, simulate **`TryAddStack`** placement rules (merge + fill empty slots, catalog **`stackMax`**, equipment routing) on the clone. If any output cannot be fully placed → **`inventory_full`** without mutations. Implementation: add **`PlayerInventoryOperations.CanPlaceStack(snapshot, itemId, quantity, registry)`** (or equivalent simulate helper) to avoid duplicating merge/fill logic. 5. **Pre-flight outputs (read-only, kickoff decision):** Clone current snapshot; **simulate removing all scaled inputs** on the clone, then simulate each scaled output add. If any output cannot be fully placed → **`inventory_full`** without mutations. Implementation: **`TrySimulateRemoveStack`** + **`TrySimulateAddStack`** on `PlayerInventoryOperations`.
6. **Commit mutations (only after steps 45 pass):** 6. **Commit mutations (only after steps 45 pass):**
- **Remove inputs:** foreach input row, **`TryRemoveStack`** scaled quantity. (Should succeed given pre-flight; defensive deny **`insufficient_materials`** if not.) - **Remove inputs:** foreach input row, **`TryRemoveStack`** scaled quantity. (Should succeed given pre-flight; defensive deny **`insufficient_materials`** if not.)

View File

@ -49,6 +49,12 @@ None.
- Nit: Compensating helpers discard rollback outcomes (`_ = TryAddStack` / `TryRemoveStack`) — same as gather engine; acceptable for prototype. - Nit: Compensating helpers discard rollback outcomes (`_ = TryAddStack` / `TryRemoveStack`) — same as gather engine; acceptable for prototype.
## Bugbot follow-up (PR #103)
1. ~~**Output pre-flight ignores input removal** — simulate output placement on snapshot with inputs still present; false `inventory_full` when input removal would free slots.~~ **Done.** Pre-flight now **`TrySimulateRemoveStack`** all inputs on clone before output simulation; test **`TryCraft_WhenBagFullWithScrapInputs_ShouldSucceedByFreeingInputSlots`**.
2. ~~**Missing output rollback in store-missing branch** — output-add `StoreMissing` restored inputs but not partial outputs.~~ **Done.** `CompensatingRemoveOutputs` added before input restore on that path.
## Verification ## Verification
```bash ```bash

View File

@ -119,14 +119,13 @@ public sealed class CraftOperationsTests
} }
[Fact] [Fact]
public void TryCraft_WhenBagFullForOutput_ShouldDenyWithInventoryFullWithoutConsumingInputs() public void TryCraft_WhenBagFullWithScrapInputs_ShouldSucceedByFreeingInputSlots()
{ {
// Arrange // Arrange
using var factory = new InMemoryWebApplicationFactory(); using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveCraftDependencies(factory); var deps = ResolveCraftDependencies(factory);
SeedStack(deps, "scrap_metal_bulk", 5); SeedStack(deps, "scrap_metal_bulk", 5);
FillBagExcept(deps, occupiedSlots: 1); FillBagExcept(deps, occupiedSlots: 1);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act // Act
var result = CraftOperations.TryCraft( var result = CraftOperations.TryCraft(
@ -141,10 +140,42 @@ public sealed class CraftOperationsTests
deps.LevelCurve, deps.LevelCurve,
deps.PerkUnlockEngine); deps.PerkUnlockEngine);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(result.Success);
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "refined_plate_stock"));
}
[Fact]
public void TryCraft_MakePrototypeArmor_WhenEquipmentOccupied_ShouldDenyWithInventoryFull()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveCraftDependencies(factory);
SeedStack(deps, "refined_plate_stock", 8);
SeedStack(deps, "scrap_metal_bulk", 5);
SeedStack(deps, "prototype_armor_shell", 1);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = CraftOperations.TryCraft(
PlayerId,
"make_prototype_armor",
quantity: 1,
deps.RecipeRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
// Assert // Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success); Assert.False(result.Success);
Assert.Equal(CraftReasonCodes.InventoryFull, result.ReasonCode); Assert.Equal(CraftReasonCodes.InventoryFull, result.ReasonCode);
Assert.Equal(8, CountBagItem(afterInventory!, "refined_plate_stock"));
Assert.Equal(5, CountBagItem(afterInventory!, "scrap_metal_bulk")); Assert.Equal(5, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
} }

View File

@ -57,6 +57,14 @@ public static class CraftOperations
} }
var simulated = CloneSnapshot(snapshot); var simulated = CloneSnapshot(snapshot);
foreach (var input in scaledInputs)
{
if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry))
{
return Deny(CraftReasonCodes.InsufficientMaterials);
}
}
foreach (var output in scaledOutputs) foreach (var output in scaledOutputs)
{ {
if (!PlayerInventoryOperations.TrySimulateAddStack(ref simulated, output.ItemId, output.Quantity, itemRegistry)) if (!PlayerInventoryOperations.TrySimulateAddStack(ref simulated, output.ItemId, output.Quantity, itemRegistry))
@ -102,6 +110,7 @@ public static class CraftOperations
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{ {
CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore);
CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore);
return Deny(CraftReasonCodes.InventoryStoreMissing); return Deny(CraftReasonCodes.InventoryStoreMissing);
} }

View File

@ -187,6 +187,54 @@ public static class PlayerInventoryOperations
return true; return true;
} }
/// <summary>
/// Simulates removing <paramref name="quantity"/> from a snapshot clone without persisting (NEO-69 craft pre-flight).
/// </summary>
public static bool TrySimulateRemoveStack(
ref PlayerInventorySnapshot snapshot,
string itemId,
int quantity,
IItemDefinitionRegistry registry)
{
if (quantity <= 0)
{
return false;
}
var lookup = itemId.Trim();
if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def))
{
return false;
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = PlayerInventorySnapshot.CloneSlots(snapshot.GetSlots(container));
if (CountQuantity(slots, def.Id) < quantity)
{
return false;
}
var remaining = quantity;
for (var i = 0; i < slots.Length && remaining > 0; i++)
{
var slot = slots[i];
if (slot.IsEmpty || !string.Equals(slot.ItemId, def.Id, StringComparison.Ordinal))
{
continue;
}
var take = Math.Min(remaining, slot.Quantity);
var left = slot.Quantity - take;
slots[i] = left <= 0
? new InventorySlotState(slot.SlotIndex, null, 0)
: new InventorySlotState(slot.SlotIndex, def.Id, left);
remaining -= take;
}
snapshot = snapshot.WithSlots(container, slots);
return true;
}
private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode) private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode)
{ {
// --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` --- // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` ---