198 lines
6.8 KiB
C#
198 lines
6.8 KiB
C#
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Factions;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.Skills;
|
|
|
|
namespace NeonSprawl.Server.Game.Contracts;
|
|
|
|
/// <summary>
|
|
/// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147).
|
|
/// HTTP: E7M4-08 (NEO-151). Economy validation: E7M4-07 (NEO-150). 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,
|
|
IEncounterDefinitionRegistry encounterRegistry,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IFactionDefinitionRegistry factionRegistry,
|
|
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!);
|
|
}
|
|
|
|
if (!ContractEconomyValidation.TryValidateTemplate(
|
|
template!,
|
|
encounterRegistry,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
factionRegistry,
|
|
out var economyReason))
|
|
{
|
|
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `reward_anomaly` ---
|
|
// TODO(E9.M1): catalog emit — economy validation deny at issue (defense-in-depth; startup load should prevent most).
|
|
// Planned payload fields: playerId, templateId, reasonCode (economy_cap_exceeded | invalid_reward_bundle).
|
|
// No ingest or ILogger here (comments-only).
|
|
|
|
return Deny(economyReason!);
|
|
}
|
|
|
|
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))
|
|
{
|
|
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_issued` ---
|
|
// TODO(E9.M1): catalog emit — first-time active instance persist only (deny paths excluded).
|
|
// Planned payload fields: playerId, contractInstanceId, templateId, seedBucket, encounterTemplateId, issuedAt.
|
|
// No ingest or ILogger here (comments-only).
|
|
|
|
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 is { 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);
|
|
}
|