Files
gogobee/internal/plugin/combat_narrative_test.go
prosolis 7c450aaefb Add forward-simulating combat engine with consumables, monster abilities, and narrative rendering
Replaces the single-roll probability system for dungeon and arena combat with
a multi-phase simulation engine where gear, buffs, pets, and NPCs are meaningful
mechanical inputs. Adds consumable items (auto-crafted from forage/mine/fish drops),
monster abilities for T2+ enemies, and Dragon Quest-style combat narrative with
phased message delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:05:22 -07:00

193 lines
5.6 KiB
Go

package plugin
import (
"strings"
"testing"
)
func TestRenderCombatLog_ProducesMessages(t *testing.T) {
player := Combatant{
Name: "Hero",
Stats: CombatStats{MaxHP: 100, Attack: 20, Defense: 10, Speed: 8, CritRate: 0.05},
Mods: CombatModifiers{DamageReduct: 1.0},
IsPlayer: true,
}
enemy := Combatant{
Name: "Goblin",
Stats: CombatStats{MaxHP: 60, Attack: 12, Defense: 5, Speed: 6, CritRate: 0.03},
Mods: CombatModifiers{DamageReduct: 1.0},
}
result := SimulateCombat(player, enemy, defaultCombatPhases)
msgs := RenderCombatLog(result, "Hero", "Goblin")
if len(msgs) < 1 {
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
}
}
func TestRenderCombatLog_PhaseHeaders(t *testing.T) {
player := Combatant{
Name: "Hero",
Stats: CombatStats{MaxHP: 100, Attack: 20, Defense: 10, Speed: 8, CritRate: 0.05},
Mods: CombatModifiers{DamageReduct: 1.0},
IsPlayer: true,
}
enemy := Combatant{
Name: "Rat",
Stats: CombatStats{MaxHP: 40, Attack: 8, Defense: 3, Speed: 4},
Mods: CombatModifiers{DamageReduct: 1.0},
}
result := SimulateCombat(player, enemy, defaultCombatPhases)
msgs := RenderCombatLog(result, "Hero", "Rat")
hasOpening := false
for _, m := range msgs {
if strings.Contains(m, "Opening") {
hasOpening = true
}
}
if !hasOpening {
t.Error("should have an Opening phase header")
}
}
func TestRenderCombatLog_WinPhaseMessages(t *testing.T) {
result := CombatResult{
PlayerWon: true,
PlayerStartHP: 100,
EnemyStartHP: 50,
PlayerEndHP: 80,
EnemyEndHP: 0,
TotalRounds: 3,
Closeness: 0.2,
Events: []CombatEvent{
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 25, EnemyHP: 25, PlayerHP: 100},
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 25, EnemyHP: 0, PlayerHP: 80},
},
}
msgs := RenderCombatLog(result, "Hero", "Slime")
if len(msgs) < 1 {
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
}
// Phase messages should contain damage numbers
found := false
for _, m := range msgs {
if strings.Contains(m, "25") {
found = true
}
}
if !found {
t.Error("phase messages should contain damage values")
}
}
func TestRenderCombatLog_LossPhaseMessages(t *testing.T) {
result := CombatResult{
PlayerWon: false,
PlayerStartHP: 100,
EnemyStartHP: 80,
PlayerEndHP: 0,
EnemyEndHP: 40,
TotalRounds: 4,
Closeness: 0.5,
Events: []CombatEvent{
{Round: 1, Phase: "opening", Actor: "enemy", Action: "hit", Damage: 50, PlayerHP: 50, EnemyHP: 80},
{Round: 2, Phase: "clash", Actor: "enemy", Action: "hit", Damage: 50, PlayerHP: 0, EnemyHP: 80},
},
}
msgs := RenderCombatLog(result, "Hero", "Dragon")
if len(msgs) < 1 {
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
}
}
func TestRenderCombatLogArena_ProducesPhaseMessages(t *testing.T) {
result := CombatResult{
PlayerWon: true,
PlayerStartHP: 100,
EnemyStartHP: 60,
PlayerEndHP: 70,
EnemyEndHP: 0,
TotalRounds: 3,
Closeness: 0.3,
Events: []CombatEvent{
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 30, PlayerHP: 100},
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 0, PlayerHP: 70},
},
}
msgs := RenderCombatLogArena(result, "Hero", "Ratticus")
if len(msgs) < 1 {
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
}
}
func TestRenderCombatLog_ConsumableEvents(t *testing.T) {
result := CombatResult{
PlayerWon: true,
PlayerStartHP: 100,
EnemyStartHP: 60,
PlayerEndHP: 80,
EnemyEndHP: 0,
Events: []CombatEvent{
{Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable", Desc: "Goblin Grease"},
{Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage", Damage: 8, PlayerHP: 100, EnemyHP: 52},
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, PlayerHP: 100, EnemyHP: 22},
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 22, PlayerHP: 80, EnemyHP: 0},
},
}
msgs := RenderCombatLog(result, "Hero", "Imp")
found := false
for _, m := range msgs {
if strings.Contains(m, "Goblin Grease") {
found = true
}
}
if !found {
t.Error("should render consumable use event mentioning item name")
}
}
func TestRenderCombatLog_MonsterAbilityEvents(t *testing.T) {
result := CombatResult{
PlayerWon: true,
PlayerStartHP: 100,
EnemyStartHP: 60,
PlayerEndHP: 50,
EnemyEndHP: 0,
Events: []CombatEvent{
{Round: 1, Phase: "opening", Actor: "enemy", Action: "armor_break", Damage: 0, PlayerHP: 100, EnemyHP: 60},
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, PlayerHP: 100, EnemyHP: 30},
{Round: 2, Phase: "clash", Actor: "enemy", Action: "hit", Damage: 25, PlayerHP: 75, EnemyHP: 30},
{Round: 2, Phase: "clash", Actor: "enemy", Action: "poison_tick", Damage: 5, PlayerHP: 70, EnemyHP: 30},
{Round: 3, Phase: "decisive", Actor: "player", Action: "crit", Damage: 30, PlayerHP: 50, EnemyHP: 0},
},
}
msgs := RenderCombatLog(result, "Hero", "Wyrm")
hasArmorBreak := false
hasPoison := false
for _, m := range msgs {
if strings.Contains(m, "armor") || strings.Contains(m, "Armor") || strings.Contains(m, "defense") || strings.Contains(m, "Defense") {
hasArmorBreak = true
}
if strings.Contains(m, "poison") || strings.Contains(m, "Poison") || strings.Contains(m, "toxin") || strings.Contains(m, "Venom") || strings.Contains(m, "venom") {
hasPoison = true
}
}
if !hasArmorBreak {
t.Error("should render armor_break event")
}
if !hasPoison {
t.Error("should render poison_tick event")
}
}