UX S6: race truth-up — wire Tiefling fire resist + best-fit hints

R22: replace race copy that promised mechanics the engine doesn't deliver.
- Tiefling: wire FireResist as a CombatModifier. Enemy main attack is
  halved when monster is FireAttacker-tagged; aoe_fire abilities are
  halved unconditionally; fire-tagged traps deal half damage to Tieflings.
  DnDMonsterTemplate carries FireAttacker; toCombatStats propagates it.
  Hand-authored fire entries tagged in dnd_bestiary.go (flameskull,
  magmin, azer, salamander, fire_elemental, emberlord_thyrak,
  young_red_dragon, infernax, belaxath).
- Open5e tuned generator derives FireAttacker from the highest-AvgDamage
  attack's DamageType (threshold AvgDamage>=5). 19 tuned monsters tag.
  Regenerated bestiary_tuned_data.go.
- Elf: drop "immune to sleep" (no sleep mechanic); reframe as keen
  senses + trance flavor.
- Half-Elf: drop "two bonus skill proficiencies" (no skill system);
  reframe as adaptable cross-cultural know-how.
- Tiefling copy: drop "bonus on CHA checks" (no checks); keep fire
  resistance with flavor framing.

R23: DnDRaceInfo grows a BestFit field; renderRaceMenu emits an
"_best with: …_" hint per race so spiky stat spreads (Orc -1/-1/-1)
read as specialist picks rather than a brick of penalties.

R24: dnd.go header comment for the caster classes now reflects the
shipped state (Playable=true, spell lists populated) instead of the
pre-Open5e scaffold language.

Tests: TestApplyRacePassives gains a FireResist column; new
TestTieflingFireResistance asserts ~0.5x ratio over a 300-trial sweep
against a FireAttacker enemy. Full suite green.
This commit is contained in:
prosolis
2026-05-14 22:06:22 -07:00
parent 1512f6cc50
commit 6386161402
9 changed files with 198 additions and 67 deletions

View File

@@ -15,16 +15,16 @@ import (
func TestApplyRacePassives(t *testing.T) {
cases := []struct {
race DnDRace
wantLucky, wantRage, wantPoisonOK bool
race DnDRace
wantLucky, wantRage, wantPoisonOK, wantFireOK bool
}{
{RaceHalfling, true, false, false},
{RaceOrc, false, true, false},
{RaceDwarf, false, false, true},
{RaceHuman, false, false, false},
{RaceElf, false, false, false},
{RaceTiefling, false, false, false},
{RaceHalfElf, false, false, false},
{RaceHalfling, true, false, false, false},
{RaceOrc, false, true, false, false},
{RaceDwarf, false, false, true, false},
{RaceHuman, false, false, false, false},
{RaceElf, false, false, false, false},
{RaceTiefling, false, false, false, true},
{RaceHalfElf, false, false, false, false},
}
for _, tc := range cases {
mods := CombatModifiers{}
@@ -39,6 +39,9 @@ func TestApplyRacePassives(t *testing.T) {
if mods.PoisonResist != tc.wantPoisonOK {
t.Errorf("%s PoisonResist = %v, want %v", tc.race, mods.PoisonResist, tc.wantPoisonOK)
}
if mods.FireResist != tc.wantFireOK {
t.Errorf("%s FireResist = %v, want %v", tc.race, mods.FireResist, tc.wantFireOK)
}
}
}
@@ -161,6 +164,55 @@ func TestDwarfPoisonResistance(t *testing.T) {
}
}
// TestTieflingFireResistance: a fire-tagged monster's primary attack should
// land for ~half damage on a Tiefling. Measured by summing the per-hit damage
// events from a stat-locked encounter so the dice noise averages out.
func TestTieflingFireResistance(t *testing.T) {
enemy := Combatant{
// FireAttacker tags this as fire-themed (e.g., fire elemental).
// AttackBonus high enough to land reliably against player AC 10.
Stats: CombatStats{MaxHP: 100000, Attack: 12, Defense: 0, Speed: 5, AC: 10, AttackBonus: 10, FireAttacker: true},
Mods: CombatModifiers{DamageReduct: 1.0},
}
phases := []CombatPhase{{Name: "Bake", Rounds: 6, AttackWeight: 1.0, DefenseWeight: 1.0, SpeedWeight: 1.0}}
makePlayer := func(resist bool) Combatant {
// Sturdy player that won't die mid-trial; low attack so the enemy
// keeps swinging the whole phase. No block, no DR ride, no crit
// path on the enemy side beyond a nat 20.
return Combatant{
IsPlayer: true,
Stats: CombatStats{MaxHP: 200000, Attack: 1, Defense: 0, Speed: 5, AC: 10, AttackBonus: 0},
Mods: CombatModifiers{DamageReduct: 1.0, FireResist: resist},
}
}
sumEnemyHits := func(r CombatResult) int {
s := 0
for _, ev := range r.Events {
if ev.Actor == "enemy" && (ev.Action == "hit" || ev.Action == "crit") {
s += ev.Damage
}
}
return s
}
totalUnprot, totalProt := 0, 0
const trials = 300
for i := 0; i < trials; i++ {
totalUnprot += sumEnemyHits(SimulateCombat(makePlayer(false), enemy, phases))
totalProt += sumEnemyHits(SimulateCombat(makePlayer(true), enemy, phases))
}
if totalUnprot == 0 {
t.Fatal("no unprotected fire damage observed; test setup broken")
}
avgU := float64(totalUnprot) / float64(trials)
avgP := float64(totalProt) / float64(trials)
ratio := avgP / avgU
if ratio < 0.40 || ratio > 0.65 {
t.Errorf("tiefling fire ratio = %.3f (avg unprot=%.1f, prot=%.1f); want ~0.5",
ratio, avgU, avgP)
}
}
// ── combat_level freeze ──────────────────────────────────────────────────────
func TestCheckAdvLevelUp_FrozenForDnDChars(t *testing.T) {