Files
gogobee/internal/plugin/dnd_expedition_region_cmd_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

127 lines
3.8 KiB
Go
Raw Permalink 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 (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
func TestResolveTransitWanderingCheck_NoCampMod(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@transit-noncamp:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
// Force d20 = 14 with no threat mod and no class mod → outcome
// should be Minor (total=14 → 11..14 bucket).
nc := resolveTransitWanderingCheck(exp, "", func() int { return 14 })
if nc.CampMod != 0 {
t.Errorf("transit campMod = %d, want 0", nc.CampMod)
}
if nc.Outcome != NightOutcomeMinor {
t.Errorf("outcome = %v, want Minor", nc.Outcome)
}
// Ranger in wilderness zone gets -2.
nc = resolveTransitWanderingCheck(exp, ClassRanger, func() int { return 14 })
if nc.ClassMod != -2 {
t.Errorf("ranger classMod = %d, want -2", nc.ClassMod)
}
}
func TestRegionTravel_AdvancesDayBurnsSuppliesAndUpdatesRegion(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@region-travel:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "",
ExpeditionSupplies{Max: 20, Current: 20, DailyBurn: 2, HarshMod: 1.5})
if err != nil {
t.Fatal(err)
}
startDay := exp.CurrentDay
startSupplies := exp.Supplies.Current
// Drive the travel manually (avoid command surface for unit isolation).
if err := setCurrentRegion(exp, "underdark_surface_tunnels"); err != nil {
t.Fatal(err)
}
cur, _ := CurrentRegion(exp)
next, _ := nextRegion(exp.ZoneID, cur.ID)
if next.ID != "underdark_drow_outpost" {
t.Fatalf("next = %s", next.ID)
}
// Mimic regionCmdTravel core: burn one day, advance, mark visited.
newSupplies, burned := applyDailyBurn(exp.Supplies, false, false)
// Phase 5-B: applyDailyBurn × phase5B 50%; 2 base × 0.5 = 1.
if burned != 1 {
t.Errorf("burned = %v, want 1", burned)
}
exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
t.Fatal(err)
}
if err := advanceExpeditionDay(exp.ID); err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, next.ID); err != nil {
t.Fatal(err)
}
if _, err := MarkRegionVisited(exp, next.ID); err != nil {
t.Fatal(err)
}
loaded, _ := getActiveExpedition(uid)
if loaded.CurrentDay != startDay+1 {
t.Errorf("day did not advance: %d → %d", startDay, loaded.CurrentDay)
}
if loaded.Supplies.Current != startSupplies-1 {
t.Errorf("supplies after burn = %v, want %v", loaded.Supplies.Current, startSupplies-1)
}
if loaded.CurrentRegion != "underdark_drow_outpost" {
t.Errorf("CurrentRegion = %q", loaded.CurrentRegion)
}
if !IsRegionVisited(loaded, "underdark_drow_outpost") {
t.Error("next region not marked visited")
}
}
func TestRenderRegionList_MarksCurrentAndCleared(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@region-render:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneAbyssPortal, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if _, err := MarkRegionBossDefeated(exp, "abyss_outer_rift"); err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, "abyss_demon_assembly"); err != nil {
t.Fatal(err)
}
if _, err := MarkRegionVisited(exp, "abyss_demon_assembly"); err != nil {
t.Fatal(err)
}
out := renderRegionList(exp)
if !strings.Contains(out, "✓ ") {
t.Errorf("expected ✓ marker for cleared region; got:\n%s", out)
}
if !strings.Contains(out, "▶ ") {
t.Errorf("expected ▶ marker for current region; got:\n%s", out)
}
if !strings.Contains(out, "Demon Assembly") || !strings.Contains(out, "Outer Rift") {
t.Errorf("region names missing in render:\n%s", out)
}
if !strings.Contains(out, "★") {
t.Errorf("expected ★ marker on zone-boss region:\n%s", out)
}
}