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