mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user