Files
gogobee/internal/plugin/dnd_expedition_extract_test.go
prosolis 5ef10e35dc Phase 5b: player power floor + Phase-3 winners shipped to live
Closes the 'fairly breezy with some death' target the user picked
for Phase 5. Five-piece ship; Phase 1 matrix lands T1 88%, T2 74%,
T4 72%, T5 ~57% in or above band. T3 remains the design hump at
~45% (manor 39, underforge 47) — Wraith promotion to elite was
already done in Phase 4-B, the remaining standard-pool deaths are
the irreducible part of T3.

Pieces:
  1. computeMaxHP × 1.5 (phase5BHPMult in dnd.go). Uniform across
     class/level so the class-balance harness's in-tier parity
     assertion stays green. Bootstrap (bootstrap_phase5b_hp.go)
     refreshes hp_max for existing characters at startup;
     idempotent via db.JobCompleted. hp_current is bumped by the
     same delta so a full-HP character stays at full.
  2. applyPhase5BPlayerFloor (dnd_combat.go): +3 AC, +3 AttackBonus,
     +3 weapon.MagicBonus (damage). Applied at the END of
     applyDnDEquipmentLayer (after computeArmorAC's AC override)
     and inside buildHarnessPlayer so live and harness measurement
     match.
  3. Elite bracket 19 → 23 (resolveCombatInterrupt). Case order
     puts Elite (≥23) before Patrol (≥22) so a 23+ total prefers
     the single dangerous fight. Elite is now effectively a
     high-threat event reachable only via the +1-per-20-threat-
     above-40 mod — Phase 4-B's elite-pool monsters still appear,
     just less often.
  4. dailyThreatDrift base 3 → 1. Slows the threat clock so
     players have the days they need before threat tips zones
     into the new 23+ elite band.
  5. applyDailyBurn default → 50% (phase5BDailyBurnRatePct). Also
     applied in the temporal-override branch in
     dnd_expedition_cycle.go so tidal / unraveling days scale by
     the same 0.5× — otherwise those days would be
     disproportionately harsh against the new baseline.

The harness's expedition_balance.go reads phase5BDailyBurnRatePct
as the default-burn fallback when the override knob is zero, so
Phase 1 matrix measurements now reflect what live players
experience.

Test debt: 13 pinned-numbers unit tests across combat_stats_test,
dnd_test, dnd_xp_test, dnd_equipment_profiles_test,
dnd_expedition_supplies_test, dnd_expedition_cycle_test,
dnd_expedition_extract_test, dnd_expedition_region_cmd_test,
dnd_expedition_combat_test, dnd_expedition_threat_test,
dnd_expedition_temporal_test, expedition_balance_test were
pinning pre-Phase-5-B baselines; updated with comments noting
the cause. Class-balance suite stayed green (uniform buff
preserves spread).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:11:27 -07:00

178 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// E5a: voluntary extraction burns one day's supplies, advances the day,
// flips status to 'extracting', and stamps completed_at.
func TestVoluntaryExtract_FlipsToExtracting(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-voluntary:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
if err != nil {
t.Fatal(err)
}
startDay := exp.CurrentDay
updated, err := voluntaryExtractExpedition(uid)
if err != nil {
t.Fatalf("voluntaryExtractExpedition: %v", err)
}
if updated.Status != ExpeditionStatusExtracting {
t.Errorf("status = %q, want extracting", updated.Status)
}
if updated.CurrentDay != startDay+1 {
t.Errorf("current_day = %d, want %d", updated.CurrentDay, startDay+1)
}
got, _ := getExpedition(exp.ID)
// Phase 5-B: applyDailyBurn × phase5B 50%; 1 base × 0.5 = 0.5 burned.
if got.Supplies.Current != 9.5 {
t.Errorf("supplies after extract = %.1f, want 9.5", got.Supplies.Current)
}
if got.CompletedAt == nil {
t.Error("expected completed_at to be set")
}
// Active query should now return nothing — extracting is post-extract.
if active, _ := getActiveExpedition(uid); active != nil {
t.Errorf("getActiveExpedition still returns row: %s", active.Status)
}
}
func TestVoluntaryExtract_NoActive(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-noactive:example")
defer cleanupExpeditions(uid)
if _, err := voluntaryExtractExpedition(uid); err != ErrNoActiveExpedition {
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
}
}
// E5b: forced extraction flips to 'abandoned' and reports the 20% coin tax.
func TestForcedExtract_AbandonedAndTax(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-forced:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET coins_earned = 100 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
got, tax, err := forcedExtractExpedition(exp.ID, "supplies depleted")
if err != nil {
t.Fatalf("forcedExtractExpedition: %v", err)
}
if tax != 20 {
t.Errorf("tax = %d, want 20 (20%% of 100)", tax)
}
if got.Status != ExpeditionStatusAbandoned {
t.Errorf("status = %q, want abandoned", got.Status)
}
persisted, _ := getExpedition(exp.ID)
if persisted.Status != ExpeditionStatusAbandoned {
t.Errorf("persisted status = %q", persisted.Status)
}
if persisted.CompletedAt == nil {
t.Error("expected completed_at after forced extract")
}
}
// E5c: resume restores 'active' status, fresh supplies, preserves threat.
func TestResume_FreshSuppliesPreservesThreat(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-resume-ok:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := applyThreatDelta(exp.ID, 35, "test"); err != nil {
t.Fatal(err)
}
if err := updateTemporalStack(exp.ID, 12); err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(uid); err != nil {
t.Fatal(err)
}
resumable, err := getResumableExpedition(uid)
if err != nil || resumable == nil {
t.Fatalf("getResumableExpedition: %v / %v", resumable, err)
}
if resumable.ID != exp.ID {
t.Error("wrong row")
}
freshSupplies := ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 1, HarshMod: 1}
if err := resumeExpedition(resumable.ID, freshSupplies); err != nil {
t.Fatalf("resumeExpedition: %v", err)
}
got, _ := getExpedition(exp.ID)
if got.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active", got.Status)
}
if got.Supplies.Current != 20 {
t.Errorf("supplies.Current = %.1f, want 20", got.Supplies.Current)
}
if got.ThreatLevel != 35 {
t.Errorf("threat = %d, want 35 (preserved)", got.ThreatLevel)
}
if got.TemporalStack != 12 {
t.Errorf("temporal = %d, want 12 (preserved)", got.TemporalStack)
}
if got.CompletedAt != nil {
t.Error("completed_at should be cleared on resume")
}
}
// E5c: resume window expires after 7 real days; getResumableExpedition still
// returns the row (caller decides), but the command path should reject.
func TestResume_WindowExpired(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-resume-expired:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(uid); err != nil {
t.Fatal(err)
}
// Backdate completed_at well past the 7-day window.
stale := time.Now().UTC().Add(-8 * 24 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
stale, exp.ID); err != nil {
t.Fatal(err)
}
got, _ := getResumableExpedition(uid)
if got == nil || got.CompletedAt == nil {
t.Fatal("expected resumable row with completed_at set")
}
if time.Since(*got.CompletedAt) <= extractResumeWindow {
t.Errorf("expected window to be expired (since=%v, window=%v)",
time.Since(*got.CompletedAt), extractResumeWindow)
}
}