NEO-147: add ContractGeneratorOperations.TryIssue orchestrator

pull/188/head
VinPropane 2026-06-27 19:15:11 -04:00
parent e0efaa3c15
commit 7f62f43f92
4 changed files with 217 additions and 0 deletions

View File

@ -0,0 +1,169 @@
using NeonSprawl.Server.Game.Factions;
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).
/// </summary>
public static class ContractGeneratorOperations
{
/// <summary>
/// Issues an active contract instance when template selection and store gates pass.
/// </summary>
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);
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Stable deny reason codes for <see cref="ContractGeneratorOperations.TryIssue"/> (NEO-147).</summary>
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";
}

View File

@ -1,3 +1,6 @@
using System.Security.Cryptography;
using System.Text;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Id normalization for contract instance stores (NEO-146).</summary>
@ -40,4 +43,23 @@ public static class ContractInstanceIds
return $"{p}\0{i}";
}
/// <summary>
/// Deterministic contract instance id from normalized seed inputs (NEO-147).
/// Format: <c>ci_{32 lowercase hex}</c> from SHA-256 of <c>{playerId}|{templateId}|{seedBucket}</c>.
/// </summary>
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))}";
}
}

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-internal contract issue resolution envelope (NEO-147).
/// HTTP DTOs via E7M4-08 (NEO-151).
/// </summary>
public readonly record struct ContractIssueOperationResult(
bool Success,
string? ReasonCode,
ContractInstanceState? Snapshot,
string? EncounterTemplateId);