Camp: reject (not downgrade) sub-par camps; map: show visited path

Camp rest rules:
  • Standard camp now rejects an uncleared room with a clear message
    instead of silently downgrading to rough — let the player make the
    call rather than spending supplies on a worse rest they didn't pick.
  • campLocationCheck treats a room with a resolved (non-active) combat
    session as cleared, so the natural "just killed, not yet advanced"
    pause still qualifies for a standard camp.
  • Reword fortified-camp gating + babysit help to "zone boss defeated"
    (cache sites aren't a thing yet) so the requirement isn't misleading.

Map: renderVisitedPath adds a 1-indexed breadcrumb of visited rooms to
!expedition map, so players can reference rooms by index (e.g. !revisit 2).

(cherry picked from commit a38fc77eed888e9790c7a7cff24369b98910b43e)
This commit is contained in:
prosolis
2026-05-19 22:17:39 -07:00
parent 20b0d027b2
commit e4518c9c39
4 changed files with 46 additions and 15 deletions

View File

@@ -98,7 +98,7 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+ return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+ "Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+ " • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
" • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+ " • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+
" • Rival duels declined on your behalf\n\n"+ " • Rival duels declined on your behalf\n\n"+
"`!adventure babysit week` — 7 days of service\n"+ "`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+ "`!adventure babysit month` — 30 days of service\n"+

View File

@@ -79,12 +79,9 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
case "rough", "standard": case "rough", "standard":
// allowed in E1e // allowed in E1e
case "fortified": case "fortified":
// E2d: §5.1 — boss-cleared room or cache site. Cache sites
// are zone-specific waypoints (E3+), so for now we require the
// expedition's boss to have been defeated.
if !exp.BossDefeated { if !exp.BossDefeated {
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
"Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.") "Fortified camps require a defeated zone boss. Clear the zone first.")
} }
case "base": case "base":
// E4d: §11.1 — base camps unlock per region after the region // E4d: §11.1 — base camps unlock per region after the region
@@ -122,7 +119,7 @@ func campHelpText(exp *Expedition) string {
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n") 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 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 standard` — full long rest (cleared room, +1 SU, medium risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (boss-cleared room, +2 SU, low risk)\n") b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n")
b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n") b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
b.WriteString("`!camp break` — break camp\n\n") b.WriteString("`!camp break` — break camp\n\n")
if exp.Camp != nil && exp.Camp.Active { if exp.Camp != nil && exp.Camp.Active {
@@ -146,10 +143,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
return p.SendDM(ctx.Sender, problem) return p.SendDM(ctx.Sender, problem)
} }
if !cleared && kind == CampTypeStandard { if !cleared && kind == CampTypeStandard {
// §5.2: non-cleared room forces rough. // §5.2: standard camp requires a cleared room. Reject explicitly
kind = CampTypeRough // rather than silently downgrading — the player should make the call.
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", return p.SendDM(ctx.Sender,
"intended standard camp; downgraded to rough (room not cleared)", "") "Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.")
} }
cost, ok := campSupplyCost[kind] cost, ok := campSupplyCost[kind]
@@ -359,15 +356,24 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
case RoomTrap: case RoomTrap:
return false, "You can't camp in a trap room — even a disarmed one." 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 { for _, idx := range run.RoomsCleared {
if idx == run.CurrentRoom { if idx == run.CurrentRoom {
cleared = true return true, ""
break
} }
} }
return cleared, "" // Not yet advanced-past, but if there's no live combat encounter in
// this room, it's still safe to rest in. Forward-only navigation means
// players naturally pause right after a kill before advancing — the
// "cleared" flag would otherwise force-downgrade their camp to rough.
encID := encounterIDForRoom(run.CurrentRoom)
sess, err := getCombatSessionForEncounter(run.RunID, encID)
if err != nil {
return false, ""
}
if sess != nil && sess.Status != CombatStatusActive {
return true, ""
}
return false, ""
} }
// campCurrentRoomIndex returns 0 (entry) when no room context exists. // campCurrentRoomIndex returns 0 (entry) when no room context exists.

View File

@@ -119,6 +119,9 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
b.WriteString(renderZoneGraphMap(g, run)) b.WriteString(renderZoneGraphMap(g, run))
b.WriteString("\n```\n") b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_")
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
if chain != "" { if chain != "" {
b.WriteString("\n_Regions: ") b.WriteString("\n_Regions: ")
b.WriteString(renderRegionLegend(exp)) b.WriteString(renderRegionLegend(exp))

View File

@@ -172,6 +172,28 @@ func nodeGlyph(n ZoneNode) string {
return "·" return "·"
} }
// renderVisitedPath renders a one-line numbered breadcrumb of the
// player's path so they can reference rooms by index (e.g. !revisit 2).
// Numbers are 1-indexed to match every other surface ("Room 2/7", etc.).
func renderVisitedPath(g ZoneGraph, run *DungeonRun) string {
if run == nil || len(run.VisitedNodes) == 0 {
return ""
}
parts := make([]string, 0, len(run.VisitedNodes))
for i, nodeID := range run.VisitedNodes {
glyph := "·"
if n, ok := g.Nodes[nodeID]; ok {
glyph = nodeGlyph(n)
}
mark := "✓"
if nodeID == run.CurrentNode {
mark = "▶"
}
parts = append(parts, fmt.Sprintf("[%d]%s%s", i+1, glyph, mark))
}
return strings.Join(parts, " → ")
}
// debugDump (test-only crutch) prints the raw column layout. Currently // debugDump (test-only crutch) prints the raw column layout. Currently
// unused outside potential debugging — kept off the unused-warning list // unused outside potential debugging — kept off the unused-warning list
// by routing fmt through it. // by routing fmt through it.