mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Adv 2.0 D&D Phase 12 E1e: !camp rough/standard + placement guards
Wires the basic camp surface per gogobee_expedition_system.md §5. Two camp types ship: rough (any location, +0.5 SU, partial rest) and standard (cleared room, +1 SU, full long rest). Fortified and base camps remain explicitly rejected at the surface — those wire up in E2 and E4 respectively, so the command tree is forward-compatible. Placement validation per §5.2: boss rooms and trap rooms reject; non- cleared rooms downgrade an intended standard camp to rough. Active- enemy detection is deferred to E2 (it needs the combat-state hooks that don't exist on the expedition path yet). Until !advance is wired into the expedition layer, the player is treated as standing at the entry room (always cleared), so today's validation effectively only fires once room context catches up. Camp-pitch consumes SU immediately and uses flavor.CampEstablished for the narrative line. !camp break struck and recorded. !camp with no arg shows help + current camp state. Tests cover deduction amounts, double-pitch rejection, break, locked- type rejection (fortified/base), insufficient-supply rejection, and boss-room placement guard via a synthetic zone run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -255,6 +255,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "expedition") {
|
||||
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "camp") {
|
||||
return p.handleCampCmd(ctx, p.GetArgs(ctx.Body, "camp"))
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
|
||||
224
internal/plugin/dnd_expedition_camp.go
Normal file
224
internal/plugin/dnd_expedition_camp.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
)
|
||||
|
||||
// Phase 12 E1e — basic camp system (rough + standard).
|
||||
// Spec: gogobee_expedition_system.md §5. Fortified and Base camps are
|
||||
// deliverables of E2 (Threat Clock & Night Phase) and E4 (Multi-Region)
|
||||
// respectively — !camp explicitly rejects those types here so the
|
||||
// surface is forward-compatible.
|
||||
|
||||
const (
|
||||
CampTypeRough = "rough"
|
||||
CampTypeStandard = "standard"
|
||||
CampTypeFortified = "fortified"
|
||||
CampTypeBase = "base"
|
||||
)
|
||||
|
||||
// campSupplyCost — extra SU consumed at pitch time (§5.1).
|
||||
var campSupplyCost = map[string]float32{
|
||||
CampTypeRough: 0.5,
|
||||
CampTypeStandard: 1.0,
|
||||
CampTypeFortified: 2.0,
|
||||
CampTypeBase: 3.0,
|
||||
}
|
||||
|
||||
// !camp <type> — establish a camp on the current expedition.
|
||||
//
|
||||
// Validation (§5.2):
|
||||
// - cannot camp in an active-enemy room
|
||||
// - cannot camp in a trap room
|
||||
// - cannot camp in a boss room
|
||||
// - non-cleared room ⇒ forced rough camp regardless of intent
|
||||
//
|
||||
// Until !advance is wired into the expedition layer (a later phase),
|
||||
// the player is treated as standing at the entry room (cleared by
|
||||
// definition), so all rooms read as cleared and camp validation only
|
||||
// rejects the never-applicable boss/trap/active cases.
|
||||
|
||||
func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
c, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
||||
}
|
||||
if c == nil || c.PendingSetup {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"No Adv 2.0 character yet — run `!setup` first.")
|
||||
}
|
||||
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"No active expedition. `!camp` only works during an expedition. Use `!expedition start` first.")
|
||||
}
|
||||
|
||||
args = strings.TrimSpace(strings.ToLower(args))
|
||||
requested, rest := splitFirstWord(args)
|
||||
if requested == "" {
|
||||
return p.SendDM(ctx.Sender, campHelpText(exp))
|
||||
}
|
||||
if requested == "break" || requested == "down" || requested == "leave" {
|
||||
return p.campBreak(ctx, exp)
|
||||
}
|
||||
switch requested {
|
||||
case "rough", "standard":
|
||||
// allowed in E1e
|
||||
case "fortified":
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Fortified camps require a boss-cleared room or cache site. (Wires up in a later phase — for now, `rough` or `standard`.)")
|
||||
case "base":
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Base camps unlock in Tier 4–5 zones from Day 3+. (Wires up in a later phase.)")
|
||||
default:
|
||||
return p.SendDM(ctx.Sender,
|
||||
"Unknown camp type. Try `rough` (any location) or `standard` (cleared rooms).")
|
||||
}
|
||||
_ = rest
|
||||
|
||||
return p.campPitch(ctx, exp, requested)
|
||||
}
|
||||
|
||||
func campHelpText(exp *Expedition) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n")
|
||||
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
|
||||
b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
|
||||
b.WriteString("`!camp break` — break camp\n\n")
|
||||
if exp.Camp != nil && exp.Camp.Active {
|
||||
b.WriteString(fmt.Sprintf("_Currently camped: **%s** (room %d)._",
|
||||
exp.Camp.Type, exp.Camp.RoomIndex+1))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind string) error {
|
||||
if exp.Camp != nil && exp.Camp.Active {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're already camped (**%s**). `!camp break` first.", exp.Camp.Type))
|
||||
}
|
||||
|
||||
// Room-context validation. Once !advance is wired into expeditions
|
||||
// this resolves to the actual current room from the linked DungeonRun.
|
||||
// For now, the entry room (always cleared) is the assumed location.
|
||||
cleared, problem := campLocationCheck(exp)
|
||||
if problem != "" {
|
||||
return p.SendDM(ctx.Sender, problem)
|
||||
}
|
||||
if !cleared && kind == CampTypeStandard {
|
||||
// §5.2: non-cleared room forces rough.
|
||||
kind = CampTypeRough
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||
"intended standard camp; downgraded to rough (room not cleared)", "")
|
||||
}
|
||||
|
||||
cost, ok := campSupplyCost[kind]
|
||||
if !ok {
|
||||
cost = 0.5
|
||||
}
|
||||
if exp.Supplies.Current < cost {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Not enough supplies for that camp (need **%.1f SU**, have **%.1f SU**). Try `!camp rough` or break camp and conserve.",
|
||||
cost, exp.Supplies.Current))
|
||||
}
|
||||
|
||||
exp.Supplies.Current -= cost
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
|
||||
}
|
||||
camp := &CampState{
|
||||
Active: true,
|
||||
Type: kind,
|
||||
RoomIndex: campCurrentRoomIndex(exp),
|
||||
EstablishedAt: time.Now().UTC(),
|
||||
NightEvents: []string{},
|
||||
}
|
||||
if err := updateCamp(exp.ID, camp); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error())
|
||||
}
|
||||
|
||||
line := flavor.Pick(flavor.CampEstablished)
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest",
|
||||
fmt.Sprintf("camp pitched (%s) — %.1f SU consumed", kind, cost), line)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("⛺ **Camp established — %s.**\n", kind))
|
||||
b.WriteString(fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n",
|
||||
exp.Supplies.Current, exp.Supplies.Max, cost))
|
||||
if line != "" {
|
||||
b.WriteString("\n" + line + "\n")
|
||||
}
|
||||
if kind == CampTypeRough {
|
||||
b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._")
|
||||
} else {
|
||||
b.WriteString("\n_Standard camp — full long rest at the next morning briefing._")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
|
||||
if exp.Camp == nil || !exp.Camp.Active {
|
||||
return p.SendDM(ctx.Sender, "No camp to break.")
|
||||
}
|
||||
if err := updateCamp(exp.ID, nil); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't break camp: "+err.Error())
|
||||
}
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp broken", "")
|
||||
return p.SendDM(ctx.Sender, "Camp struck. The dungeon awaits.")
|
||||
}
|
||||
|
||||
// campLocationCheck enforces §5.2 placement rules. Returns (clearedRoom, problem).
|
||||
// Until !advance wires into expeditions, the player is at the entry room
|
||||
// (always cleared, never trap/boss/active-enemy) so this currently always
|
||||
// returns (true, "").
|
||||
func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
|
||||
if exp.RunID == "" {
|
||||
// No room state — treat as the entry room. Cleared by definition.
|
||||
return true, ""
|
||||
}
|
||||
run, err := getZoneRun(exp.RunID)
|
||||
if err != nil || run == nil {
|
||||
// Run was wiped/abandoned out from under us; behave like entry.
|
||||
return true, ""
|
||||
}
|
||||
rt := run.CurrentRoomType()
|
||||
switch rt {
|
||||
case RoomBoss:
|
||||
return false, "You can't camp in a boss room. The room knows it's a boss room."
|
||||
case RoomTrap:
|
||||
return false, "You can't camp in a trap room — even a disarmed one."
|
||||
}
|
||||
// Active-enemy detection requires combat-state lookup; defer to E2.
|
||||
cleared = false
|
||||
for _, idx := range run.RoomsCleared {
|
||||
if idx == run.CurrentRoom {
|
||||
cleared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return cleared, ""
|
||||
}
|
||||
|
||||
// campCurrentRoomIndex returns 0 (entry) when no room context exists.
|
||||
func campCurrentRoomIndex(exp *Expedition) int {
|
||||
if exp.RunID == "" {
|
||||
return 0
|
||||
}
|
||||
run, err := getZoneRun(exp.RunID)
|
||||
if err != nil || run == nil {
|
||||
return 0
|
||||
}
|
||||
return run.CurrentRoom
|
||||
}
|
||||
212
internal/plugin/dnd_expedition_camp_test.go
Normal file
212
internal/plugin/dnd_expedition_camp_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func campTestCharacter(t *testing.T, uid id.UserID, level int) {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "campcmd"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 20, HPCurrent: 20, ArmorClass: 14,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_NoExpeditionBlocked(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-noexp:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp != nil {
|
||||
t.Error("no expedition should exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_RoughDeducts0_5SU(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-rough:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp.Camp == nil || !exp.Camp.Active {
|
||||
t.Fatal("expected camp pitched")
|
||||
}
|
||||
if exp.Camp.Type != CampTypeRough {
|
||||
t.Errorf("camp type = %q, want rough", exp.Camp.Type)
|
||||
}
|
||||
if exp.Supplies.Current != 4.5 {
|
||||
t.Errorf("supplies = %v, want 4.5 after 0.5 SU rough cost", exp.Supplies.Current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_StandardDeducts1SU(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-standard:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "standard"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp.Camp == nil || exp.Camp.Type != CampTypeStandard {
|
||||
t.Errorf("camp = %+v, want standard", exp.Camp)
|
||||
}
|
||||
if exp.Supplies.Current != 4.0 {
|
||||
t.Errorf("supplies = %v, want 4.0 after 1.0 SU standard cost", exp.Supplies.Current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_RejectsDoublePitch(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-double:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "standard"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp.Camp == nil || exp.Camp.Type != CampTypeRough {
|
||||
t.Errorf("camp should still be rough after rejected second pitch, got %+v", exp.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_BreakClearsCamp(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-break:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "break"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp.Camp != nil {
|
||||
t.Errorf("camp should be cleared, got %+v", exp.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_RejectsFortifiedAndBase(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-locked:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "fortified"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp.Camp != nil {
|
||||
t.Errorf("camp should not be pitched for locked types, got %+v", exp.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampCmd_InsufficientSuppliesRejected(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-broke:example")
|
||||
campTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 0.3, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp.Camp != nil {
|
||||
t.Errorf("camp should not be pitched with 0.3 SU vs 0.5 cost, got %+v", exp.Camp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampLocationCheck_BossRoomBlocks(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@camp-boss:example")
|
||||
defer cleanupZoneRuns(uid)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
// Pre-create a zone run, then advance current_room to the boss room.
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Walk to the boss room directly (last index).
|
||||
bossIdx := -1
|
||||
for i, rt := range run.RoomSeq {
|
||||
if rt == RoomBoss {
|
||||
bossIdx = i
|
||||
}
|
||||
}
|
||||
if bossIdx < 0 {
|
||||
t.Fatal("no boss room in seq")
|
||||
}
|
||||
exp := &Expedition{RunID: run.RunID}
|
||||
// Force current_room to bossIdx.
|
||||
if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_room = ? WHERE run_id = ?`, bossIdx, run.RunID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleared, problem := campLocationCheck(exp)
|
||||
if problem == "" {
|
||||
t.Error("expected boss-room rejection")
|
||||
}
|
||||
if cleared {
|
||||
t.Error("boss room should not read as cleared")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user