466 lines
19 KiB
C#
466 lines
19 KiB
C#
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;
|
|
|
|
public sealed class ContractGeneratorOperationsTests
|
|
{
|
|
private const string PlayerId = "dev-local-1";
|
|
private const string UnknownPlayerId = "unknown-player-xyz";
|
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
|
private const string EncounterId = "prototype_combat_pocket";
|
|
private const string GridOperatorsFactionId = "prototype_faction_grid_operators";
|
|
private const string GatedTemplateId = "test_gated_contract_template";
|
|
private const string SeedBucket = "2026-06-22";
|
|
private const string AlternateSeedBucket = "2026-06-23";
|
|
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
|
|
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldCreateActiveInstance_WhenTemplateEligible()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
|
PlayerId,
|
|
TemplateId,
|
|
SeedBucket);
|
|
// Act
|
|
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Null(result.ReasonCode);
|
|
Assert.NotNull(result.Snapshot);
|
|
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
|
|
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
|
|
Assert.Equal(PlayerId, result.Snapshot.PlayerId);
|
|
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
|
|
Assert.Equal(SeedBucket, result.Snapshot.SeedBucket);
|
|
Assert.Equal(IssuedAt, result.Snapshot.IssuedAt);
|
|
Assert.Equal(EncounterId, result.EncounterTemplateId);
|
|
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
|
|
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldAutoSelectTemplate_WhenTemplateIdOmitted()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Equal(TemplateId, result.Snapshot!.TemplateId);
|
|
Assert.Equal(EncounterId, result.EncounterTemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket()
|
|
{
|
|
// Arrange
|
|
// Act
|
|
var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
|
|
var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
|
|
var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket);
|
|
// Assert
|
|
Assert.Equal(first, same);
|
|
Assert.NotEqual(first, different);
|
|
Assert.StartsWith("ci_", first, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyActiveContractExists_WhenPlayerAlreadyHasActiveInstance()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
|
Assert.True(first.Success);
|
|
// Act
|
|
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
|
|
// Assert
|
|
Assert.False(second.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, second.ReasonCode);
|
|
Assert.NotNull(second.Snapshot);
|
|
Assert.Equal(first.Snapshot!.ContractInstanceId, second.Snapshot!.ContractInstanceId);
|
|
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
|
|
Assert.Null(second.EncounterTemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldAllowNewIssue_WhenPriorInstanceCompleted()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
|
Assert.True(first.Success);
|
|
Assert.True(deps.InstanceStore.TryMarkComplete(
|
|
PlayerId,
|
|
first.Snapshot!.ContractInstanceId,
|
|
CompletedAt,
|
|
out _));
|
|
// Act
|
|
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
|
|
// Assert
|
|
Assert.True(second.Success);
|
|
Assert.NotEqual(first.Snapshot.ContractInstanceId, second.Snapshot!.ContractInstanceId);
|
|
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
|
|
Assert.Equal(EncounterId, second.EncounterTemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyUnknownTemplate_WhenExplicitTemplateMissing()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, templateId: "not_a_real_template", seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.UnknownTemplate, result.ReasonCode);
|
|
Assert.Null(result.Snapshot);
|
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenBandMismatch()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket, zoneDifficultyBand: 99);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
|
|
Assert.Null(result.Snapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenAutoSelectBandHasNoTemplates()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket, zoneDifficultyBand: 99);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin()
|
|
{
|
|
// Arrange
|
|
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
|
|
var gatedTemplate = prototype with
|
|
{
|
|
Id = GatedTemplateId,
|
|
MinFactionStanding = new FactionGateRuleRow(GridOperatorsFactionId, 1),
|
|
};
|
|
var deps = CreateDependencies(gatedTemplate);
|
|
// Act
|
|
var result = TryIssue(deps, templateId: GatedTemplateId, seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
|
|
Assert.Null(result.Snapshot);
|
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
|
Assert.True(first.Success);
|
|
Assert.True(deps.InstanceStore.TryMarkComplete(
|
|
PlayerId,
|
|
first.Snapshot!.ContractInstanceId,
|
|
CompletedAt,
|
|
out _));
|
|
// Act
|
|
var second = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.False(second.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, second.ReasonCode);
|
|
Assert.NotNull(second.Snapshot);
|
|
Assert.Equal(ContractInstanceStatus.Completed, second.Snapshot!.Status);
|
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyInvalidIds_WhenCreateActiveReturnsFalseWithoutSnapshot()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
var store = new CreateActiveNullSnapshotStore(deps.InstanceStore);
|
|
var templateRegistry = deps.TemplateRegistry;
|
|
var standingStore = deps.StandingStore;
|
|
// Act
|
|
var result = ContractGeneratorOperations.TryIssue(
|
|
PlayerId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
zoneDifficultyBand: null,
|
|
templateRegistry,
|
|
deps.EncounterRegistry,
|
|
deps.ItemRegistry,
|
|
deps.SkillRegistry,
|
|
deps.FactionRegistry,
|
|
store,
|
|
standingStore,
|
|
deps.TimeProvider);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
|
|
Assert.Null(result.Snapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, playerId: UnknownPlayerId, templateId: TemplateId, seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.PlayerNotWritable, result.ReasonCode);
|
|
Assert.Null(result.Snapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyInvalidIds_WhenPlayerIdEmpty()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, playerId: " ", templateId: TemplateId, seedBucket: SeedBucket);
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryIssue_ShouldDenyInvalidIds_WhenSeedBucketEmpty()
|
|
{
|
|
// Arrange
|
|
var deps = CreateDependencies();
|
|
// Act
|
|
var result = TryIssue(deps, templateId: TemplateId, seedBucket: " ");
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
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,
|
|
string? templateId = TemplateId,
|
|
string seedBucket = SeedBucket,
|
|
int? zoneDifficultyBand = null) =>
|
|
ContractGeneratorOperations.TryIssue(
|
|
playerId!,
|
|
templateId,
|
|
seedBucket,
|
|
zoneDifficultyBand,
|
|
deps.TemplateRegistry,
|
|
deps.EncounterRegistry,
|
|
deps.ItemRegistry,
|
|
deps.SkillRegistry,
|
|
deps.FactionRegistry,
|
|
deps.InstanceStore,
|
|
deps.StandingStore,
|
|
deps.TimeProvider);
|
|
|
|
private static GeneratorTestDependencies CreateDependencies(params ContractTemplateRow[] templates)
|
|
{
|
|
var rows = templates.Length == 0
|
|
? new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
|
{
|
|
[TemplateId] = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId],
|
|
}
|
|
: templates.ToDictionary(static t => t.Id, StringComparer.Ordinal);
|
|
var catalog = new ContractTemplateCatalog("/tmp/catalog", rows, catalogJsonFileCount: 1);
|
|
var templateRegistry = new ContractTemplateRegistry(catalog);
|
|
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)
|
|
{
|
|
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
|
|
};
|
|
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
|
var registry = new FactionDefinitionRegistry(catalog);
|
|
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
|
return new InMemoryFactionStandingStore(options, registry);
|
|
}
|
|
|
|
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => utcNow;
|
|
}
|
|
|
|
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
|
|
{
|
|
public bool CanWritePlayer(string playerId) => inner.CanWritePlayer(playerId);
|
|
|
|
public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) =>
|
|
inner.TryGet(playerId, contractInstanceId, out snapshot);
|
|
|
|
public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) =>
|
|
inner.TryGetActiveForPlayer(playerId, out snapshot);
|
|
|
|
public bool TryCreateActive(
|
|
string playerId,
|
|
string contractInstanceId,
|
|
string templateId,
|
|
string seedBucket,
|
|
DateTimeOffset issuedAt,
|
|
out ContractInstanceState snapshot)
|
|
{
|
|
snapshot = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryMarkComplete(
|
|
string playerId,
|
|
string contractInstanceId,
|
|
DateTimeOffset completedAt,
|
|
out ContractInstanceState snapshot) =>
|
|
inner.TryMarkComplete(playerId, contractInstanceId, completedAt, out snapshot);
|
|
|
|
public bool TryClearInstance(string playerId, string contractInstanceId) =>
|
|
inner.TryClearInstance(playerId, contractInstanceId);
|
|
}
|
|
}
|