mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N5/D2: NPC arcs — Misty stages, Robbie's gift, Thom's final letter
Three flavor arcs on existing per-player counters (no schema, golden byte-identical): - Misty: 3 deepening dialogue beats at MistyEncounterCount 5/15/30, prepended to the encounter opening the one time the counter lands on a threshold. Fiction only — no copy ties a donation to the hidden arena effects. - Robbie: every 10th visit he leaves a consumable "for the trouble," drawn from the dungeon pool at a level-matched tier. - Thom: paying off the Tier-4 mortgage (no next tier) sends a final letter instead of the stock payoff line, plus an inert pet-treat keepsake if the player keeps a pet. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -24,6 +24,31 @@ var mistyAcceptLines = []string{
|
||||
|
||||
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
|
||||
|
||||
// ── Misty — Arc Beats (D2) ─────────────────────────────────────────────────
|
||||
//
|
||||
// Three deepening dialogue beats keyed to MistyEncounterCount (5 / 15 / 30),
|
||||
// prepended to the encounter opening the one time the counter lands on a
|
||||
// threshold. Fiction only: nothing here connects a donation to a combat
|
||||
// outcome — the arena effects are a hidden discovery mechanic and stay hidden.
|
||||
var mistyArcBeats = map[int]string{
|
||||
5: "It's you again. I was hoping it would be. I know your face now -- you're one of the few who ever stops. " +
|
||||
"Most people look right through me. You never have.",
|
||||
|
||||
15: "Can I tell you something? I wasn't always out here asking strangers on the road. " +
|
||||
"I had a home once. A garden that got away from me every summer. Someone who used to wait up. " +
|
||||
"It's gone now, all of it. But you keep reminding me there are still kind people in the world. That's not nothing.",
|
||||
|
||||
30: "I don't think of you as a stranger anymore. Is it strange to say that? " +
|
||||
"I catch myself watching the road for you now -- not for the gold, though I won't pretend it doesn't help. " +
|
||||
"Just to see you're still standing. Whatever's out there, I hope it knows better than to take you from me too.",
|
||||
}
|
||||
|
||||
// mistyArcBeat returns the deepening dialogue for the exact encounter count at
|
||||
// which a Misty stage lands, or "" for every other encounter.
|
||||
func mistyArcBeat(encounterCount int) string {
|
||||
return mistyArcBeats[encounterCount]
|
||||
}
|
||||
|
||||
// ── Arina — Opening Lines ──────────────────────────────────────────────────
|
||||
|
||||
var arinaOpenings = []string{
|
||||
|
||||
@@ -40,6 +40,11 @@ var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excel
|
||||
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
|
||||
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
|
||||
|
||||
// robbieLeftConsumable is appended when Robbie leaves a little something on his
|
||||
// every-10th-visit (D2). Takes the item name.
|
||||
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
||||
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||
|
||||
@@ -146,6 +146,18 @@ var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house
|
||||
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
|
||||
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
|
||||
|
||||
// thomPetTreatItem is the keepsake Thom leaves for the pet on the final
|
||||
// mortgage payoff. Inert (Type "card" like the medical-debt card): not a
|
||||
// consumable, not gear, and Robbie's sweep skips it.
|
||||
func thomPetTreatItem() AdvItem {
|
||||
return AdvItem{
|
||||
Name: "Krooke's Finest Pet Treat",
|
||||
Type: "card",
|
||||
Tier: 0,
|
||||
Value: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// thomGreeting returns an appropriate greeting based on pet state.
|
||||
func thomGreeting(char *AdventureCharacter) string {
|
||||
if char.HasPet() && char.PetLevel >= 10 {
|
||||
@@ -553,11 +565,22 @@ func (p *AdventurePlugin) processMortgagePayments() {
|
||||
freshChar.HouseLoanBalance = 0
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
// The last house: no next tier means Thom is done with this
|
||||
// player. A letter, and a treat in the bag if they keep a pet.
|
||||
finalPayoff := houseNextTier(freshChar.HouseTier) == nil
|
||||
if finalPayoff && freshChar.HasPet() {
|
||||
_ = addAdvInventoryItem(freshChar.UserID, thomPetTreatItem())
|
||||
}
|
||||
userMu.Unlock()
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
switch {
|
||||
case finalPayoff && freshChar.HasPet():
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", freshChar.PetName)
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+letter)
|
||||
case finalPayoff:
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+thomFinalLetterNoPet)
|
||||
case freshChar.HasPet():
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
} else {
|
||||
default:
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
|
||||
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D2 NPC arcs — pure-helper coverage. None of these touch combat, so the
|
||||
// characterization golden is unaffected; these just pin the arc thresholds,
|
||||
// gift tiers, and the keepsake shape.
|
||||
|
||||
func TestMistyArcBeat_FiresOnlyAtThresholds(t *testing.T) {
|
||||
want := map[int]bool{5: true, 15: true, 30: true}
|
||||
for count := 0; count <= 40; count++ {
|
||||
got := mistyArcBeat(count)
|
||||
if want[count] {
|
||||
if got == "" {
|
||||
t.Errorf("encounter %d: expected an arc beat, got none", count)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Errorf("encounter %d: expected no arc beat, got %q", count, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyArcBeats_DoNotLeakBuffMechanics(t *testing.T) {
|
||||
// The arena effects are a hidden discovery mechanic; arc copy must never
|
||||
// tie a donation to a combat outcome.
|
||||
banned := []string{"arena", "damage", "buff", "combat", "attack", "heal", "bonus"}
|
||||
for count, beat := range mistyArcBeats {
|
||||
lower := strings.ToLower(beat)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(lower, b) {
|
||||
t.Errorf("beat %d leaks mechanic word %q: %s", count, b, beat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftTier_MatchesArenaBands(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
tier int
|
||||
}{
|
||||
{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3},
|
||||
{13, 4}, {17, 4}, {18, 5}, {20, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := robbieGiftTier(c.level); got != c.tier {
|
||||
t.Errorf("robbieGiftTier(%d) = %d, want %d", c.level, got, c.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftCadence(t *testing.T) {
|
||||
// The gift lands on every 10th visit and no other.
|
||||
for visit := 1; visit <= 35; visit++ {
|
||||
gives := visit%robbieGiftEveryNVisits == 0
|
||||
if gives != (visit == 10 || visit == 20 || visit == 30) {
|
||||
t.Errorf("visit %d: unexpected gift cadence", visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomPetTreat_IsRobbieSafeKeepsake(t *testing.T) {
|
||||
treat := thomPetTreatItem()
|
||||
if treat.Name == "" {
|
||||
t.Fatal("treat has no name")
|
||||
}
|
||||
// Robbie's sweep skips "card" items, so the keepsake survives him.
|
||||
if treat.Type != "card" {
|
||||
t.Errorf("treat type = %q, want card so Robbie's sweep skips it", treat.Type)
|
||||
}
|
||||
if treat.Value != 0 {
|
||||
t.Errorf("treat value = %d, want 0 (inert keepsake)", treat.Value)
|
||||
}
|
||||
// Confirm the sweep filter actually excludes it.
|
||||
if got := robbieQualifyingItems([]AdvItem{treat}, nil); len(got) != 0 {
|
||||
t.Errorf("Robbie would take the treat: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomFinalLetterPet_SubstitutesPetName(t *testing.T) {
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", "Biscuit")
|
||||
if strings.Contains(letter, "{pet}") {
|
||||
t.Error("unsubstituted {pet} placeholder remains")
|
||||
}
|
||||
if !strings.Contains(letter, "Biscuit") {
|
||||
t.Error("pet name not substituted into the letter")
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,12 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
// Pool union: legacy mistyOpenings + D&D MistyGreeting flavor.
|
||||
mistyPool := dndMistyGreetingPool()
|
||||
opening = mistyPool[rand.IntN(len(mistyPool))]
|
||||
// D2 arc: a deepening beat lands the one time the counter hits a
|
||||
// threshold (5/15/30). MistyEncounterCount only ever increments by one
|
||||
// above, so each beat is delivered exactly once.
|
||||
if beat := mistyArcBeat(char.MistyEncounterCount); beat != "" {
|
||||
opening = beat + "\n\n" + opening
|
||||
}
|
||||
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
||||
case "arina":
|
||||
|
||||
@@ -164,16 +164,25 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count
|
||||
// Update visit count, and every 10th visit leave a small consumable
|
||||
// "for the trouble" (D2 NPC arc).
|
||||
var leftGift *AdvItem
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil {
|
||||
char.RobbieVisitCount++
|
||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
||||
if gifts := consumableCache(robbieGiftTier(char.CombatLevel), 1); len(gifts) > 0 {
|
||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||
leftGift = &gifts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
|
||||
}
|
||||
|
||||
// Send DM
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||
}
|
||||
@@ -243,7 +252,27 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
|
||||
const robbieGiftEveryNVisits = 10
|
||||
|
||||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||
func robbieGiftTier(level int) int {
|
||||
switch {
|
||||
case level >= 18:
|
||||
return 5
|
||||
case level >= 13:
|
||||
return 4
|
||||
case level >= 8:
|
||||
return 3
|
||||
case level >= 4:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
@@ -290,6 +319,12 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Every-10th-visit consumable (D2).
|
||||
if leftGift != nil {
|
||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Closing
|
||||
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
|
||||
sb.WriteString(closing)
|
||||
|
||||
@@ -120,6 +120,23 @@ var ThomRateDecreasePet = []string{
|
||||
"You can draw your own conclusions.",
|
||||
}
|
||||
|
||||
// ── FINAL PAYOFF — THE LAST HOUSE (D2 NPC ARC) ────────────────────────────────
|
||||
//
|
||||
// Sent once, when the Tier-4 "Established" mortgage hits zero — the last house
|
||||
// Thom has to sell. There is no next tier, so this is the last time Thom has
|
||||
// business with the player. A letter, and (if they keep a pet) a treat left in
|
||||
// the bag. Substitute {pet} with strings.ReplaceAll at the call site.
|
||||
|
||||
var thomFinalLetterNoPet = "Paid off. All of it. There is no next tier -- you've bought the last house I had to sell you.\n\n" +
|
||||
"I've closed a great many mortgages. Thirty-one years, Krooke Realty. Most people I stop thinking about the hour the balance hits zero, and I have never once considered that a loss. I am writing to tell you that you appear to be an exception. I have not decided how I feel about that. I am telling you anyway, which should indicate something.\n\n" +
|
||||
"The door is yours, the roof is yours, the ground it stands on is yours. Nobody sends you a bill for it ever again. Walk through it whenever you like.\n\n" +
|
||||
"-- Thom Krooke, Krooke Realty"
|
||||
|
||||
var thomFinalLetterPet = "Paid off. All of it. There is no next tier -- you own it outright now, walls and roof and the ground it sits on.\n\n" +
|
||||
"I've enclosed something for {pet}. A treat. The good kind -- not the sort they sell by the sack, I had it sent from a place I trust. Give it to {pet} tonight and don't you dare ration it, and don't 'save it for a special occasion' either. Tonight IS the occasion. {pet} has spent this entire mortgage living in a house that wasn't finished being paid for, and now it is, and {pet} should know the difference even if {pet} can't be told in words.\n\n" +
|
||||
"You did a fine thing, getting {pet} a home that is truly yours. I won't say it a second time.\n\n" +
|
||||
"-- Thom Krooke, Krooke Realty"
|
||||
|
||||
// ── NO CHANGE (NEVER SENT) ────────────────────────────────────────────────────
|
||||
// Thom does not send neutral news.
|
||||
// If rate is unchanged week over week: no DM. No notification. Nothing.
|
||||
|
||||
Reference in New Issue
Block a user