Files
TheriapolisV3/Theriapolis.Core/Items/ConsumableHandler.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

142 lines
6.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Items;
/// <summary>
/// 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 <see cref="ConsumeResult"/> 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
/// <see cref="ItemDef.ConsumableKind"/> in JSON, then add a
/// <c>case</c> branch below. <see cref="ConsumeResult"/> carries the
/// outcome a caller cares about.
/// </summary>
public static class ConsumableHandler
{
/// <summary>
/// Consume <paramref name="item"/> for <paramref name="pc"/>, deriving
/// any randomness from <paramref name="seed"/> (e.g. healing-dice rolls).
/// The caller is responsible for actually removing the item from the
/// inventory once a non-rejected result is returned.
///
/// <paramref name="seed"/> should be deterministic per usage —
/// callers typically derive it from
/// <c>worldSeed ^ characterCreationMs ^ usageIndex</c> or similar so
/// save / load round-trips reproduce the roll.
/// </summary>
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,
};
}
/// <summary>
/// Phase 7 M2 — outcome of a <see cref="ConsumableHandler.Consume"/> call.
/// Tagged-union shape: exactly one of <see cref="HealedAmount"/>,
/// <see cref="MaskTier"/>, <see cref="RejectedReason"/>, or
/// <see cref="UnrecognizedItemId"/> carries the meaningful payload, keyed
/// by <see cref="Kind"/>.
/// </summary>
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 };
}