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,88 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// The six d20-adjacent ability scores. Score range is 1..30; level-1
|
||||
/// characters typically end up in 8..18 after clade/species mods.
|
||||
/// </summary>
|
||||
public readonly struct AbilityScores : IEquatable<AbilityScores>
|
||||
{
|
||||
public readonly byte STR;
|
||||
public readonly byte DEX;
|
||||
public readonly byte CON;
|
||||
public readonly byte INT;
|
||||
public readonly byte WIS;
|
||||
public readonly byte CHA;
|
||||
|
||||
public AbilityScores(int str, int dex, int con, int @int, int wis, int cha)
|
||||
{
|
||||
STR = ClampScore(str);
|
||||
DEX = ClampScore(dex);
|
||||
CON = ClampScore(con);
|
||||
INT = ClampScore(@int);
|
||||
WIS = ClampScore(wis);
|
||||
CHA = ClampScore(cha);
|
||||
}
|
||||
|
||||
/// <summary>Standard d20 ability modifier: floor((score - 10) / 2).</summary>
|
||||
public static int Mod(int score)
|
||||
{
|
||||
// C# integer division truncates toward zero; for negatives we need
|
||||
// floor toward -infinity to match d20 behaviour (score 9 → -1 not 0).
|
||||
int diff = score - 10;
|
||||
return diff >= 0 ? diff / 2 : (diff - 1) / 2;
|
||||
}
|
||||
|
||||
public int ModFor(AbilityId id) => Mod(Get(id));
|
||||
|
||||
public byte Get(AbilityId id) => id switch
|
||||
{
|
||||
AbilityId.STR => STR,
|
||||
AbilityId.DEX => DEX,
|
||||
AbilityId.CON => CON,
|
||||
AbilityId.INT => INT,
|
||||
AbilityId.WIS => WIS,
|
||||
AbilityId.CHA => CHA,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(id)),
|
||||
};
|
||||
|
||||
/// <summary>Returns a new score block with <paramref name="id"/> replaced.</summary>
|
||||
public AbilityScores With(AbilityId id, int newScore) => id switch
|
||||
{
|
||||
AbilityId.STR => new AbilityScores(newScore, DEX, CON, INT, WIS, CHA),
|
||||
AbilityId.DEX => new AbilityScores(STR, newScore, CON, INT, WIS, CHA),
|
||||
AbilityId.CON => new AbilityScores(STR, DEX, newScore, INT, WIS, CHA),
|
||||
AbilityId.INT => new AbilityScores(STR, DEX, CON, newScore, WIS, CHA),
|
||||
AbilityId.WIS => new AbilityScores(STR, DEX, CON, INT, newScore, CHA),
|
||||
AbilityId.CHA => new AbilityScores(STR, DEX, CON, INT, WIS, newScore),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(id)),
|
||||
};
|
||||
|
||||
/// <summary>Returns a new block with each ability incremented by the supplied dictionary.</summary>
|
||||
public AbilityScores Plus(IReadOnlyDictionary<AbilityId, int> mods)
|
||||
{
|
||||
var s = this;
|
||||
foreach (var kv in mods) s = s.With(kv.Key, s.Get(kv.Key) + kv.Value);
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>The standard array, in descending order: 15, 14, 13, 12, 10, 8.</summary>
|
||||
public static int[] StandardArray => new[] { 15, 14, 13, 12, 10, 8 };
|
||||
|
||||
private static byte ClampScore(int v) => (byte)Math.Clamp(v, 1, 30);
|
||||
|
||||
public bool Equals(AbilityScores o) =>
|
||||
STR == o.STR && DEX == o.DEX && CON == o.CON && INT == o.INT && WIS == o.WIS && CHA == o.CHA;
|
||||
public override bool Equals(object? o) => o is AbilityScores a && Equals(a);
|
||||
public override int GetHashCode() => HashCode.Combine(STR, DEX, CON, INT, WIS, CHA);
|
||||
public override string ToString() => $"STR {STR} DEX {DEX} CON {CON} INT {INT} WIS {WIS} CHA {CHA}";
|
||||
}
|
||||
|
||||
public enum AbilityId : byte
|
||||
{
|
||||
STR = 0,
|
||||
DEX = 1,
|
||||
CON = 2,
|
||||
INT = 3,
|
||||
WIS = 4,
|
||||
CHA = 5,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Status effects applied during combat or by environment. Phase 5 ships
|
||||
/// the most common subset; the enum is open-ended for additions in later
|
||||
/// phases without renumbering existing values.
|
||||
/// </summary>
|
||||
public enum Condition : byte
|
||||
{
|
||||
None = 0,
|
||||
Prone = 1,
|
||||
Frightened = 2,
|
||||
Restrained = 3,
|
||||
Grappled = 4,
|
||||
Dazed = 5,
|
||||
Blinded = 6,
|
||||
Stunned = 7,
|
||||
Unconscious = 8,
|
||||
Charmed = 9,
|
||||
Poisoned = 10,
|
||||
Deafened = 11,
|
||||
Invisible = 12,
|
||||
Petrified = 13,
|
||||
Incapacitated = 14,
|
||||
/// <summary>1..6 levels per d20; tracked separately on Character.ExhaustionLevel rather than as a binary flag.</summary>
|
||||
Exhausted = 15,
|
||||
}
|
||||
|
||||
public static class ConditionExtensions
|
||||
{
|
||||
public static Condition FromJson(string raw) => raw.ToLowerInvariant() switch
|
||||
{
|
||||
"none" => Condition.None,
|
||||
"prone" => Condition.Prone,
|
||||
"frightened" => Condition.Frightened,
|
||||
"restrained" => Condition.Restrained,
|
||||
"grappled" => Condition.Grappled,
|
||||
"dazed" => Condition.Dazed,
|
||||
"blinded" => Condition.Blinded,
|
||||
"stunned" => Condition.Stunned,
|
||||
"unconscious" => Condition.Unconscious,
|
||||
"charmed" => Condition.Charmed,
|
||||
"poisoned" => Condition.Poisoned,
|
||||
"deafened" => Condition.Deafened,
|
||||
"invisible" => Condition.Invisible,
|
||||
"petrified" => Condition.Petrified,
|
||||
"incapacitated" => Condition.Incapacitated,
|
||||
"exhausted" => Condition.Exhausted,
|
||||
_ => throw new ArgumentException($"Unknown condition: '{raw}'"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Damage classifications. Resistance and immunity are checked against
|
||||
/// these. Theriapolis adds no exotic types beyond standard d20 — the
|
||||
/// scent/pheromone abilities use <see cref="Condition"/> not damage.
|
||||
/// </summary>
|
||||
public enum DamageType : byte
|
||||
{
|
||||
Bludgeoning = 0,
|
||||
Piercing = 1,
|
||||
Slashing = 2,
|
||||
Fire = 3,
|
||||
Cold = 4,
|
||||
Lightning = 5,
|
||||
Poison = 6,
|
||||
Psychic = 7,
|
||||
Thunder = 8,
|
||||
Acid = 9,
|
||||
Necrotic = 10,
|
||||
Radiant = 11,
|
||||
Force = 12,
|
||||
}
|
||||
|
||||
public static class DamageTypeExtensions
|
||||
{
|
||||
public static DamageType FromJson(string raw) => raw.ToLowerInvariant() switch
|
||||
{
|
||||
"bludgeoning" => DamageType.Bludgeoning,
|
||||
"piercing" => DamageType.Piercing,
|
||||
"slashing" => DamageType.Slashing,
|
||||
"fire" => DamageType.Fire,
|
||||
"cold" => DamageType.Cold,
|
||||
"lightning" => DamageType.Lightning,
|
||||
"poison" => DamageType.Poison,
|
||||
"psychic" => DamageType.Psychic,
|
||||
"thunder" => DamageType.Thunder,
|
||||
"acid" => DamageType.Acid,
|
||||
"necrotic" => DamageType.Necrotic,
|
||||
"radiant" => DamageType.Radiant,
|
||||
"force" => DamageType.Force,
|
||||
_ => throw new ArgumentException($"Unknown damage type: '{raw}'"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Theriapolis.Core.Items;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Pure computed values derived from a <see cref="Character"/>'s ability
|
||||
/// scores, equipped items, conditions, and encumbrance state. Recomputed on
|
||||
/// demand — nothing here mutates the character. UI panels and the combat
|
||||
/// resolver call these helpers to surface the current AC, speed, etc.
|
||||
///
|
||||
/// Phase 5 M3 ships the AC, Speed, and CarryCap formulas plus the
|
||||
/// encumbrance band. Class/feature-driven AC bonuses (Bovid Herd Wall +1
|
||||
/// adjacent ally, Feral Unarmored Defense, etc.) are layered on at combat
|
||||
/// resolution time, not here — those need positional context.
|
||||
/// </summary>
|
||||
public static class DerivedStats
|
||||
{
|
||||
public enum EncumbranceBand : byte
|
||||
{
|
||||
Light = 0, // ≤ soft threshold — no penalty
|
||||
Heavy = 1, // > soft threshold — speed -10 ft.
|
||||
Over = 2, // > hard threshold — speed halved + disadvantage on STR/DEX/CON
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Armor Class from base 10 (or unarmored-defense pseudo-armor) plus DEX
|
||||
/// (capped by armor type) plus shield. Out-of-combat baseline; does not
|
||||
/// include feature/positional bonuses.
|
||||
/// </summary>
|
||||
public static int ArmorClass(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
int dexMod = c.Abilities.ModFor(AbilityId.DEX);
|
||||
int ac;
|
||||
|
||||
var body = c.Inventory.GetEquipped(EquipSlot.Body);
|
||||
if (body is null)
|
||||
{
|
||||
// Unarmored: 10 + DEX. Feral's "Unarmored Defense" (10 + DEX + CON)
|
||||
// ships at M6 with the rest of class-feature combat effects.
|
||||
ac = 10 + dexMod;
|
||||
}
|
||||
else
|
||||
{
|
||||
int dexAllowed = body.Def.AcMaxDex < 0 ? dexMod : Math.Min(dexMod, body.Def.AcMaxDex);
|
||||
ac = body.Def.AcBase + dexAllowed;
|
||||
}
|
||||
|
||||
var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
|
||||
if (off is not null && string.Equals(off.Def.Kind, "shield", StringComparison.OrdinalIgnoreCase))
|
||||
ac += off.Def.AcBase;
|
||||
|
||||
// Phase 5 M6: class features may replace the unarmored baseline
|
||||
// (Feral Unarmored Defense). Per-encounter combat-time bonuses
|
||||
// (Sentinel Stance) are added at attack-resolution time, not here.
|
||||
ac = Theriapolis.Core.Rules.Combat.FeatureProcessor.ApplyAcFeatures(c, ac);
|
||||
|
||||
return Math.Clamp(ac, C.AC_FLOOR, C.AC_CEILING);
|
||||
}
|
||||
|
||||
/// <summary>Initiative = DEX modifier. Class features that add to it (Feral L7) layered later.</summary>
|
||||
public static int Initiative(Theriapolis.Core.Rules.Character.Character c) => c.Abilities.ModFor(AbilityId.DEX);
|
||||
|
||||
/// <summary>
|
||||
/// Movement speed in feet per turn. Base from species, modified by
|
||||
/// encumbrance band and (later) by conditions and class features.
|
||||
/// </summary>
|
||||
public static int SpeedFt(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
int speed = c.Species.BaseSpeedFt;
|
||||
switch (Encumbrance(c))
|
||||
{
|
||||
case EncumbranceBand.Heavy: speed -= 10; break;
|
||||
case EncumbranceBand.Over: speed /= 2; break;
|
||||
}
|
||||
return Math.Max(0, speed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carrying capacity in pounds. Base = STR × 15, scaled by size category
|
||||
/// (Small ½×, Large 2×, etc. per equipment.md).
|
||||
/// </summary>
|
||||
public static float CarryCapacityLb(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
return c.Abilities.STR * 15f * c.Size.CarryCapacityMult();
|
||||
}
|
||||
|
||||
/// <summary>Current encumbrance band given inventory weight vs. carry capacity.</summary>
|
||||
public static EncumbranceBand Encumbrance(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
float cap = CarryCapacityLb(c);
|
||||
if (cap <= 0f) return EncumbranceBand.Over;
|
||||
|
||||
float w = c.Inventory.TotalWeightLb;
|
||||
float ratio = w / cap;
|
||||
if (ratio > C.ENCUMBRANCE_HARD_MULT) return EncumbranceBand.Over;
|
||||
if (ratio > C.ENCUMBRANCE_SOFT_MULT) return EncumbranceBand.Heavy;
|
||||
return EncumbranceBand.Light;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Speed multiplier applied to <see cref="C.TACTICAL_PLAYER_PX_PER_SEC"/>.
|
||||
/// 1.0 = normal walking pace; smaller = encumbered drag. Light = 1.0,
|
||||
/// Heavy ≈ 0.66, Over = 0.5.
|
||||
/// </summary>
|
||||
public static float TacticalSpeedMult(Theriapolis.Core.Rules.Character.Character c) => Encumbrance(c) switch
|
||||
{
|
||||
EncumbranceBand.Light => 1.0f,
|
||||
EncumbranceBand.Heavy => 0.66f,
|
||||
EncumbranceBand.Over => 0.50f,
|
||||
_ => 1.0f,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Standard d20 proficiency-bonus-by-level table:
|
||||
/// 1-4 → +2
|
||||
/// 5-8 → +3
|
||||
/// 9-12 → +4
|
||||
/// 13-16 → +5
|
||||
/// 17-20 → +6
|
||||
/// Phase 5 only ever evaluates level 1, but the full table ships so
|
||||
/// future leveling work doesn't have to revisit this file.
|
||||
/// </summary>
|
||||
public static class ProficiencyBonus
|
||||
{
|
||||
public const int MinLevel = 1;
|
||||
public const int MaxLevel = 20;
|
||||
|
||||
public static int ForLevel(int level)
|
||||
{
|
||||
if (level < MinLevel || level > MaxLevel)
|
||||
throw new ArgumentOutOfRangeException(nameof(level), $"Level must be {MinLevel}..{MaxLevel}, got {level}");
|
||||
|
||||
return level switch
|
||||
{
|
||||
>= 17 => 6,
|
||||
>= 13 => 5,
|
||||
>= 9 => 4,
|
||||
>= 5 => 3,
|
||||
_ => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Saving-throw categories. There's exactly one per ability; this enum is
|
||||
/// a thin alias of <see cref="AbilityId"/> kept distinct so callsites read
|
||||
/// clearly ("MakeSave(SaveId.DEX, dc)" vs "Mod(AbilityId.DEX)").
|
||||
/// </summary>
|
||||
public enum SaveId : byte
|
||||
{
|
||||
STR = 0,
|
||||
DEX = 1,
|
||||
CON = 2,
|
||||
INT = 3,
|
||||
WIS = 4,
|
||||
CHA = 5,
|
||||
}
|
||||
|
||||
public static class SaveIdExtensions
|
||||
{
|
||||
public static AbilityId Ability(this SaveId s) => (AbilityId)(byte)s;
|
||||
|
||||
public static SaveId FromJson(string raw) => raw.ToUpperInvariant() switch
|
||||
{
|
||||
"STR" => SaveId.STR,
|
||||
"DEX" => SaveId.DEX,
|
||||
"CON" => SaveId.CON,
|
||||
"INT" => SaveId.INT,
|
||||
"WIS" => SaveId.WIS,
|
||||
"CHA" => SaveId.CHA,
|
||||
_ => throw new ArgumentException($"Unknown save: '{raw}'"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Body-size category from clades.md. Determines tactical-tile footprint,
|
||||
/// reach, equipment fit, and grappling rules.
|
||||
/// </summary>
|
||||
public enum SizeCategory : byte
|
||||
{
|
||||
Tiny = 0, // reserved; no Phase 5 species uses this
|
||||
Small = 1, // rabbit-folk, housecat-folk, ferret-folk
|
||||
Medium = 2, // fox-folk, deer-folk, ram-folk, leopard-folk, badger-folk, hare-folk
|
||||
MediumLarge = 3, // wolf-folk, elk-folk, lion-folk, wolverine-folk, coyote-folk
|
||||
Large = 4, // brown bear-folk, polar bear-folk, moose-folk, bull-folk, bison-folk
|
||||
Huge = 5, // reserved; no Phase 5 species uses this
|
||||
}
|
||||
|
||||
public static class SizeExtensions
|
||||
{
|
||||
/// <summary>Tactical-tile footprint per side (1 = 1×1, 2 = 2×2).</summary>
|
||||
public static int FootprintTiles(this SizeCategory s) => s switch
|
||||
{
|
||||
SizeCategory.Tiny => 1,
|
||||
SizeCategory.Small => 1,
|
||||
SizeCategory.Medium => 1,
|
||||
SizeCategory.MediumLarge => 1, // counts as Large for grappling/carrying only
|
||||
SizeCategory.Large => 2,
|
||||
SizeCategory.Huge => 3,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
/// <summary>Default melee reach in tactical tiles (weapon-modifiable).</summary>
|
||||
public static int DefaultReachTiles(this SizeCategory s) => s switch
|
||||
{
|
||||
SizeCategory.Tiny => 1,
|
||||
SizeCategory.Small => 1,
|
||||
SizeCategory.Medium => 1,
|
||||
SizeCategory.MediumLarge => 1,
|
||||
SizeCategory.Large => 2,
|
||||
SizeCategory.Huge => 3,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
/// <summary>Carrying-capacity multiplier per equipment.md (Small ½×, Large 2×).</summary>
|
||||
public static float CarryCapacityMult(this SizeCategory s) => s switch
|
||||
{
|
||||
SizeCategory.Tiny => 0.25f,
|
||||
SizeCategory.Small => 0.5f,
|
||||
SizeCategory.Medium => 1.0f,
|
||||
SizeCategory.MediumLarge => 1.0f, // Medium frame, Large for grappling
|
||||
SizeCategory.Large => 2.0f,
|
||||
SizeCategory.Huge => 4.0f,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
/// <summary>Parses a snake_case JSON value (e.g. "medium_large") into a SizeCategory.</summary>
|
||||
public static SizeCategory FromJson(string? raw) => raw switch
|
||||
{
|
||||
"tiny" => SizeCategory.Tiny,
|
||||
"small" => SizeCategory.Small,
|
||||
"medium" => SizeCategory.Medium,
|
||||
"medium_large" => SizeCategory.MediumLarge,
|
||||
"large" => SizeCategory.Large,
|
||||
"huge" => SizeCategory.Huge,
|
||||
_ => throw new ArgumentException($"Unknown size category: '{raw}'"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Standard d20-adjacent skill list. Each skill is backed by a single
|
||||
/// ability — see <see cref="SkillAbility"/>.
|
||||
/// </summary>
|
||||
public enum SkillId : byte
|
||||
{
|
||||
Acrobatics = 0,
|
||||
AnimalHandling = 1,
|
||||
Arcana = 2, // Theriapolis: "Advanced Engineering"
|
||||
Athletics = 3,
|
||||
Deception = 4,
|
||||
History = 5,
|
||||
Insight = 6,
|
||||
Intimidation = 7,
|
||||
Investigation = 8,
|
||||
Medicine = 9,
|
||||
Nature = 10,
|
||||
Perception = 11,
|
||||
Performance = 12,
|
||||
Persuasion = 13,
|
||||
Religion = 14, // Theriapolis: Covenant lore
|
||||
SleightOfHand = 15,
|
||||
Stealth = 16,
|
||||
Survival = 17,
|
||||
}
|
||||
|
||||
public static class SkillIdExtensions
|
||||
{
|
||||
public static AbilityId Ability(this SkillId s) => s switch
|
||||
{
|
||||
SkillId.Acrobatics => AbilityId.DEX,
|
||||
SkillId.AnimalHandling => AbilityId.WIS,
|
||||
SkillId.Arcana => AbilityId.INT,
|
||||
SkillId.Athletics => AbilityId.STR,
|
||||
SkillId.Deception => AbilityId.CHA,
|
||||
SkillId.History => AbilityId.INT,
|
||||
SkillId.Insight => AbilityId.WIS,
|
||||
SkillId.Intimidation => AbilityId.CHA,
|
||||
SkillId.Investigation => AbilityId.INT,
|
||||
SkillId.Medicine => AbilityId.WIS,
|
||||
SkillId.Nature => AbilityId.INT,
|
||||
SkillId.Perception => AbilityId.WIS,
|
||||
SkillId.Performance => AbilityId.CHA,
|
||||
SkillId.Persuasion => AbilityId.CHA,
|
||||
SkillId.Religion => AbilityId.INT,
|
||||
SkillId.SleightOfHand => AbilityId.DEX,
|
||||
SkillId.Stealth => AbilityId.DEX,
|
||||
SkillId.Survival => AbilityId.WIS,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
/// <summary>Parses a snake_case JSON value (e.g. "animal_handling") into a SkillId.</summary>
|
||||
public static SkillId FromJson(string raw) => raw.ToLowerInvariant() switch
|
||||
{
|
||||
"acrobatics" => SkillId.Acrobatics,
|
||||
"animal_handling" => SkillId.AnimalHandling,
|
||||
"arcana" => SkillId.Arcana,
|
||||
"athletics" => SkillId.Athletics,
|
||||
"deception" => SkillId.Deception,
|
||||
"history" => SkillId.History,
|
||||
"insight" => SkillId.Insight,
|
||||
"intimidation" => SkillId.Intimidation,
|
||||
"investigation" => SkillId.Investigation,
|
||||
"medicine" => SkillId.Medicine,
|
||||
"nature" => SkillId.Nature,
|
||||
"perception" => SkillId.Perception,
|
||||
"performance" => SkillId.Performance,
|
||||
"persuasion" => SkillId.Persuasion,
|
||||
"religion" => SkillId.Religion,
|
||||
"sleight_of_hand" => SkillId.SleightOfHand,
|
||||
"stealth" => SkillId.Stealth,
|
||||
"survival" => SkillId.Survival,
|
||||
_ => throw new ArgumentException($"Unknown skill: '{raw}'"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Standard d20 XP-to-level table. Phase 5 awards XP and persists it but
|
||||
/// does not act on level-up — <see cref="LevelForXp"/> is exposed for the
|
||||
/// HUD to display "next level in N XP" without requiring a level-up
|
||||
/// flow yet.
|
||||
/// </summary>
|
||||
public static class XpTable
|
||||
{
|
||||
/// <summary>XP threshold for each level 1..20. <c>Threshold[1] = 0</c> by convention.</summary>
|
||||
public static readonly int[] Threshold = new[]
|
||||
{
|
||||
0, // index 0 unused
|
||||
0, // level 1
|
||||
300, // level 2
|
||||
900,
|
||||
2_700,
|
||||
6_500,
|
||||
14_000,
|
||||
23_000,
|
||||
34_000,
|
||||
48_000,
|
||||
64_000,
|
||||
85_000,
|
||||
100_000,
|
||||
120_000,
|
||||
140_000,
|
||||
165_000,
|
||||
195_000,
|
||||
225_000,
|
||||
265_000,
|
||||
305_000,
|
||||
355_000, // level 20
|
||||
};
|
||||
|
||||
public static int LevelForXp(int xp)
|
||||
{
|
||||
if (xp < 0) throw new ArgumentOutOfRangeException(nameof(xp));
|
||||
for (int lv = 20; lv >= 1; lv--)
|
||||
if (xp >= Threshold[lv]) return lv;
|
||||
return 1;
|
||||
}
|
||||
|
||||
public static int XpRequiredForNextLevel(int currentLevel)
|
||||
{
|
||||
if (currentLevel < 1 || currentLevel > 20)
|
||||
throw new ArgumentOutOfRangeException(nameof(currentLevel));
|
||||
if (currentLevel == 20) return int.MaxValue;
|
||||
return Threshold[currentLevel + 1];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user