From 3d49230c3c8a86ba4278fbe03c35a359abee8ff1 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 09:20:09 -0400 Subject: [PATCH] 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. --- ...actCompletionOperationsIntegrationTests.cs | 6 + .../ContractCompletionOperationsTests.cs | 9 ++ .../ContractEconomyValidationTests.cs | 140 ++++++++++++++++++ ...ractGeneratorOperationsIntegrationTests.cs | 11 ++ .../ContractGeneratorOperationsTests.cs | 118 +++++++++++++++ ...actGeneratorPersistenceIntegrationTests.cs | 7 + .../EncounterCompletionOperationsTests.cs | 10 +- .../Game/Quests/QuestFixtureApiTests.cs | 7 + .../Contracts/ContractEconomyValidation.cs | 63 ++++++++ .../Contracts/ContractGeneratorOperations.cs | 20 ++- .../Contracts/ContractGeneratorReasonCodes.cs | 4 + 11 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs index 77bc2ba..96b7364 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs @@ -32,6 +32,10 @@ public sealed class ContractCompletionOperationsIntegrationTests SeedBucket, zoneDifficultyBand: null, deps.TemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); @@ -123,6 +127,7 @@ public sealed class ContractCompletionOperationsIntegrationTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), @@ -147,6 +152,7 @@ public sealed class ContractCompletionOperationsIntegrationTests IContractInstanceStore InstanceStore, IContractOutcomeStore OutcomeStore, IEncounterDefinitionRegistry EncounterRegistry, + IFactionDefinitionRegistry FactionRegistry, IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs index 5f77030..9a57dc4 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -165,6 +166,10 @@ public sealed class ContractCompletionOperationsTests SeedBucket, zoneDifficultyBand: null, deps.TemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); @@ -226,6 +231,8 @@ public sealed class ContractCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), @@ -248,6 +255,8 @@ public sealed class ContractCompletionOperationsTests IContractTemplateRegistry TemplateRegistry, IContractInstanceStore InstanceStore, IContractOutcomeStore OutcomeStore, + IEncounterDefinitionRegistry EncounterRegistry, + IFactionDefinitionRegistry FactionRegistry, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, ISkillDefinitionRegistry SkillRegistry, diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs new file mode 100644 index 0000000..2bf9563 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs @@ -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(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(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(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(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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs index 6e9580c..9de5f42 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; @@ -27,12 +30,20 @@ public sealed class ContractGeneratorOperationsIntegrationTests TemplateId, SeedBucket); // Act + var encounterRegistry = scope.ServiceProvider.GetRequiredService(); + var itemRegistry = scope.ServiceProvider.GetRequiredService(); + var skillRegistry = scope.ServiceProvider.GetRequiredService(); + var factionRegistry = scope.ServiceProvider.GetRequiredService(); var result = ContractGeneratorOperations.TryIssue( PlayerId, TemplateId, SeedBucket, zoneDifficultyBand: null, templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, instanceStore, standingStore, timeProvider); diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs index f10f791..078256b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -1,7 +1,10 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; @@ -206,6 +209,10 @@ public sealed class ContractGeneratorOperationsTests SeedBucket, zoneDifficultyBand: null, templateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, store, standingStore, deps.TimeProvider); @@ -252,6 +259,50 @@ public sealed class ContractGeneratorOperationsTests 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( GeneratorTestDependencies deps, string? playerId = PlayerId, @@ -264,6 +315,10 @@ public sealed class ContractGeneratorOperationsTests seedBucket, zoneDifficultyBand, deps.TemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); @@ -281,13 +336,66 @@ public sealed class ContractGeneratorOperationsTests var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); var instanceStore = new InMemoryContractInstanceStore(positionOptions); var standingStore = CreateStandingStore(); + var catalogRegistries = CreatePrototypeCatalogRegistries(); return new GeneratorTestDependencies( templateRegistry, + catalogRegistries.EncounterRegistry, + catalogRegistries.ItemRegistry, + catalogRegistries.SkillRegistry, + catalogRegistries.FactionRegistry, instanceStore, standingStore, new FakeTimeProvider(IssuedAt)); } + private static CatalogRegistries CreatePrototypeCatalogRegistries() + { + var encounterRows = new Dictionary(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(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(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(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() { var byId = new Dictionary(StringComparer.Ordinal) @@ -307,10 +415,20 @@ public sealed class ContractGeneratorOperationsTests private readonly record struct GeneratorTestDependencies( IContractTemplateRegistry TemplateRegistry, + IEncounterDefinitionRegistry EncounterRegistry, + IItemDefinitionRegistry ItemRegistry, + ISkillDefinitionRegistry SkillRegistry, + IFactionDefinitionRegistry FactionRegistry, IContractInstanceStore InstanceStore, IFactionStandingStore StandingStore, TimeProvider TimeProvider); + private sealed record CatalogRegistries( + IEncounterDefinitionRegistry EncounterRegistry, + IItemDefinitionRegistry ItemRegistry, + ISkillDefinitionRegistry SkillRegistry, + IFactionDefinitionRegistry FactionRegistry); + /// Simulates store create failure without populating the out snapshot (Bugbot regression). private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs index 5278cc6..2721d2c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState; @@ -36,6 +39,10 @@ public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegra SeedBucket, zoneDifficultyBand: null, services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 4a585ec..dc3516a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -28,6 +28,10 @@ public sealed class EncounterCompletionOperationsTests "2026-06-22", zoneDifficultyBand: null, deps.ContractTemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.ContractInstanceStore, deps.StandingStore, TimeProvider.System); @@ -513,7 +517,8 @@ public sealed class EncounterCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -564,7 +569,8 @@ public sealed class EncounterCompletionOperationsTests IReputationDeltaStore AuditStore, IContractInstanceStore ContractInstanceStore, IContractOutcomeStore ContractOutcomeStore, - IContractTemplateRegistry ContractTemplateRegistry); + IContractTemplateRegistry ContractTemplateRegistry, + IFactionDefinitionRegistry FactionRegistry); private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs index 979ca77..0369c39 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting; using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -291,12 +292,18 @@ public sealed class QuestFixtureApiTests var timeProvider = factory.Services.GetRequiredService(); const string templateId = "prototype_contract_clear_combat_pocket"; const string encounterId = "prototype_combat_pocket"; + var encounterRegistry = factory.Services.GetRequiredService(); + var factionRegistry = factory.Services.GetRequiredService(); var issued = ContractGeneratorOperations.TryIssue( DevPlayer, templateId, "2026-06-22", zoneDifficultyBand: null, templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, instanceStore, standingStore, timeProvider); diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs b/server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs new file mode 100644 index 0000000..61e0ae5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs @@ -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; + +/// +/// Issue-time economy and reward-bundle validation for contract templates (NEO-150). +/// Mirrors startup catalog gates for defense in depth at . +/// +public static class ContractEconomyValidation +{ + /// + /// Validates band caps and cross-refs for one template against live registries. + /// + 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(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; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs index ba158b9..8919803 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs @@ -1,10 +1,13 @@ +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; /// /// 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). /// public static class ContractGeneratorOperations { @@ -17,6 +20,10 @@ public static class ContractGeneratorOperations string seedBucket, int? zoneDifficultyBand, IContractTemplateRegistry templateRegistry, + IEncounterDefinitionRegistry encounterRegistry, + IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IFactionDefinitionRegistry factionRegistry, IContractInstanceStore instanceStore, IFactionStandingStore standingStore, TimeProvider timeProvider) @@ -52,6 +59,17 @@ public static class ContractGeneratorOperations return Deny(denyReason!); } + if (!ContractEconomyValidation.TryValidateTemplate( + template!, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, + out var economyReason)) + { + return Deny(economyReason!); + } + var instanceId = ContractInstanceIds.MakeDeterministicInstanceId( normalizedPlayerId, template!.Id, diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs index 67be3ba..b4fc34a 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs @@ -12,4 +12,8 @@ public static class ContractGeneratorReasonCodes public const string NoEligibleTemplate = "no_eligible_template"; public const string UnknownTemplate = "unknown_template"; + + public const string EconomyCapExceeded = "economy_cap_exceeded"; + + public const string InvalidRewardBundle = "invalid_reward_bundle"; }