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,
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,

View File

@ -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,

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.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);

View File

@ -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
{

View File

@ -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>());

View File

@ -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
{

View File

@ -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);

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.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,

View File

@ -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";
}