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