NEO-147: guard DenyCreateFailure when create snapshot is null

pull/188/head
VinPropane 2026-06-27 19:43:44 -04:00
parent 81f4cde673
commit c237bead53
3 changed files with 61 additions and 3 deletions

View File

@ -70,7 +70,7 @@ Issue a validated **`active`** contract instance from **`ContractSeed`** inputs
- **Types:** `ContractGeneratorReasonCodes`, `ContractIssueOperationResult`, `ContractInstanceIds.MakeDeterministicInstanceId`.
- **Orchestrator:** `ContractGeneratorOperations.TryIssue` — band + faction template selection, deterministic id, store persist.
- **Tests:** `ContractGeneratorOperationsTests` (13 unit), `ContractGeneratorOperationsIntegrationTests` (DI), `ContractGeneratorPersistenceIntegrationTests` (`RequirePostgresFact`); **890** tests green.
- **Tests:** `ContractGeneratorOperationsTests` (14 unit), `ContractGeneratorOperationsIntegrationTests` (DI), `ContractGeneratorPersistenceIntegrationTests` (`RequirePostgresFact`); **891** tests green.
- **Docs:** `server/README.md` contract generator section; `E7_M4_ContractMissionGenerator.md` NEO-147 note.
## Technical approach

View File

@ -195,6 +195,30 @@ public sealed class ContractGeneratorOperationsTests
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenCreateActiveReturnsFalseWithoutSnapshot()
{
// Arrange
var deps = CreateDependencies();
var store = new CreateActiveNullSnapshotStore(deps.InstanceStore);
var templateRegistry = deps.TemplateRegistry;
var standingStore = deps.StandingStore;
// Act
var result = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
templateRegistry,
store,
standingStore,
deps.TimeProvider);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown()
{
@ -290,4 +314,38 @@ public sealed class ContractGeneratorOperationsTests
IContractInstanceStore InstanceStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider);
/// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary>
private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore
{
public bool CanWritePlayer(string playerId) => inner.CanWritePlayer(playerId);
public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) =>
inner.TryGet(playerId, contractInstanceId, out snapshot);
public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) =>
inner.TryGetActiveForPlayer(playerId, out snapshot);
public bool TryCreateActive(
string playerId,
string contractInstanceId,
string templateId,
string seedBucket,
DateTimeOffset issuedAt,
out ContractInstanceState snapshot)
{
snapshot = null!;
return false;
}
public bool TryMarkComplete(
string playerId,
string contractInstanceId,
DateTimeOffset completedAt,
out ContractInstanceState snapshot) =>
inner.TryMarkComplete(playerId, contractInstanceId, completedAt, out snapshot);
public bool TryClearInstance(string playerId, string contractInstanceId) =>
inner.TryClearInstance(playerId, contractInstanceId);
}
}

View File

@ -142,14 +142,14 @@ public static class ContractGeneratorOperations
private static ContractIssueOperationResult DenyCreateFailure(
IContractInstanceStore instanceStore,
string normalizedPlayerId,
ContractInstanceState attemptSnapshot)
ContractInstanceState? attemptSnapshot)
{
if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var active))
{
return Deny(ContractGeneratorReasonCodes.ActiveContractExists, active);
}
if (attemptSnapshot.Status == ContractInstanceStatus.Active)
if (attemptSnapshot is { Status: ContractInstanceStatus.Active })
{
return Deny(ContractGeneratorReasonCodes.ActiveContractExists, attemptSnapshot);
}