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