From 7f62f43f92aa9a56b4584fdc33b4d56c8f1ed545 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 19:15:11 -0400 Subject: [PATCH] NEO-147: add ContractGeneratorOperations.TryIssue orchestrator --- .../Contracts/ContractGeneratorOperations.cs | 169 ++++++++++++++++++ .../Contracts/ContractGeneratorReasonCodes.cs | 15 ++ .../Game/Contracts/ContractInstanceIds.cs | 22 +++ .../Contracts/ContractIssueOperationResult.cs | 11 ++ 4 files changed, 217 insertions(+) create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs new file mode 100644 index 0000000..8745c07 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs @@ -0,0 +1,169 @@ +using NeonSprawl.Server.Game.Factions; + +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147). +/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152). +/// +public static class ContractGeneratorOperations +{ + /// + /// Issues an active contract instance when template selection and store gates pass. + /// + public static ContractIssueOperationResult TryIssue( + string playerId, + string? templateId, + string seedBucket, + int? zoneDifficultyBand, + IContractTemplateRegistry templateRegistry, + IContractInstanceStore instanceStore, + IFactionStandingStore standingStore, + TimeProvider timeProvider) + { + var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(seedBucket); + var band = zoneDifficultyBand ?? 1; + + if (normalizedPlayerId.Length == 0 || normalizedSeedBucket.Length == 0) + { + return Deny(ContractGeneratorReasonCodes.InvalidIds); + } + + if (!instanceStore.CanWritePlayer(normalizedPlayerId)) + { + return Deny(ContractGeneratorReasonCodes.PlayerNotWritable); + } + + if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot)) + { + return Deny(ContractGeneratorReasonCodes.ActiveContractExists, activeSnapshot); + } + + if (!TrySelectTemplate( + templateId, + band, + normalizedPlayerId, + templateRegistry, + standingStore, + out var template, + out var denyReason)) + { + return Deny(denyReason!); + } + + var instanceId = ContractInstanceIds.MakeDeterministicInstanceId( + normalizedPlayerId, + template!.Id, + normalizedSeedBucket); + if (instanceId.Length == 0) + { + return Deny(ContractGeneratorReasonCodes.InvalidIds); + } + + if (instanceStore.TryCreateActive( + normalizedPlayerId, + instanceId, + template.Id, + normalizedSeedBucket, + timeProvider.GetUtcNow(), + out var snapshot)) + { + return Success(snapshot, template.EncounterTemplateId); + } + + return DenyCreateFailure(instanceStore, normalizedPlayerId, snapshot); + } + + private static bool TrySelectTemplate( + string? templateId, + int band, + string normalizedPlayerId, + IContractTemplateRegistry templateRegistry, + IFactionStandingStore standingStore, + out ContractTemplateRow? template, + out string? denyReason) + { + template = null; + denyReason = null; + + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(templateId); + if (normalizedTemplateId.Length > 0) + { + if (!templateRegistry.TryGetDefinition(normalizedTemplateId, out template)) + { + denyReason = ContractGeneratorReasonCodes.UnknownTemplate; + return false; + } + + if (!IsEligible(template, band, normalizedPlayerId, standingStore)) + { + denyReason = ContractGeneratorReasonCodes.NoEligibleTemplate; + template = null; + return false; + } + + return true; + } + + foreach (var candidate in templateRegistry.GetDefinitionsInIdOrder()) + { + if (!IsEligible(candidate, band, normalizedPlayerId, standingStore)) + { + continue; + } + + template = candidate; + return true; + } + + denyReason = ContractGeneratorReasonCodes.NoEligibleTemplate; + return false; + } + + private static bool IsEligible( + ContractTemplateRow template, + int band, + string normalizedPlayerId, + IFactionStandingStore standingStore) + { + if (template.ZoneDifficultyBand != band) + { + return false; + } + + var gate = FactionGateOperations.TryEvaluate( + normalizedPlayerId, + [template.MinFactionStanding], + standingStore); + return gate.Success; + } + + private static ContractIssueOperationResult DenyCreateFailure( + IContractInstanceStore instanceStore, + string normalizedPlayerId, + ContractInstanceState attemptSnapshot) + { + if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var active)) + { + return Deny(ContractGeneratorReasonCodes.ActiveContractExists, active); + } + + if (attemptSnapshot.Status == ContractInstanceStatus.Active) + { + return Deny(ContractGeneratorReasonCodes.ActiveContractExists, attemptSnapshot); + } + + return Deny(ContractGeneratorReasonCodes.InvalidIds, attemptSnapshot); + } + + private static ContractIssueOperationResult Success( + ContractInstanceState snapshot, + string encounterTemplateId) => + new(true, null, snapshot, encounterTemplateId); + + private static ContractIssueOperationResult Deny( + string reasonCode, + ContractInstanceState? snapshot = null) => + new(false, reasonCode, snapshot, null); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs new file mode 100644 index 0000000..67be3ba --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Stable deny reason codes for (NEO-147). +public static class ContractGeneratorReasonCodes +{ + public const string PlayerNotWritable = ContractInstanceReasonCodes.PlayerNotWritable; + + public const string ActiveContractExists = ContractInstanceReasonCodes.ActiveContractExists; + + public const string InvalidIds = ContractInstanceReasonCodes.InvalidIds; + + public const string NoEligibleTemplate = "no_eligible_template"; + + public const string UnknownTemplate = "unknown_template"; +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs index 3317557..963bb19 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; + namespace NeonSprawl.Server.Game.Contracts; /// Id normalization for contract instance stores (NEO-146). @@ -40,4 +43,23 @@ public static class ContractInstanceIds return $"{p}\0{i}"; } + + /// + /// Deterministic contract instance id from normalized seed inputs (NEO-147). + /// Format: ci_{32 lowercase hex} from SHA-256 of {playerId}|{templateId}|{seedBucket}. + /// + public static string MakeDeterministicInstanceId(string? playerId, string? templateId, string? seedBucket) + { + var p = NormalizePlayerId(playerId); + var t = NormalizeTemplateId(templateId); + var b = NormalizeSeedBucket(seedBucket); + if (p.Length == 0 || t.Length == 0 || b.Length == 0) + { + return string.Empty; + } + + var payload = $"{p}|{t}|{b}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return $"ci_{Convert.ToHexStringLower(hash.AsSpan(0, 16))}"; + } } diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs b/server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs new file mode 100644 index 0000000..afea0dd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Server-internal contract issue resolution envelope (NEO-147). +/// HTTP DTOs via E7M4-08 (NEO-151). +/// +public readonly record struct ContractIssueOperationResult( + bool Success, + string? ReasonCode, + ContractInstanceState? Snapshot, + string? EncounterTemplateId);