mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint: Phases 1-8 (per gogobee_dnd_session_summary.md): - Race/class/stats system, !setup flow, !sheet, !respec - d20-vs-AC combat with race/class passives, auto-migration - XP/level-up, skill checks, NPC refund hooks - Equipment slot mapping, rarity, !rest short/long - Pre-arm active abilities, resource pools - Bestiary, !roll/!stats/!level, onboarding DM - Audit fixes A-I, flavor wiring under internal/flavor/ - Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix) Phase 9 SP1 — spell system foundations: - 79 SpellDefinitions (76 in-scope + 3 reaction-deferred) - dnd_known_spells, dnd_spell_slots tables - pending_cast / concentration_* columns on dnd_character - Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math - Long-rest refreshes slots; respec wipes spell state - Auto-grant default known list on !setup confirm and auto-migration Phase 9 SP2 — !cast / !spells / !prepare commands: - Out-of-combat HEAL and UTILITY resolve immediately - Damage/control/buff queue as pending_cast for next combat - Audit-style save-then-debit, slot refund on save failure - !cast --drop, concentration supersession, prep-cap enforcement - Reaction spells refused with Phase 11 note SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn tests) are next. Build clean, full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
247
internal/plugin/dnd_round1_test.go
Normal file
247
internal/plugin/dnd_round1_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Race passives ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestApplyRacePassives(t *testing.T) {
|
||||
cases := []struct {
|
||||
race DnDRace
|
||||
wantLucky, wantRage, wantPoisonOK 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},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
mods := CombatModifiers{}
|
||||
stats := CombatStats{}
|
||||
applyRacePassives(&stats, &mods, &DnDCharacter{Race: tc.race})
|
||||
if mods.LuckyReroll != tc.wantLucky {
|
||||
t.Errorf("%s LuckyReroll = %v, want %v", tc.race, mods.LuckyReroll, tc.wantLucky)
|
||||
}
|
||||
if mods.RageReady != tc.wantRage {
|
||||
t.Errorf("%s RageReady = %v, want %v", tc.race, mods.RageReady, tc.wantRage)
|
||||
}
|
||||
if mods.PoisonResist != tc.wantPoisonOK {
|
||||
t.Errorf("%s PoisonResist = %v, want %v", tc.race, mods.PoisonResist, tc.wantPoisonOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHalflingLuckyEmitsReroll: a Halfling fighter rolling many d20s should
|
||||
// emit at least one lucky_reroll event over a long fight, and never more than
|
||||
// one (single-use per combat).
|
||||
func TestHalflingLuckyEmitsReroll(t *testing.T) {
|
||||
player := Combatant{
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{MaxHP: 100000, Attack: 5, Defense: 0, Speed: 50, AC: 10, AttackBonus: 5},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0, LuckyReroll: true},
|
||||
}
|
||||
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}}
|
||||
|
||||
saw := 0
|
||||
for trial := 0; trial < 5; trial++ {
|
||||
result := SimulateCombat(player, enemy, phases)
|
||||
rerolls := 0
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "lucky_reroll" {
|
||||
rerolls++
|
||||
}
|
||||
}
|
||||
if rerolls > 1 {
|
||||
t.Errorf("trial %d: %d lucky_reroll events in one fight (should be ≤1)", trial, rerolls)
|
||||
}
|
||||
if rerolls == 1 {
|
||||
saw++
|
||||
}
|
||||
}
|
||||
if saw == 0 {
|
||||
t.Error("never observed a Halfling Lucky reroll over 5 long fights — extremely unlikely (~5%^5)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrcRageFiresOnLowHP: an Orc whose HP gets driven below 50% should emit
|
||||
// a "rage" event and have higher damage on the following attack.
|
||||
func TestOrcRageFiresOnLowHP(t *testing.T) {
|
||||
// Player starts with 50 max HP, low attack. Enemy is a tank that hits
|
||||
// back hard so the player crosses the 50% threshold.
|
||||
player := Combatant{
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{MaxHP: 50, Attack: 10, Defense: 5, Speed: 5, AC: 10, AttackBonus: 5},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0, RageReady: true},
|
||||
}
|
||||
enemy := Combatant{
|
||||
Stats: CombatStats{MaxHP: 100000, Attack: 30, Defense: 5, Speed: 5, AC: 10, AttackBonus: 8},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
phases := []CombatPhase{{Name: "Brawl", Rounds: 40, AttackWeight: 1.0, DefenseWeight: 1.0, SpeedWeight: 1.0}}
|
||||
|
||||
rageFiredEver := false
|
||||
for trial := 0; trial < 5; trial++ {
|
||||
result := SimulateCombat(player, enemy, phases)
|
||||
rageCount := 0
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "rage" {
|
||||
rageCount++
|
||||
}
|
||||
}
|
||||
if rageCount > 1 {
|
||||
t.Errorf("trial %d: %d rage events (should be ≤1)", trial, rageCount)
|
||||
}
|
||||
if rageCount == 1 {
|
||||
rageFiredEver = true
|
||||
}
|
||||
}
|
||||
if !rageFiredEver {
|
||||
t.Error("Orc Rage never fired over 5 brutal fights — should always trigger when player crosses 50%")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDwarfPoisonResistance: poison_tick damage applied to a Dwarf should be
|
||||
// roughly half of the unprotected baseline. We measure by summing the actual
|
||||
// poison_tick events rather than HP delta, since the engine's exhaustion
|
||||
// tiebreaker can zero HP independently of poison damage.
|
||||
func TestDwarfPoisonResistance(t *testing.T) {
|
||||
enemy := Combatant{
|
||||
Stats: CombatStats{MaxHP: 1000, Attack: 5, Defense: 0, Speed: 5, AC: 10, AttackBonus: 0},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
Ability: &MonsterAbility{Name: "Spit", Phase: "any", ProcChance: 1.0, Effect: "poison"},
|
||||
}
|
||||
phases := []CombatPhase{{Name: "Tick", Rounds: 5, AttackWeight: 0.1, DefenseWeight: 1.0, SpeedWeight: 0.1}}
|
||||
|
||||
makePlayer := func(resist bool) Combatant {
|
||||
return Combatant{
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{MaxHP: 200, Attack: 1, Defense: 100, Speed: 100, AC: 99, AttackBonus: 0},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0, PoisonResist: resist},
|
||||
}
|
||||
}
|
||||
sumPoison := func(r CombatResult) int {
|
||||
s := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "poison_tick" {
|
||||
s += ev.Damage
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
totalUnprot, totalProt := 0, 0
|
||||
const trials = 300
|
||||
for i := 0; i < trials; i++ {
|
||||
totalUnprot += sumPoison(SimulateCombat(makePlayer(false), enemy, phases))
|
||||
totalProt += sumPoison(SimulateCombat(makePlayer(true), enemy, phases))
|
||||
}
|
||||
if totalUnprot == 0 {
|
||||
t.Fatal("no unprotected poison damage observed; test setup broken")
|
||||
}
|
||||
avgU := float64(totalUnprot) / float64(trials)
|
||||
avgP := float64(totalProt) / float64(trials)
|
||||
ratio := avgP / avgU
|
||||
if ratio < 0.35 || ratio > 0.65 {
|
||||
t.Errorf("dwarf poison ratio = %.3f (avg unprot=%.1f, prot=%.1f); want ~0.5",
|
||||
ratio, avgU, avgP)
|
||||
}
|
||||
}
|
||||
|
||||
// ── combat_level freeze ──────────────────────────────────────────────────────
|
||||
|
||||
func TestCheckAdvLevelUp_FrozenForDnDChars(t *testing.T) {
|
||||
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Skip("prod db not present")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dst := filepath.Join(dir, "gogobee.db")
|
||||
in, _ := os.Open(src)
|
||||
defer in.Close()
|
||||
out, _ := os.Create(dst)
|
||||
defer out.Close()
|
||||
io.Copy(out, in)
|
||||
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
uid := id.UserID("@freeze_test:example")
|
||||
|
||||
// Case 1: no dnd_character → combat XP advances combat_level normally.
|
||||
char := &AdventureCharacter{
|
||||
UserID: uid,
|
||||
DisplayName: "freeze_test",
|
||||
CombatLevel: 5,
|
||||
CombatXP: 1000, // far over the threshold for L6
|
||||
}
|
||||
leveled, newLvl := checkAdvLevelUp(char, "combat")
|
||||
if !leveled || newLvl <= 5 {
|
||||
t.Errorf("non-DnD player: leveled=%v newLvl=%d, want leveled=true, newLvl>5", leveled, newLvl)
|
||||
}
|
||||
|
||||
// Case 2: confirmed dnd_character → frozen.
|
||||
dnd := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 1,
|
||||
STR: 15, DEX: 14, CON: 13, INT: 12, WIS: 10, CHA: 8,
|
||||
HPMax: 12, HPCurrent: 12, ArmorClass: 16,
|
||||
PendingSetup: false, AutoMigrated: false,
|
||||
}
|
||||
if err := SaveDnDCharacter(dnd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
char2 := &AdventureCharacter{
|
||||
UserID: uid,
|
||||
DisplayName: "freeze_test",
|
||||
CombatLevel: 5,
|
||||
CombatXP: 1000,
|
||||
}
|
||||
leveled, newLvl = checkAdvLevelUp(char2, "combat")
|
||||
if leveled || newLvl != 5 {
|
||||
t.Errorf("DnD player: leveled=%v newLvl=%d, want leveled=false, newLvl=5", leveled, newLvl)
|
||||
}
|
||||
|
||||
// Case 3: skill levels still advance for the same player.
|
||||
char2.MiningSkill = 5
|
||||
char2.MiningXP = 1000
|
||||
leveled, newLvl = checkAdvLevelUp(char2, "mining")
|
||||
if !leveled || newLvl <= 5 {
|
||||
t.Errorf("DnD player mining: leveled=%v newLvl=%d, want leveled=true", leveled, newLvl)
|
||||
}
|
||||
}
|
||||
|
||||
// ── !respec cooldown logic ──────────────────────────────────────────────────
|
||||
|
||||
func TestRespecCooldownFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{7 * 24 * time.Hour, "7d"},
|
||||
{6*24*time.Hour + 5*time.Hour, "6d 5h"},
|
||||
{3 * time.Hour, "3h"},
|
||||
{45 * time.Minute, "45m"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := formatRespecDuration(c.d)
|
||||
if got != c.want {
|
||||
t.Errorf("formatRespecDuration(%v) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user