using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Util; namespace Theriapolis.Core.Items; /// /// Phase 7 M2 — central dispatch for "the player consumed item X". The /// inventory UI's "Use" button routes here; quest effects and dialogue /// effects that consume items also route here. Centralising the dispatch /// keeps the per-kind handlers (healing potion, scent mask, …) testable /// in one place and makes the Hybrid Medical Incompatibility scaling /// (Phase 6.5 M4 carryover) apply uniformly across all heal sources. /// /// Returns a describing what happened so the /// UI can format a message and the inventory caller can decrement / /// remove the consumed instance. /// /// Adding a new consumable kind: add the value to /// in JSON, then add a /// case branch below. carries the /// outcome a caller cares about. /// public static class ConsumableHandler { /// /// Consume for , deriving /// any randomness from (e.g. healing-dice rolls). /// The caller is responsible for actually removing the item from the /// inventory once a non-rejected result is returned. /// /// should be deterministic per usage — /// callers typically derive it from /// worldSeed ^ characterCreationMs ^ usageIndex or similar so /// save / load round-trips reproduce the roll. /// public static ConsumeResult Consume(ItemDef item, Character pc, ulong seed) { if (item is null) throw new System.ArgumentNullException(nameof(item)); if (pc is null) throw new System.ArgumentNullException(nameof(pc)); if (item.Kind != "consumable") return ConsumeResult.Rejected($"'{item.Id}' is not a consumable (kind='{item.Kind}')."); return item.ConsumableKind switch { "healing" => ConsumeHealingPotion(item, pc, seed), "scent_mask" => ConsumeScentMask(item, pc), _ => ConsumeResult.Unrecognized(item.Id), }; } private static ConsumeResult ConsumeHealingPotion(ItemDef item, Character pc, ulong seed) { // Healing dice — items.json field "healing" = e.g. "2d4+2". if (string.IsNullOrEmpty(item.Healing)) return ConsumeResult.Rejected($"healing potion '{item.Id}' has no healing dice expression."); var roll = Rules.Combat.DamageRoll.Parse(item.Healing, Rules.Stats.DamageType.Bludgeoning); var rng = new SeededRng(seed); // Average each die roll independently — same shape as the // resolver's DamageRoll roll path. We don't have a delegate hook // here; just sum the dice directly. int rolled = roll.FlatMod; for (int i = 0; i < roll.DiceCount; i++) rolled += rng.NextInt(1, roll.DiceSides + 1); if (rolled < 0) rolled = 0; // Phase 6.5 M4 carryover — Hybrid Medical Incompatibility scales // potion healing at 0.75× (round down, min 1). Same handler the // Field Repair / Lay on Paws paths use; centralising here means // every future healing source gets it automatically. int delivered = HybridDetriments.ScaleHealForHybrid(pc, rolled); // Apply to PC HP, capped to MaxHp. int before = pc.CurrentHp; pc.CurrentHp = System.Math.Min(pc.MaxHp, pc.CurrentHp + delivered); int actualHealed = pc.CurrentHp - before; return ConsumeResult.Healed(actualHealed, wasScaledForHybrid: pc.IsHybrid && delivered != rolled); } private static ConsumeResult ConsumeScentMask(ItemDef item, Character pc) { var tier = ParseScentMaskTier(item.Id); if (tier == ScentMaskTier.None) return ConsumeResult.Rejected($"unknown scent-mask tier on '{item.Id}'."); // Hybrid PCs are the use case; non-hybrids consuming a mask is // mechanically a no-op (no detriments to suppress) but we still // accept the consume so the UI doesn't error out — flavoured as // "you put on a mask; nothing in particular happens". if (pc.Hybrid is null) return ConsumeResult.MaskApplied(tier, hadEffect: false); pc.Hybrid.ActiveMaskTier = tier; return ConsumeResult.MaskApplied(tier, hadEffect: true); } private static ScentMaskTier ParseScentMaskTier(string itemId) => itemId switch { "scent_mask_basic" => ScentMaskTier.Basic, "scent_mask_military" => ScentMaskTier.Military, "scent_mask_deep_cover" => ScentMaskTier.DeepCover, _ => ScentMaskTier.None, }; } /// /// Phase 7 M2 — outcome of a call. /// Tagged-union shape: exactly one of , /// , , or /// carries the meaningful payload, keyed /// by . /// public sealed record ConsumeResult { public enum ResultKind : byte { Healed, MaskApplied, Rejected, Unrecognized } public ResultKind Kind { get; init; } public int HealedAmount { get; init; } public bool WasScaledForHybrid { get; init; } public ScentMaskTier MaskTier { get; init; } public bool MaskHadEffect { get; init; } public string RejectedReason { get; init; } = ""; public string UnrecognizedItemId { get; init; } = ""; public bool IsSuccess => Kind == ResultKind.Healed || Kind == ResultKind.MaskApplied; public static ConsumeResult Healed(int amount, bool wasScaledForHybrid) => new() { Kind = ResultKind.Healed, HealedAmount = amount, WasScaledForHybrid = wasScaledForHybrid }; public static ConsumeResult MaskApplied(ScentMaskTier tier, bool hadEffect) => new() { Kind = ResultKind.MaskApplied, MaskTier = tier, MaskHadEffect = hadEffect }; public static ConsumeResult Rejected(string reason) => new() { Kind = ResultKind.Rejected, RejectedReason = reason }; public static ConsumeResult Unrecognized(string itemId) => new() { Kind = ResultKind.Unrecognized, UnrecognizedItemId = itemId }; }