From 3103b519fb582b23d8d3854ea79aed1be5c26322 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:12:53 -0700 Subject: [PATCH] =?UTF-8?q?N5/D2:=20NPC=20arcs=20=E2=80=94=20Misty=20stage?= =?UTF-8?q?s,=20Robbie's=20gift,=20Thom's=20final=20letter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/adventure_flavor_npcs.go | 25 +++++ internal/plugin/adventure_flavor_robbie.go | 5 + internal/plugin/adventure_housing.go | 29 +++++- internal/plugin/adventure_npc_arcs_test.go | 91 +++++++++++++++++++ internal/plugin/adventure_npcs.go | 6 ++ internal/plugin/adventure_robbie.go | 41 ++++++++- .../plugin/thom_krooke_mortgage_flavor.go | 17 ++++ 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 internal/plugin/adventure_npc_arcs_test.go diff --git a/internal/plugin/adventure_flavor_npcs.go b/internal/plugin/adventure_flavor_npcs.go index c937189..ca3ac94 100644 --- a/internal/plugin/adventure_flavor_npcs.go +++ b/internal/plugin/adventure_flavor_npcs.go @@ -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{ diff --git a/internal/plugin/adventure_flavor_robbie.go b/internal/plugin/adventure_flavor_robbie.go index ae08896..c9e4ec2 100644 --- a/internal/plugin/adventure_flavor_robbie.go +++ b/internal/plugin/adventure_flavor_robbie.go @@ -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. " + diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go index 4d95177..385c6b2 100644 --- a/internal/plugin/adventure_housing.go +++ b/internal/plugin/adventure_housing.go @@ -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 diff --git a/internal/plugin/adventure_npc_arcs_test.go b/internal/plugin/adventure_npc_arcs_test.go new file mode 100644 index 0000000..28e30e4 --- /dev/null +++ b/internal/plugin/adventure_npc_arcs_test.go @@ -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") + } +} diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 49a285e..c3c48d5 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -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": diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index 4287425..e1ad486 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -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) diff --git a/internal/plugin/thom_krooke_mortgage_flavor.go b/internal/plugin/thom_krooke_mortgage_flavor.go index 568876c..5df58ea 100644 --- a/internal/plugin/thom_krooke_mortgage_flavor.go +++ b/internal/plugin/thom_krooke_mortgage_flavor.go @@ -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.