142 lines
6.3 KiB
C#
142 lines
6.3 KiB
C#
|
|
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 };
|
|||
|
|
}
|