NEO-147: add ContractGeneratorOperations.TryIssue orchestrator
parent
e0efaa3c15
commit
7f62f43f92
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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))}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue