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>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Equipment slot a single <see cref="ItemInstance"/> occupies when worn or
|
||||
/// wielded. Each slot holds at most one item. Two-handed weapons clear the
|
||||
/// OffHand slot when equipped.
|
||||
///
|
||||
/// Natural-weapon enhancers (Fang Caps, Claw Sheaths, Hoof Plates, Antler Tips,
|
||||
/// Horn Rings) attach to a *specific* anatomical slot — they do not share with
|
||||
/// general-purpose worn items, so each gets its own enum entry.
|
||||
/// </summary>
|
||||
public enum EquipSlot : byte
|
||||
{
|
||||
MainHand = 0,
|
||||
OffHand = 1,
|
||||
Body = 2,
|
||||
Helm = 3,
|
||||
Cloak = 4,
|
||||
Boots = 5,
|
||||
AdaptivePack = 6,
|
||||
NaturalWeaponFang = 7,
|
||||
NaturalWeaponClaw = 8,
|
||||
NaturalWeaponHoof = 9,
|
||||
NaturalWeaponAntler = 10,
|
||||
NaturalWeaponHorn = 11,
|
||||
}
|
||||
|
||||
public static class EquipSlotExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an <see cref="Data.ItemDef.EnhancerSlot"/> string ("fang", "claw",
|
||||
/// "hoof", "antler", "horn") to the corresponding NaturalWeapon* slot.
|
||||
/// Returns null if the string isn't a recognized natural-weapon location.
|
||||
/// </summary>
|
||||
public static EquipSlot? FromEnhancerSlot(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"fang" => EquipSlot.NaturalWeaponFang,
|
||||
"claw" => EquipSlot.NaturalWeaponClaw,
|
||||
"hoof" => EquipSlot.NaturalWeaponHoof,
|
||||
"antler" => EquipSlot.NaturalWeaponAntler,
|
||||
"horn" => EquipSlot.NaturalWeaponHorn,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Parses a snake_case JSON value (e.g. "main_hand") into an EquipSlot.</summary>
|
||||
public static EquipSlot? FromJson(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"main_hand" => EquipSlot.MainHand,
|
||||
"off_hand" => EquipSlot.OffHand,
|
||||
"body" => EquipSlot.Body,
|
||||
"helm" => EquipSlot.Helm,
|
||||
"cloak" => EquipSlot.Cloak,
|
||||
"boots" => EquipSlot.Boots,
|
||||
"adaptive_pack" => EquipSlot.AdaptivePack,
|
||||
"natural_weapon_fang" => EquipSlot.NaturalWeaponFang,
|
||||
"natural_weapon_claw" => EquipSlot.NaturalWeaponClaw,
|
||||
"natural_weapon_hoof" => EquipSlot.NaturalWeaponHoof,
|
||||
"natural_weapon_antler" => EquipSlot.NaturalWeaponAntler,
|
||||
"natural_weapon_horn" => EquipSlot.NaturalWeaponHorn,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// A character's items: everything they're carrying plus the per-slot equipped
|
||||
/// references. Equipped items remain in <see cref="Items"/> — equipping does
|
||||
/// not move the instance, it just sets <see cref="ItemInstance.EquippedAt"/>
|
||||
/// and registers it in <see cref="Equipped"/> for fast slot lookup.
|
||||
///
|
||||
/// Phase 5 M2 ships the basic plumbing (add/remove/equip/unequip with size
|
||||
/// checks). Encumbrance speed effects, proficiency-driven attack disadvantage,
|
||||
/// and the equip UI itself land in M3.
|
||||
/// </summary>
|
||||
public sealed class Inventory
|
||||
{
|
||||
public List<ItemInstance> Items { get; } = new();
|
||||
|
||||
/// <summary>Slot → currently-equipped instance. Missing key = empty slot.</summary>
|
||||
public Dictionary<EquipSlot, ItemInstance> Equipped { get; } = new();
|
||||
|
||||
public float TotalWeightLb
|
||||
{
|
||||
get
|
||||
{
|
||||
float w = 0f;
|
||||
foreach (var i in Items) w += i.TotalWeightLb;
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
public ItemInstance Add(ItemDef def, int qty = 1)
|
||||
{
|
||||
var inst = new ItemInstance(def, qty);
|
||||
Items.Add(inst);
|
||||
return inst;
|
||||
}
|
||||
|
||||
public bool Remove(ItemInstance inst)
|
||||
{
|
||||
if (inst.EquippedAt is { } slot)
|
||||
Equipped.Remove(slot);
|
||||
return Items.Remove(inst);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equip <paramref name="item"/> into <paramref name="slot"/>. Returns false
|
||||
/// with an error message if the slot is occupied, the item isn't in this
|
||||
/// inventory, or there's a basic structural mismatch (two-handed weapon
|
||||
/// when OffHand is taken, etc.).
|
||||
///
|
||||
/// Note: this method does NOT enforce proficiency or size disadvantage —
|
||||
/// those are computed at attack-resolution time so the player can equip a
|
||||
/// wrong-size weapon and accept the penalty. Hard structural blocks only.
|
||||
/// </summary>
|
||||
public bool TryEquip(ItemInstance item, EquipSlot slot, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!Items.Contains(item))
|
||||
{
|
||||
error = "Item is not in this inventory.";
|
||||
return false;
|
||||
}
|
||||
if (Equipped.TryGetValue(slot, out var existing) && existing != item)
|
||||
{
|
||||
error = $"Slot {slot} is already occupied by {existing.Def.Name}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Two-handed weapon must clear OffHand first.
|
||||
if (slot == EquipSlot.MainHand &&
|
||||
HasProperty(item.Def, "two_handed") &&
|
||||
Equipped.TryGetValue(EquipSlot.OffHand, out var offHand))
|
||||
{
|
||||
error = $"Cannot wield two-handed weapon: OffHand holds {offHand.Def.Name}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Equipping into OffHand while MainHand has a two-handed weapon is invalid.
|
||||
if (slot == EquipSlot.OffHand &&
|
||||
Equipped.TryGetValue(EquipSlot.MainHand, out var mainHand) &&
|
||||
HasProperty(mainHand.Def, "two_handed"))
|
||||
{
|
||||
error = $"Cannot use OffHand: MainHand holds two-handed {mainHand.Def.Name}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Natural-weapon enhancer must go into a NaturalWeapon* slot matching its declared anatomy.
|
||||
if (item.Def.Kind == "natural_weapon_enhancer")
|
||||
{
|
||||
var declared = EquipSlotExtensions.FromEnhancerSlot(item.Def.EnhancerSlot);
|
||||
if (declared is null)
|
||||
{
|
||||
error = $"Item '{item.Def.Id}' has invalid enhancer_slot '{item.Def.EnhancerSlot}'.";
|
||||
return false;
|
||||
}
|
||||
if (slot != declared.Value)
|
||||
{
|
||||
error = $"Enhancer '{item.Def.Name}' fits {declared.Value}, not {slot}.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unequip the item from any prior slot first.
|
||||
if (item.EquippedAt is { } prior && prior != slot)
|
||||
Equipped.Remove(prior);
|
||||
|
||||
Equipped[slot] = item;
|
||||
item.EquippedAt = slot;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryUnequip(EquipSlot slot, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!Equipped.TryGetValue(slot, out var inst))
|
||||
{
|
||||
error = $"Slot {slot} is empty.";
|
||||
return false;
|
||||
}
|
||||
Equipped.Remove(slot);
|
||||
inst.EquippedAt = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public ItemInstance? GetEquipped(EquipSlot slot) =>
|
||||
Equipped.TryGetValue(slot, out var i) ? i : null;
|
||||
|
||||
private static bool HasProperty(ItemDef def, string prop)
|
||||
{
|
||||
foreach (var p in def.Properties)
|
||||
if (string.Equals(p, prop, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// One stack of items in an <see cref="Inventory"/>. Holds a reference to the
|
||||
/// immutable <see cref="ItemDef"/> plus per-instance state: how many in the
|
||||
/// stack, current condition, and (optionally) the slot the item is equipped
|
||||
/// into.
|
||||
///
|
||||
/// Phase 5 ships condition as a no-op (always 100); it's reserved for damage,
|
||||
/// repairs, and weapon breaking that arrive in Phase 5.5+.
|
||||
/// </summary>
|
||||
public sealed class ItemInstance
|
||||
{
|
||||
public ItemDef Def { get; }
|
||||
public int Qty { get; set; }
|
||||
|
||||
/// <summary>Condition, 0..100. 100 = pristine. Phase 5 always uses 100.</summary>
|
||||
public int Condition { get; set; } = 100;
|
||||
|
||||
/// <summary>Null while in the inventory bag; set when the item is equipped.</summary>
|
||||
public EquipSlot? EquippedAt { get; set; }
|
||||
|
||||
public ItemInstance(ItemDef def, int qty = 1)
|
||||
{
|
||||
Def = def ?? throw new ArgumentNullException(nameof(def));
|
||||
if (qty < 1) throw new ArgumentOutOfRangeException(nameof(qty), "qty must be ≥ 1");
|
||||
Qty = qty;
|
||||
}
|
||||
|
||||
public float TotalWeightLb => Def.WeightLb * Qty;
|
||||
|
||||
public override string ToString() =>
|
||||
EquippedAt is null
|
||||
? $"{Def.Name}{(Qty > 1 ? $" ×{Qty}" : "")}"
|
||||
: $"{Def.Name} (equipped: {EquippedAt})";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Body-size compatibility check for equipment. Per equipment.md:
|
||||
/// "Most equipment comes in Small, Medium, and Large variants. Using
|
||||
/// equipment not sized for your body imposes disadvantage on relevant
|
||||
/// checks unless it has the Adaptive property."
|
||||
///
|
||||
/// Returns <see cref="MatchResult.Match"/> when the item lists the wearer's
|
||||
/// size, <see cref="MatchResult.Adaptive"/> when not listed but the item has
|
||||
/// the "adaptive" property (no penalty), and
|
||||
/// <see cref="MatchResult.WrongSize"/> otherwise (wearer takes disadvantage).
|
||||
/// </summary>
|
||||
public static class SizeMatch
|
||||
{
|
||||
public enum MatchResult : byte
|
||||
{
|
||||
Match = 0, // item explicitly fits the wearer's size
|
||||
Adaptive = 1, // item is universally adaptive — no disadvantage
|
||||
WrongSize = 2, // wearer can equip it but suffers disadvantage
|
||||
}
|
||||
|
||||
public static MatchResult Check(ItemDef def, SizeCategory wearerSize)
|
||||
{
|
||||
string wearerKey = wearerSize switch
|
||||
{
|
||||
SizeCategory.Tiny => "tiny",
|
||||
SizeCategory.Small => "small",
|
||||
SizeCategory.Medium => "medium",
|
||||
SizeCategory.MediumLarge => "medium", // M-Large uses Medium-sized gear
|
||||
SizeCategory.Large => "large",
|
||||
SizeCategory.Huge => "large", // closest available
|
||||
_ => "medium",
|
||||
};
|
||||
|
||||
foreach (var s in def.Sizes)
|
||||
if (string.Equals(s, wearerKey, StringComparison.OrdinalIgnoreCase))
|
||||
return MatchResult.Match;
|
||||
|
||||
foreach (var p in def.Properties)
|
||||
if (string.Equals(p, "adaptive", StringComparison.OrdinalIgnoreCase))
|
||||
return MatchResult.Adaptive;
|
||||
|
||||
return MatchResult.WrongSize;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user