mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
§6 night-phase resolution. Fires once per recap when the player has an
active camp. resolveWanderingCheck rolls 1d20 + threat mod (+1 per full
10 above 30) + camp mod (rough +3, fortified -4, base -6) + class mod
(ranger -2 in wilderness zones), then buckets per §6.1 into Peaceful /
Passage / Minor / Standard / Elite / Ambush.
Encounter side picks a roster entry from the zone (weighted by
SpawnWeight, biased to elite for Elite/Ambush) and persists the result
to camp.NightEvents + a "night"-typed log entry. Signs-of-passage adds
+2 threat per spec. Combat resolution itself is deferred — expeditions
don't host their own combat loop yet — so encounter outcomes log as
pending and surface in the recap with an "encounter pending" hint;
the combat-link phase will read these on next !advance.
Flavor reuse: encounter narration pulls from the existing ThreatClock
{Stirring,Alert,Hostile,Siege} pools, matched to the current band so
voice tracks the dungeon's awareness level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
5.8 KiB
Go
201 lines
5.8 KiB
Go
package plugin
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Phase 12 E2b — wandering monster / night phase tests.
|
|
|
|
func TestWanderingOutcome_BucketBoundaries(t *testing.T) {
|
|
cases := []struct {
|
|
total int
|
|
want NightOutcome
|
|
}{
|
|
{1, NightOutcomePeaceful},
|
|
{5, NightOutcomePeaceful},
|
|
{6, NightOutcomePassage},
|
|
{10, NightOutcomePassage},
|
|
{11, NightOutcomeMinor},
|
|
{14, NightOutcomeMinor},
|
|
{15, NightOutcomeStandard},
|
|
{17, NightOutcomeStandard},
|
|
{18, NightOutcomeElite},
|
|
{19, NightOutcomeElite},
|
|
{20, NightOutcomeAmbush},
|
|
{25, NightOutcomeAmbush},
|
|
}
|
|
for _, c := range cases {
|
|
got := wanderingOutcome(c.total)
|
|
if got != c.want {
|
|
t.Errorf("total %d → %v, want %v", c.total, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveWanderingCheck_RoughCampBumpsRoll(t *testing.T) {
|
|
exp := &Expedition{
|
|
ZoneID: ZoneGoblinWarrens,
|
|
ThreatLevel: 0,
|
|
Camp: &CampState{Active: true, Type: CampTypeRough},
|
|
}
|
|
// roll 8 → +3 rough → total 11 → Minor (would have been Passage at 8).
|
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 8 })
|
|
if nc.Outcome != NightOutcomeMinor {
|
|
t.Errorf("rough+8 → %v, want Minor", nc.Outcome)
|
|
}
|
|
if nc.CampMod != 3 {
|
|
t.Errorf("camp mod = %d, want 3", nc.CampMod)
|
|
}
|
|
}
|
|
|
|
func TestResolveWanderingCheck_FortifiedCampReducesRoll(t *testing.T) {
|
|
exp := &Expedition{
|
|
ZoneID: ZoneGoblinWarrens,
|
|
ThreatLevel: 0,
|
|
Camp: &CampState{Active: true, Type: CampTypeFortified},
|
|
}
|
|
// roll 14 → -4 fortified → total 10 → Passage.
|
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 14 })
|
|
if nc.Outcome != NightOutcomePassage {
|
|
t.Errorf("fortified+14 → %v, want Passage", nc.Outcome)
|
|
}
|
|
}
|
|
|
|
func TestResolveWanderingCheck_ThreatModAbove30(t *testing.T) {
|
|
exp := &Expedition{
|
|
ZoneID: ZoneGoblinWarrens,
|
|
ThreatLevel: 60,
|
|
Camp: &CampState{Active: true, Type: CampTypeStandard},
|
|
}
|
|
// (60-30)/10 = 3 mod. roll 12 → +3 → total 15 → Standard.
|
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 12 })
|
|
if nc.ThreatMod != 3 {
|
|
t.Errorf("threat mod = %d, want 3", nc.ThreatMod)
|
|
}
|
|
if nc.Outcome != NightOutcomeStandard {
|
|
t.Errorf("outcome = %v, want Standard", nc.Outcome)
|
|
}
|
|
}
|
|
|
|
func TestResolveWanderingCheck_RangerWildernessDiscount(t *testing.T) {
|
|
exp := &Expedition{
|
|
ZoneID: ZoneForestShadows,
|
|
ThreatLevel: 0,
|
|
Camp: &CampState{Active: true, Type: CampTypeStandard},
|
|
}
|
|
nc := resolveWanderingCheck(exp, ClassRanger, func() int { return 12 })
|
|
if nc.ClassMod != -2 {
|
|
t.Errorf("ranger wilderness mod = %d, want -2", nc.ClassMod)
|
|
}
|
|
// In a non-wilderness zone, no class mod.
|
|
exp2 := *exp
|
|
exp2.ZoneID = ZoneGoblinWarrens
|
|
nc2 := resolveWanderingCheck(&exp2, ClassRanger, func() int { return 12 })
|
|
if nc2.ClassMod != 0 {
|
|
t.Errorf("ranger non-wilderness mod = %d, want 0", nc2.ClassMod)
|
|
}
|
|
}
|
|
|
|
func TestResolveWanderingCheck_NoCampNoEncounter(t *testing.T) {
|
|
exp := &Expedition{ZoneID: ZoneGoblinWarrens}
|
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 20 })
|
|
if nc.Outcome != NightOutcomePeaceful {
|
|
t.Errorf("no camp should yield peaceful, got %v", nc.Outcome)
|
|
}
|
|
}
|
|
|
|
func TestProcessNightCheck_LogsAndBumpsThreatOnPassage(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@exp-night-passage:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: time.Now().UTC()}
|
|
if err := updateCamp(exp.ID, exp.Camp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nc := NightCheck{
|
|
Outcome: NightOutcomePassage, Roll: 8, Total: 8,
|
|
Summary: "Signs of passage near camp; no encounter.",
|
|
ThreatBumped: true,
|
|
}
|
|
if err := processNightCheck(exp, nc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := getExpedition(exp.ID)
|
|
if got.ThreatLevel != 2 {
|
|
t.Errorf("threat after signs-of-passage = %d, want 2", got.ThreatLevel)
|
|
}
|
|
entries, _ := recentExpeditionLog(exp.ID, 5)
|
|
foundNight := false
|
|
for _, e := range entries {
|
|
if e.Type == "night" && strings.Contains(e.Summary, "Signs of passage") {
|
|
foundNight = true
|
|
}
|
|
}
|
|
if !foundNight {
|
|
t.Error("night log entry missing")
|
|
}
|
|
}
|
|
|
|
func TestProcessNightCheck_PersistsCampNightEvents(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@exp-night-campevents:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
exp, _ := startExpedition(uid, ZoneGoblinWarrens, "",
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: time.Now().UTC(), NightEvents: []string{}}
|
|
_ = updateCamp(exp.ID, exp.Camp)
|
|
|
|
nc := NightCheck{Outcome: NightOutcomePeaceful, Roll: 3, Total: 3, Summary: "Peaceful night."}
|
|
if err := processNightCheck(exp, nc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := getExpedition(exp.ID)
|
|
if got.Camp == nil || len(got.Camp.NightEvents) != 1 {
|
|
t.Errorf("camp.NightEvents not appended: %+v", got.Camp)
|
|
}
|
|
}
|
|
|
|
func TestPickWanderingMonster_RespectsEliteFlag(t *testing.T) {
|
|
// Run several picks; elite outcomes should never return non-elite when
|
|
// the zone has elite entries. Goblin Warrens should have at least one
|
|
// IsElite enemy in its roster.
|
|
zone, _ := getZone(ZoneGoblinWarrens)
|
|
hasElite := false
|
|
for _, e := range zone.Enemies {
|
|
if e.IsElite {
|
|
hasElite = true
|
|
break
|
|
}
|
|
}
|
|
if !hasElite {
|
|
t.Skip("goblin_warrens roster has no elite — pick test would be vacuous")
|
|
}
|
|
for i := 0; i < 20; i++ {
|
|
id, _ := pickWanderingMonster(ZoneGoblinWarrens, NightOutcomeElite)
|
|
if id == "" {
|
|
t.Fatalf("expected an elite pick, got empty")
|
|
}
|
|
isElite := false
|
|
for _, e := range zone.Enemies {
|
|
if e.BestiaryID == id && e.IsElite {
|
|
isElite = true
|
|
break
|
|
}
|
|
}
|
|
if !isElite {
|
|
t.Errorf("elite outcome picked non-elite %q", id)
|
|
}
|
|
}
|
|
}
|