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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+141
View File
@@ -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 };
}
+62
View File
@@ -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,
};
}
+136
View File
@@ -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;
}
}
+38
View File
@@ -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})";
}
+49
View File
@@ -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;
}
}