437 lines
19 KiB
C#
437 lines
19 KiB
C#
|
|
using Theriapolis.Core.Data;
|
||
|
|
using Theriapolis.Core.Items;
|
||
|
|
using Theriapolis.Core.Rules.Stats;
|
||
|
|
using Theriapolis.Core.Util;
|
||
|
|
|
||
|
|
namespace Theriapolis.Core.Rules.Character;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Fluent builder for a level-1 <see cref="Character"/>. Used by both the
|
||
|
|
/// in-game character-creation screen and the headless <c>character-roll</c>
|
||
|
|
/// Tools command, plus the M2 test suite.
|
||
|
|
///
|
||
|
|
/// Pattern: set inputs (clade, species, class, background, base scores,
|
||
|
|
/// chosen skills, name), then call <see cref="Build"/>. <see cref="Validate"/>
|
||
|
|
/// returns the first error string when any required input is missing or
|
||
|
|
/// inconsistent — <see cref="Build"/> calls Validate and throws on failure.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class CharacterBuilder
|
||
|
|
{
|
||
|
|
public CladeDef? Clade { get; set; }
|
||
|
|
public SpeciesDef? Species { get; set; }
|
||
|
|
public ClassDef? ClassDef { get; set; }
|
||
|
|
public BackgroundDef? Background { get; set; }
|
||
|
|
|
||
|
|
/// <summary>Pre-clade-mod base scores (e.g. Standard Array assignment or 4d6 roll outcome).</summary>
|
||
|
|
public AbilityScores BaseAbilities { get; set; } = new(10, 10, 10, 10, 10, 10);
|
||
|
|
|
||
|
|
/// <summary>Class-skill picks. Background skills are added automatically by Build().</summary>
|
||
|
|
public HashSet<SkillId> ChosenClassSkills { get; } = new();
|
||
|
|
|
||
|
|
public string Name { get; set; } = "Wanderer";
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Phase 5 M6: Fangsworn fighting style choice. One of "duelist",
|
||
|
|
/// "great_weapon", "shieldwall", "fang_and_blade", "natural_predator".
|
||
|
|
/// Empty string defaults to "duelist" if the class is Fangsworn (sensible
|
||
|
|
/// auto-pick that has visible combat effect at level 1). Ignored for
|
||
|
|
/// non-Fangsworn classes.
|
||
|
|
/// </summary>
|
||
|
|
public string FightingStyle { get; set; } = "";
|
||
|
|
|
||
|
|
// ── Phase 6.5 M4: hybrid origin ─────────────────────────────────────
|
||
|
|
/// <summary>
|
||
|
|
/// When true, <see cref="TryBuildHybrid"/> is the canonical build path
|
||
|
|
/// and <see cref="Clade"/> / <see cref="Species"/> are the *dominant*
|
||
|
|
/// parent's lineage; <see cref="HybridSireClade"/> /
|
||
|
|
/// <see cref="HybridDamClade"/> populate the secondary parent. Defaults
|
||
|
|
/// to false (purebred path); the character creation screen flips this
|
||
|
|
/// when the player ticks the Hybrid checkbox.
|
||
|
|
/// </summary>
|
||
|
|
public bool IsHybridOrigin { get; set; } = false;
|
||
|
|
|
||
|
|
/// <summary>Sire clade for hybrid origin path (paternal lineage).</summary>
|
||
|
|
public CladeDef? HybridSireClade { get; set; }
|
||
|
|
/// <summary>Sire species for hybrid origin path.</summary>
|
||
|
|
public SpeciesDef? HybridSireSpecies { get; set; }
|
||
|
|
/// <summary>Dam clade for hybrid origin path (maternal lineage).</summary>
|
||
|
|
public CladeDef? HybridDamClade { get; set; }
|
||
|
|
/// <summary>Dam species for hybrid origin path.</summary>
|
||
|
|
public SpeciesDef? HybridDamSpecies { get; set; }
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Which parent's expression dominates. Drives Passing presentation
|
||
|
|
/// (the PC scent-reads as this lineage's clade). Default is Sire.
|
||
|
|
/// </summary>
|
||
|
|
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
|
||
|
|
|
||
|
|
// ── Builder fluent helpers ──────────────────────────────────────────
|
||
|
|
|
||
|
|
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
|
||
|
|
public CharacterBuilder WithSpecies(SpeciesDef s) { Species = s; return this; }
|
||
|
|
public CharacterBuilder WithClass(ClassDef c) { ClassDef = c; return this; }
|
||
|
|
public CharacterBuilder WithBackground(BackgroundDef b) { Background = b; return this; }
|
||
|
|
public CharacterBuilder WithAbilities(AbilityScores a) { BaseAbilities = a; return this; }
|
||
|
|
public CharacterBuilder WithName(string name) { Name = name ?? "Wanderer"; return this; }
|
||
|
|
|
||
|
|
public CharacterBuilder ChooseSkill(SkillId s)
|
||
|
|
{
|
||
|
|
ChosenClassSkills.Add(s);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Validation ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
public bool Validate(out string error)
|
||
|
|
{
|
||
|
|
error = "";
|
||
|
|
if (Clade is null) { error = "Clade not selected."; return false; }
|
||
|
|
if (Species is null) { error = "Species not selected."; return false; }
|
||
|
|
if (ClassDef is null) { error = "Class not selected."; return false; }
|
||
|
|
if (Background is null) { error = "Background not selected."; return false; }
|
||
|
|
|
||
|
|
if (!string.Equals(Species.CladeId, Clade.Id, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
error = $"Species '{Species.Id}' belongs to clade '{Species.CladeId}', not '{Clade.Id}'.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate every chosen class skill is in the class's offered list.
|
||
|
|
foreach (var s in ChosenClassSkills)
|
||
|
|
{
|
||
|
|
string raw = SkillToJsonName(s);
|
||
|
|
bool listed = false;
|
||
|
|
foreach (var opt in ClassDef.SkillOptions)
|
||
|
|
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
listed = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
if (!listed)
|
||
|
|
{
|
||
|
|
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
|
||
|
|
{
|
||
|
|
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Build ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
public Character Build(IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit = null)
|
||
|
|
{
|
||
|
|
if (!Validate(out string error))
|
||
|
|
throw new InvalidOperationException($"Cannot build character: {error}");
|
||
|
|
|
||
|
|
// Apply clade + species ability mods to the base scores.
|
||
|
|
var clade = Clade!;
|
||
|
|
var species = Species!;
|
||
|
|
var classD = ClassDef!;
|
||
|
|
var bgD = Background!;
|
||
|
|
|
||
|
|
var modded = BaseAbilities;
|
||
|
|
modded = ApplyMods(modded, clade.AbilityMods);
|
||
|
|
modded = ApplyMods(modded, species.AbilityMods);
|
||
|
|
|
||
|
|
var c = new Character(clade, species, classD, bgD, modded)
|
||
|
|
{
|
||
|
|
Level = 1,
|
||
|
|
Xp = 0,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Skills: class-chosen + background freebies (deduplicated).
|
||
|
|
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
|
||
|
|
foreach (var raw in bgD.SkillProficiencies)
|
||
|
|
{
|
||
|
|
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
|
||
|
|
catch (ArgumentException) { /* unknown skill names ignored — content bug, but don't crash creation */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
// HP: HitDie + CON modifier at level 1.
|
||
|
|
c.MaxHp = c.ComputeMaxHpFromScratch();
|
||
|
|
c.CurrentHp = c.MaxHp;
|
||
|
|
|
||
|
|
// Phase 5 M6: Fangsworn fighting style. Default to "duelist" — has
|
||
|
|
// immediate combat effect at level 1 and works with the most weapons
|
||
|
|
// in our starting kits. The CodexUI character creator surfaces this
|
||
|
|
// as a real picker; the legacy Myra screen leaves it on default.
|
||
|
|
if (string.Equals(classD.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Optional starting kit. The caller passes the loaded item table
|
||
|
|
// (typically <see cref="Data.ContentResolver.Items"/>); if null, the
|
||
|
|
// character starts with an empty inventory (existing test behaviour).
|
||
|
|
if (itemsForStartingKit is not null)
|
||
|
|
ApplyStartingKit(c, itemsForStartingKit);
|
||
|
|
|
||
|
|
return c;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Phase 6.5 M4: Hybrid build path ─────────────────────────────────
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Validate the hybrid-origin fields. Returns true and an empty error
|
||
|
|
/// string when the sire+dam configuration is valid for building a
|
||
|
|
/// hybrid character.
|
||
|
|
///
|
||
|
|
/// Required: both sire and dam picked (clade + species each); sire and
|
||
|
|
/// dam must be *different* clades (cross-clade is the definition of
|
||
|
|
/// hybrid); each species must belong to its declared clade.
|
||
|
|
/// </summary>
|
||
|
|
public bool ValidateHybrid(out string error)
|
||
|
|
{
|
||
|
|
error = "";
|
||
|
|
if (HybridSireClade is null) { error = "Sire clade not selected."; return false; }
|
||
|
|
if (HybridSireSpecies is null) { error = "Sire species not selected."; return false; }
|
||
|
|
if (HybridDamClade is null) { error = "Dam clade not selected."; return false; }
|
||
|
|
if (HybridDamSpecies is null) { error = "Dam species not selected."; return false; }
|
||
|
|
|
||
|
|
if (string.Equals(HybridSireClade.Id, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
error = $"Sire and dam must be different clades (both are '{HybridSireClade.Id}'). Hybrids are cross-clade.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!string.Equals(HybridSireSpecies.CladeId, HybridSireClade.Id, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
error = $"Sire species '{HybridSireSpecies.Id}' belongs to clade '{HybridSireSpecies.CladeId}', not '{HybridSireClade.Id}'.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (!string.Equals(HybridDamSpecies.CladeId, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
error = $"Dam species '{HybridDamSpecies.Id}' belongs to clade '{HybridDamSpecies.CladeId}', not '{HybridDamClade.Id}'.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Build a hybrid character from the configured sire + dam pair. The
|
||
|
|
/// builder resolves the dominant parent's clade + species as the
|
||
|
|
/// primary <see cref="Character.Clade"/> / <see cref="Character.Species"/>
|
||
|
|
/// (so existing systems that key off these fields keep working), and
|
||
|
|
/// records the full sire+dam genealogy in <see cref="Character.Hybrid"/>.
|
||
|
|
///
|
||
|
|
/// Ability mod blending follows <c>clades.md</c> HYBRID ORIGIN:
|
||
|
|
/// take *one* ability mod from each parent clade. If both grant the
|
||
|
|
/// same ability, the duplicate is dropped (no double-counting); the
|
||
|
|
/// player picks an alternative +1 elsewhere via the standard array
|
||
|
|
/// or roll path. (M4 simplification: take both clade mod sets and
|
||
|
|
/// blend them — duplicates collapse to a single +1 — and use both
|
||
|
|
/// species mods.)
|
||
|
|
/// </summary>
|
||
|
|
public bool TryBuildHybrid(
|
||
|
|
IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit,
|
||
|
|
out Character? character,
|
||
|
|
out string error)
|
||
|
|
{
|
||
|
|
character = null;
|
||
|
|
|
||
|
|
if (!ValidateHybrid(out error)) return false;
|
||
|
|
if (ClassDef is null)
|
||
|
|
{
|
||
|
|
error = "Class not selected.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (Background is null)
|
||
|
|
{
|
||
|
|
error = "Background not selected.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate skills against the class — same as the purebred path.
|
||
|
|
foreach (var s in ChosenClassSkills)
|
||
|
|
{
|
||
|
|
string raw = SkillToJsonName(s);
|
||
|
|
bool listed = false;
|
||
|
|
foreach (var opt in ClassDef.SkillOptions)
|
||
|
|
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
listed = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
if (!listed)
|
||
|
|
{
|
||
|
|
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
|
||
|
|
{
|
||
|
|
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve the dominant lineage as the primary clade/species so the
|
||
|
|
// rest of the engine (rendering, scent reads, dialogue gates that
|
||
|
|
// key off Character.Clade / Character.Species) sees the dominant
|
||
|
|
// expression. The Hybrid record carries the full sire+dam
|
||
|
|
// genealogy.
|
||
|
|
var dominantClade = HybridDominantParent == ParentLineage.Sire
|
||
|
|
? HybridSireClade! : HybridDamClade!;
|
||
|
|
var dominantSpecies = HybridDominantParent == ParentLineage.Sire
|
||
|
|
? HybridSireSpecies! : HybridDamSpecies!;
|
||
|
|
|
||
|
|
// Blend ability mods: apply BOTH parent clades' mods, then BOTH
|
||
|
|
// species mods. Same-key collisions accumulate (e.g. two clades
|
||
|
|
// each granting +1 CON yield +2 CON). This is a small departure
|
||
|
|
// from clades.md's "take one from each" but matches the engine's
|
||
|
|
// declarative-mod model and produces sensible totals; M4 ships it
|
||
|
|
// and the rule fine-tunes in playtesting.
|
||
|
|
var modded = BaseAbilities;
|
||
|
|
modded = ApplyMods(modded, HybridSireClade!.AbilityMods);
|
||
|
|
modded = ApplyMods(modded, HybridDamClade!.AbilityMods);
|
||
|
|
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
|
||
|
|
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
|
||
|
|
|
||
|
|
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
|
||
|
|
{
|
||
|
|
Level = 1,
|
||
|
|
Xp = 0,
|
||
|
|
Hybrid = new HybridState
|
||
|
|
{
|
||
|
|
SireClade = HybridSireClade.Id,
|
||
|
|
SireSpecies = HybridSireSpecies.Id,
|
||
|
|
DamClade = HybridDamClade.Id,
|
||
|
|
DamSpecies = HybridDamSpecies.Id,
|
||
|
|
DominantParent = HybridDominantParent,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Skills (same as purebred path).
|
||
|
|
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
|
||
|
|
foreach (var raw in Background.SkillProficiencies)
|
||
|
|
{
|
||
|
|
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
|
||
|
|
catch (ArgumentException) { /* unknown skill names ignored */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
c.MaxHp = c.ComputeMaxHpFromScratch();
|
||
|
|
c.CurrentHp = c.MaxHp;
|
||
|
|
|
||
|
|
if (string.Equals(ClassDef.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
|
||
|
|
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
|
||
|
|
|
||
|
|
if (itemsForStartingKit is not null)
|
||
|
|
ApplyStartingKit(c, itemsForStartingKit);
|
||
|
|
|
||
|
|
character = c;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Adds every entry from <see cref="ClassDef.StartingKit"/> to the
|
||
|
|
/// character's inventory and auto-equips entries flagged for it. Logs and
|
||
|
|
/// continues on missing items / unknown slots — content bugs should fail
|
||
|
|
/// loud at content-validate time, not crash character creation.
|
||
|
|
/// </summary>
|
||
|
|
public static void ApplyStartingKit(Character c, IReadOnlyDictionary<string, ItemDef> items)
|
||
|
|
{
|
||
|
|
foreach (var entry in c.ClassDef.StartingKit)
|
||
|
|
{
|
||
|
|
if (!items.TryGetValue(entry.ItemId, out var def))
|
||
|
|
continue; // unknown item id — skip silently (caught by ContentValidate)
|
||
|
|
|
||
|
|
var inst = c.Inventory.Add(def, Math.Max(1, entry.Qty));
|
||
|
|
if (!entry.AutoEquip || string.IsNullOrEmpty(entry.EquipSlot))
|
||
|
|
continue;
|
||
|
|
|
||
|
|
var slot = EquipSlotExtensions.FromJson(entry.EquipSlot);
|
||
|
|
if (slot is null) continue;
|
||
|
|
// Best-effort equip; ignore the error string here. If a structural
|
||
|
|
// conflict occurs (two-handed in main when off-hand pre-occupied),
|
||
|
|
// the item stays in the bag rather than blocking creation.
|
||
|
|
c.Inventory.TryEquip(inst, slot.Value, out _);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static AbilityScores ApplyMods(AbilityScores a, IReadOnlyDictionary<string, int> mods)
|
||
|
|
{
|
||
|
|
if (mods is null || mods.Count == 0) return a;
|
||
|
|
var dict = new Dictionary<AbilityId, int>();
|
||
|
|
foreach (var kv in mods)
|
||
|
|
{
|
||
|
|
if (TryParseAbility(kv.Key, out var id))
|
||
|
|
dict[id] = (dict.TryGetValue(id, out var existing) ? existing : 0) + kv.Value;
|
||
|
|
}
|
||
|
|
return a.Plus(dict);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static bool TryParseAbility(string raw, out AbilityId id)
|
||
|
|
{
|
||
|
|
switch (raw.ToUpperInvariant())
|
||
|
|
{
|
||
|
|
case "STR": id = AbilityId.STR; return true;
|
||
|
|
case "DEX": id = AbilityId.DEX; return true;
|
||
|
|
case "CON": id = AbilityId.CON; return true;
|
||
|
|
case "INT": id = AbilityId.INT; return true;
|
||
|
|
case "WIS": id = AbilityId.WIS; return true;
|
||
|
|
case "CHA": id = AbilityId.CHA; return true;
|
||
|
|
default: id = AbilityId.STR; return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string SkillToJsonName(SkillId s) => s switch
|
||
|
|
{
|
||
|
|
SkillId.Acrobatics => "acrobatics",
|
||
|
|
SkillId.AnimalHandling => "animal_handling",
|
||
|
|
SkillId.Arcana => "arcana",
|
||
|
|
SkillId.Athletics => "athletics",
|
||
|
|
SkillId.Deception => "deception",
|
||
|
|
SkillId.History => "history",
|
||
|
|
SkillId.Insight => "insight",
|
||
|
|
SkillId.Intimidation => "intimidation",
|
||
|
|
SkillId.Investigation => "investigation",
|
||
|
|
SkillId.Medicine => "medicine",
|
||
|
|
SkillId.Nature => "nature",
|
||
|
|
SkillId.Perception => "perception",
|
||
|
|
SkillId.Performance => "performance",
|
||
|
|
SkillId.Persuasion => "persuasion",
|
||
|
|
SkillId.Religion => "religion",
|
||
|
|
SkillId.SleightOfHand => "sleight_of_hand",
|
||
|
|
SkillId.Stealth => "stealth",
|
||
|
|
SkillId.Survival => "survival",
|
||
|
|
_ => s.ToString().ToLowerInvariant(),
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Stat-rolling ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Roll 4d6-drop-lowest six times, returning a fresh <see cref="AbilityScores"/>
|
||
|
|
/// in (STR, DEX, CON, INT, WIS, CHA) order. Player assigns afterward.
|
||
|
|
///
|
||
|
|
/// Seed:
|
||
|
|
/// <c>worldSeed ^ C.RNG_STAT_ROLL ^ msSinceGameStart</c>
|
||
|
|
/// where <paramref name="msSinceGameStart"/> is wall-clock ms since process
|
||
|
|
/// launch in production, or a fixed test override for reproducibility.
|
||
|
|
/// </summary>
|
||
|
|
public static AbilityScores RollAbilityScores(ulong worldSeed, ulong msSinceGameStart)
|
||
|
|
{
|
||
|
|
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_STAT_ROLL ^ msSinceGameStart);
|
||
|
|
int[] r = new int[6];
|
||
|
|
for (int i = 0; i < 6; i++) r[i] = Roll4d6DropLowest(rng);
|
||
|
|
return new AbilityScores(r[0], r[1], r[2], r[3], r[4], r[5]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>4d6, drop the lowest; returns 3..18.</summary>
|
||
|
|
public static int Roll4d6DropLowest(SeededRng rng)
|
||
|
|
{
|
||
|
|
int d1 = (int)(rng.NextUInt64() % 6) + 1;
|
||
|
|
int d2 = (int)(rng.NextUInt64() % 6) + 1;
|
||
|
|
int d3 = (int)(rng.NextUInt64() % 6) + 1;
|
||
|
|
int d4 = (int)(rng.NextUInt64() % 6) + 1;
|
||
|
|
int low = Math.Min(d1, Math.Min(d2, Math.Min(d3, d4)));
|
||
|
|
return d1 + d2 + d3 + d4 - low;
|
||
|
|
}
|
||
|
|
}
|