NEO-150: Add issue-time contract economy validation

Introduce ContractEconomyValidation.TryValidateTemplate and wire it
into TryIssue before persist with economy_cap_exceeded and
invalid_reward_bundle deny codes.
pull/192/head
VinPropane 2026-06-28 09:20:09 -04:00
parent 039542aa74
commit 3d49230c3c
11 changed files with 392 additions and 3 deletions

View File

@ -32,6 +32,10 @@ public sealed class ContractCompletionOperationsIntegrationTests
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
deps.TemplateRegistry, deps.TemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.InstanceStore, deps.InstanceStore,
deps.StandingStore, deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
@ -123,6 +127,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(), services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(), services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(), services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(), services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(), services.GetRequiredService<IEncounterCompletionStore>(),
@ -147,6 +152,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
IContractInstanceStore InstanceStore, IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore, IContractOutcomeStore OutcomeStore,
IEncounterDefinitionRegistry EncounterRegistry, IEncounterDefinitionRegistry EncounterRegistry,
IFactionDefinitionRegistry FactionRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry, IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore, IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore, IEncounterCompletionStore CompletionStore,

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -165,6 +166,10 @@ public sealed class ContractCompletionOperationsTests
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
deps.TemplateRegistry, deps.TemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.InstanceStore, deps.InstanceStore,
deps.StandingStore, deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
@ -226,6 +231,8 @@ public sealed class ContractCompletionOperationsTests
services.GetRequiredService<IContractTemplateRegistry>(), services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(), services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>(),
services.GetRequiredService<IItemDefinitionRegistry>(), services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(), services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(), services.GetRequiredService<ISkillDefinitionRegistry>(),
@ -248,6 +255,8 @@ public sealed class ContractCompletionOperationsTests
IContractTemplateRegistry TemplateRegistry, IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore, IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore, IContractOutcomeStore OutcomeStore,
IEncounterDefinitionRegistry EncounterRegistry,
IFactionDefinitionRegistry FactionRegistry,
IItemDefinitionRegistry ItemRegistry, IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore, IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry, ISkillDefinitionRegistry SkillRegistry,

View File

@ -0,0 +1,140 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractEconomyValidationTests
{
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string GridOperatorsFactionId = "prototype_faction_grid_operators";
[Fact]
public void TryValidateTemplate_ShouldReturnTrue_WhenPrototypeFreezeRowAtCaps()
{
// Arrange
var template = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var registries = CreatePrototypeRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
template,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.True(ok);
Assert.Null(reasonCode);
}
[Fact]
public void TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var overCapTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("scrap_metal_bulk", 11)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var registries = CreatePrototypeRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
overCapTemplate,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.False(ok);
Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, reasonCode);
}
[Fact]
public void TryValidateTemplate_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var unknownItemTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("unknown_item_xyz", 1)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var registries = CreatePrototypeRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
unknownItemTemplate,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.False(ok);
Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, reasonCode);
}
private static CatalogRegistries CreatePrototypeRegistries()
{
var encounterRows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[EncounterId] = new(
EncounterId,
"Prototype Combat Pocket",
"defeat_all_targets",
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
"prototype_combat_pocket_clear"),
};
var encounterRegistry = new EncounterDefinitionRegistry(
new EncounterDefinitionCatalog("/tmp/encounters", encounterRows, catalogJsonFileCount: 1));
var itemRows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var itemRegistry = new ItemDefinitionRegistry(
new ItemDefinitionCatalog("/tmp/items", itemRows, catalogJsonFileCount: 1));
var skillRows = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
{
["salvage"] = new("salvage", "gather", "Salvage", ["activity", "mission_reward"]),
["refine"] = new("refine", "process", "Refine", ["activity", "mission_reward", "trainer"]),
["intrusion"] = new("intrusion", "tech", "Intrusion", ["activity", "mission_reward", "book_or_item"]),
};
var skillRegistry = new SkillDefinitionRegistry(
new SkillDefinitionCatalog("/tmp/skills", skillRows, catalogJsonFileCount: 1));
var factionRows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
};
var factionRegistry = new FactionDefinitionRegistry(
new FactionDefinitionCatalog("/tmp/factions", factionRows, catalogJsonFileCount: 1));
return new CatalogRegistries(encounterRegistry, itemRegistry, skillRegistry, factionRegistry);
}
private sealed record CatalogRegistries(
IEncounterDefinitionRegistry EncounterRegistry,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IFactionDefinitionRegistry FactionRegistry);
}

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;
@ -27,12 +30,20 @@ public sealed class ContractGeneratorOperationsIntegrationTests
TemplateId, TemplateId,
SeedBucket); SeedBucket);
// Act // Act
var encounterRegistry = scope.ServiceProvider.GetRequiredService<IEncounterDefinitionRegistry>();
var itemRegistry = scope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
var skillRegistry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var factionRegistry = scope.ServiceProvider.GetRequiredService<IFactionDefinitionRegistry>();
var result = ContractGeneratorOperations.TryIssue( var result = ContractGeneratorOperations.TryIssue(
PlayerId, PlayerId,
TemplateId, TemplateId,
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
templateRegistry, templateRegistry,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
instanceStore, instanceStore,
standingStore, standingStore,
timeProvider); timeProvider);

View File

@ -1,7 +1,10 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;
@ -206,6 +209,10 @@ public sealed class ContractGeneratorOperationsTests
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
templateRegistry, templateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
store, store,
standingStore, standingStore,
deps.TimeProvider); deps.TimeProvider);
@ -252,6 +259,50 @@ public sealed class ContractGeneratorOperationsTests
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode); Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
} }
[Fact]
public void TryIssue_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var overCapTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("scrap_metal_bulk", 11)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var deps = CreateDependencies(overCapTemplate);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var unknownItemTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("unknown_item_xyz", 1)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var deps = CreateDependencies(unknownItemTemplate);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
private static ContractIssueOperationResult TryIssue( private static ContractIssueOperationResult TryIssue(
GeneratorTestDependencies deps, GeneratorTestDependencies deps,
string? playerId = PlayerId, string? playerId = PlayerId,
@ -264,6 +315,10 @@ public sealed class ContractGeneratorOperationsTests
seedBucket, seedBucket,
zoneDifficultyBand, zoneDifficultyBand,
deps.TemplateRegistry, deps.TemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.InstanceStore, deps.InstanceStore,
deps.StandingStore, deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
@ -281,13 +336,66 @@ public sealed class ContractGeneratorOperationsTests
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
var instanceStore = new InMemoryContractInstanceStore(positionOptions); var instanceStore = new InMemoryContractInstanceStore(positionOptions);
var standingStore = CreateStandingStore(); var standingStore = CreateStandingStore();
var catalogRegistries = CreatePrototypeCatalogRegistries();
return new GeneratorTestDependencies( return new GeneratorTestDependencies(
templateRegistry, templateRegistry,
catalogRegistries.EncounterRegistry,
catalogRegistries.ItemRegistry,
catalogRegistries.SkillRegistry,
catalogRegistries.FactionRegistry,
instanceStore, instanceStore,
standingStore, standingStore,
new FakeTimeProvider(IssuedAt)); new FakeTimeProvider(IssuedAt));
} }
private static CatalogRegistries CreatePrototypeCatalogRegistries()
{
var encounterRows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[EncounterId] = new(
EncounterId,
"Prototype Combat Pocket",
"defeat_all_targets",
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
"prototype_combat_pocket_clear"),
};
var encounterRegistry = new EncounterDefinitionRegistry(
new EncounterDefinitionCatalog("/tmp/encounters", encounterRows, catalogJsonFileCount: 1));
var itemRows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var itemRegistry = new ItemDefinitionRegistry(
new ItemDefinitionCatalog("/tmp/items", itemRows, catalogJsonFileCount: 1));
var skillRows = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
{
["salvage"] = new("salvage", "gather", "Salvage", ["activity", "mission_reward"]),
["refine"] = new("refine", "process", "Refine", ["activity", "mission_reward", "trainer"]),
["intrusion"] = new("intrusion", "tech", "Intrusion", ["activity", "mission_reward", "book_or_item"]),
};
var skillRegistry = new SkillDefinitionRegistry(
new SkillDefinitionCatalog("/tmp/skills", skillRows, catalogJsonFileCount: 1));
var factionRows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
};
var factionRegistry = new FactionDefinitionRegistry(
new FactionDefinitionCatalog("/tmp/factions", factionRows, catalogJsonFileCount: 1));
return new CatalogRegistries(encounterRegistry, itemRegistry, skillRegistry, factionRegistry);
}
private static InMemoryFactionStandingStore CreateStandingStore() private static InMemoryFactionStandingStore CreateStandingStore()
{ {
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal) var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
@ -307,10 +415,20 @@ public sealed class ContractGeneratorOperationsTests
private readonly record struct GeneratorTestDependencies( private readonly record struct GeneratorTestDependencies(
IContractTemplateRegistry TemplateRegistry, IContractTemplateRegistry TemplateRegistry,
IEncounterDefinitionRegistry EncounterRegistry,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IFactionDefinitionRegistry FactionRegistry,
IContractInstanceStore InstanceStore, IContractInstanceStore InstanceStore,
IFactionStandingStore StandingStore, IFactionStandingStore StandingStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
private sealed record CatalogRegistries(
IEncounterDefinitionRegistry EncounterRegistry,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IFactionDefinitionRegistry FactionRegistry);
/// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary> /// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary>
private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore
{ {

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState;
@ -36,6 +39,10 @@ public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegra
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
services.GetRequiredService<IContractTemplateRegistry>(), services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>(),
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IFactionStandingStore>(), services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<TimeProvider>()); services.GetRequiredService<TimeProvider>());

View File

@ -28,6 +28,10 @@ public sealed class EncounterCompletionOperationsTests
"2026-06-22", "2026-06-22",
zoneDifficultyBand: null, zoneDifficultyBand: null,
deps.ContractTemplateRegistry, deps.ContractTemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.ContractInstanceStore, deps.ContractInstanceStore,
deps.StandingStore, deps.StandingStore,
TimeProvider.System); TimeProvider.System);
@ -513,7 +517,8 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IReputationDeltaStore>(), services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(), services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>()); services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -564,7 +569,8 @@ public sealed class EncounterCompletionOperationsTests
IReputationDeltaStore AuditStore, IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore, IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore, IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry); IContractTemplateRegistry ContractTemplateRegistry,
IFactionDefinitionRegistry FactionRegistry);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{ {

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -291,12 +292,18 @@ public sealed class QuestFixtureApiTests
var timeProvider = factory.Services.GetRequiredService<TimeProvider>(); var timeProvider = factory.Services.GetRequiredService<TimeProvider>();
const string templateId = "prototype_contract_clear_combat_pocket"; const string templateId = "prototype_contract_clear_combat_pocket";
const string encounterId = "prototype_combat_pocket"; const string encounterId = "prototype_combat_pocket";
var encounterRegistry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var factionRegistry = factory.Services.GetRequiredService<IFactionDefinitionRegistry>();
var issued = ContractGeneratorOperations.TryIssue( var issued = ContractGeneratorOperations.TryIssue(
DevPlayer, DevPlayer,
templateId, templateId,
"2026-06-22", "2026-06-22",
zoneDifficultyBand: null, zoneDifficultyBand: null,
templateRegistry, templateRegistry,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
instanceStore, instanceStore,
standingStore, standingStore,
timeProvider); timeProvider);

View File

@ -0,0 +1,63 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Issue-time economy and reward-bundle validation for contract templates (NEO-150).
/// Mirrors startup catalog gates for defense in depth at <see cref="ContractGeneratorOperations.TryIssue"/>.
/// </summary>
public static class ContractEconomyValidation
{
/// <summary>
/// Validates band caps and cross-refs for one template against live registries.
/// </summary>
public static bool TryValidateTemplate(
ContractTemplateRow template,
IEncounterDefinitionRegistry encounterRegistry,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IFactionDefinitionRegistry factionRegistry,
out string? reasonCode)
{
ArgumentNullException.ThrowIfNull(encounterRegistry);
ArgumentNullException.ThrowIfNull(itemRegistry);
ArgumentNullException.ThrowIfNull(skillRegistry);
ArgumentNullException.ThrowIfNull(factionRegistry);
reasonCode = null;
var rowsById = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[template.Id] = template,
};
if (PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rowsById) is not null)
{
reasonCode = ContractGeneratorReasonCodes.EconomyCapExceeded;
return false;
}
var knownEncounterIds = encounterRegistry.GetDefinitionsInIdOrder()
.Select(static d => d.Id)
.ToHashSet(StringComparer.Ordinal);
var knownFactionIds = factionRegistry.GetDefinitionsInIdOrder()
.Select(static d => d.Id)
.ToHashSet(StringComparer.Ordinal);
var skillDefsById = skillRegistry.GetDefinitionsInIdOrder()
.ToDictionary(static d => d.Id, StringComparer.Ordinal);
if (PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
rowsById,
knownEncounterIds,
knownFactionIds,
skillDefsById) is not null)
{
reasonCode = ContractGeneratorReasonCodes.InvalidRewardBundle;
return false;
}
return true;
}
}

View File

@ -1,10 +1,13 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts; namespace NeonSprawl.Server.Game.Contracts;
/// <summary> /// <summary>
/// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147). /// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147).
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152). /// HTTP: E7M4-08 (NEO-151). Economy validation: E7M4-07 (NEO-150). Telemetry hook sites: E7M4-09 (NEO-152).
/// </summary> /// </summary>
public static class ContractGeneratorOperations public static class ContractGeneratorOperations
{ {
@ -17,6 +20,10 @@ public static class ContractGeneratorOperations
string seedBucket, string seedBucket,
int? zoneDifficultyBand, int? zoneDifficultyBand,
IContractTemplateRegistry templateRegistry, IContractTemplateRegistry templateRegistry,
IEncounterDefinitionRegistry encounterRegistry,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IFactionDefinitionRegistry factionRegistry,
IContractInstanceStore instanceStore, IContractInstanceStore instanceStore,
IFactionStandingStore standingStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) TimeProvider timeProvider)
@ -52,6 +59,17 @@ public static class ContractGeneratorOperations
return Deny(denyReason!); return Deny(denyReason!);
} }
if (!ContractEconomyValidation.TryValidateTemplate(
template!,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
out var economyReason))
{
return Deny(economyReason!);
}
var instanceId = ContractInstanceIds.MakeDeterministicInstanceId( var instanceId = ContractInstanceIds.MakeDeterministicInstanceId(
normalizedPlayerId, normalizedPlayerId,
template!.Id, template!.Id,

View File

@ -12,4 +12,8 @@ public static class ContractGeneratorReasonCodes
public const string NoEligibleTemplate = "no_eligible_template"; public const string NoEligibleTemplate = "no_eligible_template";
public const string UnknownTemplate = "unknown_template"; public const string UnknownTemplate = "unknown_template";
public const string EconomyCapExceeded = "economy_cap_exceeded";
public const string InvalidRewardBundle = "invalid_reward_bundle";
} }