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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue