using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Gathering; /// /// Orchestrates gather side effects: capacity pre-check, inventory grant, skill XP, depletion commit (NEO-62). /// Wired from on resource_node interact (NEO-63). /// NEO-64 telemetry hook sites: (resource_gathered, gather_node_depleted). /// public static class GatherOperations { private static readonly GatherGrantApplied[] EmptyGrants = []; /// /// Resolves one gather for at . /// Mutations commit in order: inventory add → XP grant → depletion decrement (last). /// public static GatherResult TryGather( string playerId, string interactableId, IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IResourceNodeInstanceStore instanceStore) { var key = ResourceNodeInstanceIds.Normalize(interactableId); if (key.Length == 0 || !nodeRegistry.TryGetDefinition(key, out var definition) || !nodeRegistry.TryGetYield(key, out var yield)) { return Deny(GatherReasonCodes.UnknownNode, null); } if (!instanceStore.TryEnsureInitialized(key, definition.MaxGathers)) { throw new InvalidOperationException( $"Failed to initialize resource node instance '{key}' with maxGathers {definition.MaxGathers}."); } if (!instanceStore.TryGetRemainingGathers(key, out var capacity) || capacity.RemainingGathers <= 0) { var remaining = capacity.RemainingGathers; return Deny(GatherReasonCodes.NodeDepleted, remaining); } var inventoryOutcome = PlayerInventoryOperations.TryAddStack( playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore); if (inventoryOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) { return Deny(GatherReasonCodes.InventoryStoreMissing, capacity.RemainingGathers); } if (inventoryOutcome.Kind == PlayerInventoryMutationKind.Denied) { return Deny(inventoryOutcome.ReasonCode ?? GatherReasonCodes.InventoryFull, capacity.RemainingGathers); } var xpOutcome = SkillProgressionGrantOperations.TryApplyGrant( playerId, GatherSkillXpConstants.SalvageSkillId, GatherSkillXpConstants.ActivityXpPerResourceNodeGather, GatherSkillXpConstants.ActivitySourceKind, skillRegistry, xpStore, levelCurve, perkUnlockEngine); if (xpOutcome.Kind != SkillProgressionGrantApplyKind.Granted) { var xpReason = xpOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing ? GatherReasonCodes.ProgressionStoreMissing : xpOutcome.Response?.ReasonCode ?? GatherReasonCodes.ProgressionStoreMissing; _ = PlayerInventoryOperations.TryRemoveStack( playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore); return Deny(xpReason, capacity.RemainingGathers); } var depletionOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement( key, nodeRegistry, instanceStore); if (depletionOutcome.Kind != ResourceNodeInstanceMutationKind.Applied) { _ = PlayerInventoryOperations.TryRemoveStack( playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore); var depletedRemaining = depletionOutcome.Snapshot?.RemainingGathers ?? 0; return Deny( depletionOutcome.ReasonCode ?? GatherReasonCodes.NodeDepleted, depletedRemaining); } var remainingAfterGather = depletionOutcome.Snapshot!.Value.RemainingGathers; // --- Telemetry hook site (NEO-64): future E9.M1 catalog event `resource_gathered` --- // TODO(E9.M1): catalog emit — on every successful gather (inventory + XP + depletion committed). // Planned payload fields: playerId, interactableId (node key), itemId, quantity (GatherGrantApplied), // skillId, xpAmount, sourceKind (GatherXpGrantSummary), remainingGathers after commit. // No ingest or ILogger here (comments-only). InteractionApi delegates here (no duplicate hooks). if (remainingAfterGather == 0) { // --- Telemetry hook site (NEO-64): future E9.M1 catalog event `gather_node_depleted` --- // TODO(E9.M1): catalog emit — once when this successful gather drives remainingGathers to 0 // (not on pre-gather node_depleted deny). Planned payload: interactableId, optional playerId // (last gatherer), maxGathers from definition when wiring E9.M1. } return new GatherResult( Success: true, ReasonCode: null, GrantsApplied: [new GatherGrantApplied(yield.ItemId, yield.Quantity)], RemainingGathers: remainingAfterGather, XpGrantSummary: new GatherXpGrantSummary( GatherSkillXpConstants.SalvageSkillId, GatherSkillXpConstants.ActivityXpPerResourceNodeGather, GatherSkillXpConstants.ActivitySourceKind)); } private static GatherResult Deny(string reasonCode, int? remainingGathers) => new( Success: false, ReasonCode: reasonCode, GrantsApplied: EmptyGrants, RemainingGathers: remainingGathers, XpGrantSummary: null); }