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
parent
039542aa74
commit
3d49230c3c
|
|
@ -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<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IContractOutcomeStore>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>(),
|
||||
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
|
||||
services.GetRequiredService<IEncounterProgressStore>(),
|
||||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
|
|
@ -147,6 +152,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
|
|||
IContractInstanceStore InstanceStore,
|
||||
IContractOutcomeStore OutcomeStore,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IFactionDefinitionRegistry FactionRegistry,
|
||||
IRewardTableDefinitionRegistry RewardTableRegistry,
|
||||
IEncounterProgressStore ProgressStore,
|
||||
IEncounterCompletionStore CompletionStore,
|
||||
|
|
|
|||
|
|
@ -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<IContractTemplateRegistry>(),
|
||||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IContractOutcomeStore>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
|
|
@ -248,6 +255,8 @@ public sealed class ContractCompletionOperationsTests
|
|||
IContractTemplateRegistry TemplateRegistry,
|
||||
IContractInstanceStore InstanceStore,
|
||||
IContractOutcomeStore OutcomeStore,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IFactionDefinitionRegistry FactionRegistry,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<IEncounterDefinitionRegistry>();
|
||||
var itemRegistry = scope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var skillRegistry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||
var factionRegistry = scope.ServiceProvider.GetRequiredService<IFactionDefinitionRegistry>();
|
||||
var result = ContractGeneratorOperations.TryIssue(
|
||||
PlayerId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
zoneDifficultyBand: null,
|
||||
templateRegistry,
|
||||
encounterRegistry,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
factionRegistry,
|
||||
instanceStore,
|
||||
standingStore,
|
||||
timeProvider);
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
{
|
||||
var byId = new Dictionary<string, FactionDefRow>(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);
|
||||
|
||||
/// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary>
|
||||
private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<IContractTemplateRegistry>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>(),
|
||||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
services.GetRequiredService<TimeProvider>());
|
||||
|
|
|
|||
|
|
@ -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<IReputationDeltaStore>(),
|
||||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IContractOutcomeStore>(),
|
||||
services.GetRequiredService<IContractTemplateRegistry>());
|
||||
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>());
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<TimeProvider>();
|
||||
const string templateId = "prototype_contract_clear_combat_pocket";
|
||||
const string encounterId = "prototype_combat_pocket";
|
||||
var encounterRegistry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var factionRegistry = factory.Services.GetRequiredService<IFactionDefinitionRegistry>();
|
||||
var issued = ContractGeneratorOperations.TryIssue(
|
||||
DevPlayer,
|
||||
templateId,
|
||||
"2026-06-22",
|
||||
zoneDifficultyBand: null,
|
||||
templateRegistry,
|
||||
encounterRegistry,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
factionRegistry,
|
||||
instanceStore,
|
||||
standingStore,
|
||||
timeProvider);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue