mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Bundle of uncommitted working-tree edits across combat engine, expedition cycle, flavor pools, and TwinBee/zone narration. Includes new files: combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus CHANGES_24H.md and REBALANCE_NOTES.md scratch notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
7.4 KiB
Go
238 lines
7.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestProficiencyBonus(t *testing.T) {
|
|
cases := []struct{ level, want int }{
|
|
{1, 2}, {2, 2}, {3, 2}, {4, 2},
|
|
{5, 3}, {6, 3}, {7, 3}, {8, 3},
|
|
{9, 4}, {12, 4},
|
|
{13, 5}, {16, 5},
|
|
{17, 6}, {20, 6},
|
|
}
|
|
for _, c := range cases {
|
|
if got := proficiencyBonus(c.level); got != c.want {
|
|
t.Errorf("proficiencyBonus(%d) = %d, want %d", c.level, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassAttackStatMod(t *testing.T) {
|
|
c := &DnDCharacter{STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 8, CHA: 18}
|
|
cases := []struct {
|
|
class DnDClass
|
|
want int
|
|
}{
|
|
{ClassFighter, 3}, // STR 16 → +3
|
|
{ClassRogue, 1}, // DEX 12 → +1
|
|
{ClassRanger, 1},
|
|
{ClassMage, 0}, // INT 10 → 0
|
|
{ClassCleric, -1}, // WIS 8 → -1
|
|
}
|
|
for _, tc := range cases {
|
|
c.Class = tc.class
|
|
if got := classAttackStatMod(c); got != tc.want {
|
|
t.Errorf("classAttackStatMod(%s, STR=%d/DEX=%d/INT=%d/WIS=%d) = %d, want %d",
|
|
tc.class, c.STR, c.DEX, c.INT, c.WIS, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDnDPlayerAttackBonus(t *testing.T) {
|
|
// L1 Fighter, STR 17 (+3 mod), prof 2, weapon bonus 2 → +7
|
|
fighter := &DnDCharacter{Class: ClassFighter, Level: 1, STR: 17}
|
|
if got := dndPlayerAttackBonus(fighter); got != 7 {
|
|
t.Errorf("L1 Fighter STR17 attack bonus = %d, want 7", got)
|
|
}
|
|
// L5 Mage, INT 16 (+3), prof 3, no weapon bonus → +6
|
|
mage := &DnDCharacter{Class: ClassMage, Level: 5, INT: 16}
|
|
if got := dndPlayerAttackBonus(mage); got != 6 {
|
|
t.Errorf("L5 Mage INT16 attack bonus = %d, want 6", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyDnDPlayerLayer(t *testing.T) {
|
|
// Combat MaxHP is now c.HPMax + stats.HPBonus. The HPBonus carries the
|
|
// equipment/arena/housing buffs DerivePlayerStats accumulated.
|
|
stats := CombatStats{MaxHP: 50, HPBonus: 8, Attack: 10, Defense: 5}
|
|
c := &DnDCharacter{Class: ClassFighter, Level: 1, STR: 17, HPMax: 12, ArmorClass: 16}
|
|
applyDnDPlayerLayer(&stats, c)
|
|
if stats.AC != 16 {
|
|
t.Errorf("AC = %d, want 16", stats.AC)
|
|
}
|
|
if stats.AttackBonus != 7 {
|
|
t.Errorf("AttackBonus = %d, want 7", stats.AttackBonus)
|
|
}
|
|
if stats.MaxHP != 20 { // 12 + 8
|
|
t.Errorf("MaxHP = %d, want 20 (HPMax 12 + HPBonus 8)", stats.MaxHP)
|
|
}
|
|
if stats.Attack != 10 {
|
|
t.Errorf("Attack mutated: %d, want unchanged 10", stats.Attack)
|
|
}
|
|
}
|
|
|
|
func TestApplyDnDArenaMonsterLayer(t *testing.T) {
|
|
stats := CombatStats{MaxHP: 100, Attack: 20}
|
|
applyDnDArenaMonsterLayer(&stats, 12)
|
|
// AC base 10 + 12*0.25 = 13
|
|
if stats.AC != 13 {
|
|
t.Errorf("AC = %d, want 13", stats.AC)
|
|
}
|
|
// Atk base 4 + 12*0.30 = 7 (int trunc)
|
|
if stats.AttackBonus != 7 {
|
|
t.Errorf("AttackBonus = %d, want 7", stats.AttackBonus)
|
|
}
|
|
}
|
|
|
|
func TestClassStatPriority(t *testing.T) {
|
|
// Each class's array must contain {15,14,13,12,10,8} exactly once.
|
|
want := standardArray
|
|
for _, class := range []DnDClass{ClassFighter, ClassRogue, ClassMage, ClassCleric, ClassRanger} {
|
|
got := classStatPriority(class)
|
|
if !isStandardArray(got) {
|
|
t.Errorf("classStatPriority(%s) = %v, not a permutation of %v", class, got, want)
|
|
}
|
|
}
|
|
// Fighter: STR (idx 0) is the highest stat.
|
|
f := classStatPriority(ClassFighter)
|
|
if f[0] != 15 {
|
|
t.Errorf("Fighter STR = %d, want 15 (primary stat)", f[0])
|
|
}
|
|
// Mage: INT (idx 3) is the highest stat.
|
|
m := classStatPriority(ClassMage)
|
|
if m[3] != 15 {
|
|
t.Errorf("Mage INT = %d, want 15 (primary stat)", m[3])
|
|
}
|
|
// Cleric: WIS (idx 4) is the highest stat.
|
|
c := classStatPriority(ClassCleric)
|
|
if c[4] != 15 {
|
|
t.Errorf("Cleric WIS = %d, want 15 (primary stat)", c[4])
|
|
}
|
|
}
|
|
|
|
func TestApplyDnDDungeonMonsterLayer(t *testing.T) {
|
|
stats := CombatStats{}
|
|
applyDnDDungeonMonsterLayer(&stats, 1)
|
|
if stats.AC != 10 || stats.AttackBonus != 5 {
|
|
t.Errorf("T1 monster: AC=%d Atk=%d, want AC=10 Atk=5", stats.AC, stats.AttackBonus)
|
|
}
|
|
stats = CombatStats{}
|
|
applyDnDDungeonMonsterLayer(&stats, 5)
|
|
if stats.AC != 14 || stats.AttackBonus != 9 {
|
|
t.Errorf("T5 monster: AC=%d Atk=%d, want AC=14 Atk=9", stats.AC, stats.AttackBonus)
|
|
}
|
|
}
|
|
|
|
// TestSimulateCombat_RollsAppear: every attack event in a normal fight should
|
|
// carry a d20 Roll in [1,20] and a RollAgainst (target AC).
|
|
func TestSimulateCombat_RollsAppear(t *testing.T) {
|
|
player := Combatant{
|
|
Name: "P", IsPlayer: true,
|
|
Stats: CombatStats{
|
|
MaxHP: 50, Attack: 15, Defense: 5, Speed: 8,
|
|
AC: 14, AttackBonus: 5,
|
|
},
|
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
|
}
|
|
enemy := Combatant{
|
|
Name: "E",
|
|
Stats: CombatStats{
|
|
MaxHP: 30, Attack: 10, Defense: 3, Speed: 5,
|
|
AC: 12, AttackBonus: 4,
|
|
},
|
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
|
}
|
|
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
|
attackEvents := 0
|
|
for _, ev := range result.Events {
|
|
if ev.Actor != "player" && ev.Actor != "enemy" {
|
|
continue
|
|
}
|
|
if ev.Action != "hit" && ev.Action != "miss" && ev.Action != "crit" && ev.Action != "block" {
|
|
continue
|
|
}
|
|
attackEvents++
|
|
if ev.Roll < 1 || ev.Roll > 20 {
|
|
t.Errorf("roll out of range: %+v", ev)
|
|
}
|
|
if ev.RollAgainst <= 0 {
|
|
t.Errorf("RollAgainst should be set: %+v", ev)
|
|
}
|
|
}
|
|
if attackEvents == 0 {
|
|
t.Error("no attack events produced")
|
|
}
|
|
}
|
|
|
|
// TestD20HitRate_PlayerDominant: player +10 attack vs AC 11 should hit ~95%
|
|
// (any roll 2+ hits, plus nat 20). Run many trials for statistical bound.
|
|
func TestD20HitRate_PlayerDominant(t *testing.T) {
|
|
hits, total := 0, 2000
|
|
for i := 0; i < total; i++ {
|
|
// Single-round simulation: large player HP/attack, high attack bonus,
|
|
// monster low HP/AC. Count hit/crit events from "player".
|
|
player := Combatant{
|
|
IsPlayer: true,
|
|
Stats: CombatStats{MaxHP: 1000, Attack: 1, Defense: 0, Speed: 100, AC: 99, AttackBonus: 10},
|
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
|
}
|
|
enemy := Combatant{
|
|
Stats: CombatStats{MaxHP: 100000, Attack: 0, Defense: 0, Speed: 1, AC: 11, AttackBonus: 0},
|
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
|
}
|
|
// Run only one phase / one round to isolate first-attack hit rate.
|
|
phases := []CombatPhase{{Name: "Test", Rounds: 1, AttackWeight: 1.0, DefenseWeight: 1.0, SpeedWeight: 1.0}}
|
|
result := SimulateCombat(player, enemy, phases)
|
|
for _, ev := range result.Events {
|
|
if ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit") {
|
|
hits++
|
|
break
|
|
}
|
|
if ev.Actor == "player" && ev.Action == "miss" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
rate := float64(hits) / float64(total)
|
|
// Expected: roll >=1 always (1=fumble miss, 2-20 hit). Nat 20 also hits. So 19/20 = 95%.
|
|
if rate < 0.92 || rate > 0.98 {
|
|
t.Errorf("player-dominant hit rate = %.3f, want ~0.95", rate)
|
|
}
|
|
}
|
|
|
|
// TestD20FumbleAndCrit: nat 1 always misses, nat 20 always crits. Sample
|
|
// many rolls and confirm at least one of each occurs over many rounds.
|
|
func TestD20FumbleAndCrit(t *testing.T) {
|
|
sawFumble, sawCrit := false, false
|
|
player := Combatant{
|
|
IsPlayer: true,
|
|
Stats: CombatStats{MaxHP: 100000, Attack: 5, Defense: 0, Speed: 50, AC: 10, AttackBonus: 5},
|
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
|
}
|
|
enemy := Combatant{
|
|
Stats: CombatStats{MaxHP: 100000, Attack: 1, Defense: 0, Speed: 50, AC: 13, AttackBonus: 0},
|
|
Mods: CombatModifiers{DamageReduct: 1.0},
|
|
}
|
|
phases := []CombatPhase{{Name: "Long", Rounds: 200, AttackWeight: 1.0, DefenseWeight: 1.0, SpeedWeight: 1.0}}
|
|
result := SimulateCombat(player, enemy, phases)
|
|
for _, ev := range result.Events {
|
|
if ev.Actor != "player" {
|
|
continue
|
|
}
|
|
if ev.Action == "miss" && ev.Desc == "fumble" {
|
|
sawFumble = true
|
|
}
|
|
if ev.Action == "crit" && ev.Roll == 20 {
|
|
sawCrit = true
|
|
}
|
|
}
|
|
if !sawFumble {
|
|
t.Error("never observed a fumble (nat 1) over 200 rounds")
|
|
}
|
|
if !sawCrit {
|
|
t.Error("never observed a nat-20 crit over 200 rounds")
|
|
}
|
|
}
|