diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 3ab2efa..9e04409 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -21,6 +21,8 @@ import ( // !zone map → ASCII layout of the run with current marker // !zone advance → resolve the current room and move on // !zone abandon → end the active run (no rewards) +// !zone taunt → poke TwinBee (mood −10, snarky reply) +// !zone compliment → flatter TwinBee (mood +5, fond reply) // // TwinBee narration / mood triggers arrive in D1d. Combat / trap / boss // resolution per room is wired in D1e via dnd_zone_combat.go. @@ -54,6 +56,10 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro return p.zoneCmdAdvance(ctx) case "abandon", "leave", "quit": return p.zoneCmdAbandon(ctx) + case "taunt": + return p.zoneCmdTaunt(ctx) + case "compliment": + return p.zoneCmdCompliment(ctx) default: return p.SendDM(ctx.Sender, zoneHelpText()) } @@ -67,7 +73,9 @@ func zoneHelpText() string { b.WriteString("`!zone status` — show your current run\n") b.WriteString("`!zone map` — show the room layout\n") b.WriteString("`!zone advance` — resolve the current room and move on\n") - b.WriteString("`!zone abandon` — end the active run (no rewards)") + b.WriteString("`!zone abandon` — end the active run (no rewards)\n") + b.WriteString("`!zone taunt` — poke TwinBee (mood −10)\n") + b.WriteString("`!zone compliment` — flatter TwinBee (mood +5)") return b.String() } @@ -472,6 +480,53 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon return b.String(), false, nil } +// ── taunt / compliment ────────────────────────────────────────────────────── + +func (p *AdventurePlugin) zoneCmdTaunt(ctx MessageContext) error { + return p.zoneMoodInteraction(ctx, MoodEventTaunt, tauntResponseLine, "💢") +} + +func (p *AdventurePlugin) zoneCmdCompliment(ctx MessageContext) error { + return p.zoneMoodInteraction(ctx, MoodEventCompliment, complimentResponseLine, "💐") +} + +// zoneMoodInteraction is the shared wiring for !zone taunt / !zone compliment: +// require an active run, apply the mood event, render a deterministic line +// from the prewritten pool, and report the new mood band. +func (p *AdventurePlugin) zoneMoodInteraction( + ctx MessageContext, + ev GMMoodEvent, + render func(runID string, roomIdx int) string, + icon string, +) error { + run, err := getActiveZoneRun(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) + } + if run == nil { + return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") + } + _ = applyMoodDecayIfStale(run) + newMood, err := applyMoodEvent(run.RunID, ev) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error()) + } + var b strings.Builder + if line := render(run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + b.WriteString("\n\n") + } + delta := MoodEventDelta(ev) + sign := "+" + if delta < 0 { + sign = "−" + delta = -delta + } + b.WriteString(fmt.Sprintf("%s GM mood: %d/100 (%s) _(%s%d)_", + icon, newMood, gmMoodLabel(newMood), sign, delta)) + return p.SendDM(ctx.Sender, b.String()) +} + // ── abandon ───────────────────────────────────────────────────────────────── func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error { diff --git a/internal/plugin/dnd_zone_cmd_test.go b/internal/plugin/dnd_zone_cmd_test.go index c997186..fca0b5b 100644 --- a/internal/plugin/dnd_zone_cmd_test.go +++ b/internal/plugin/dnd_zone_cmd_test.go @@ -244,3 +244,93 @@ func TestGMMoodLabel_Bands(t *testing.T) { } } } + +// Phase 11 D7 — !zone taunt / !zone compliment. + +func TestZoneCmd_TauntAppliesMoodPenalty(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@zone-cmd-taunt:example") + zoneCmdTestCharacter(t, uid, 1) + defer cleanupZoneRuns(uid) + p := &AdventurePlugin{} + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil { + t.Fatalf("enter: %v", err) + } + before, err := getActiveZoneRun(uid) + if err != nil || before == nil { + t.Fatalf("active run after enter: %v", err) + } + startMood := before.GMMood + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "taunt"); err != nil { + t.Fatalf("taunt: %v", err) + } + after, err := getActiveZoneRun(uid) + if err != nil || after == nil { + t.Fatalf("active run after taunt: %v", err) + } + want := clampMood(startMood + MoodEventDelta(MoodEventTaunt)) + if after.GMMood != want { + t.Errorf("mood after taunt = %d, want %d (start %d, delta %d)", + after.GMMood, want, startMood, MoodEventDelta(MoodEventTaunt)) + } +} + +func TestZoneCmd_ComplimentAppliesMoodBonus(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@zone-cmd-compliment:example") + zoneCmdTestCharacter(t, uid, 1) + defer cleanupZoneRuns(uid) + p := &AdventurePlugin{} + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil { + t.Fatalf("enter: %v", err) + } + before, err := getActiveZoneRun(uid) + if err != nil || before == nil { + t.Fatalf("active run after enter: %v", err) + } + startMood := before.GMMood + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "compliment"); err != nil { + t.Fatalf("compliment: %v", err) + } + after, err := getActiveZoneRun(uid) + if err != nil || after == nil { + t.Fatalf("active run after compliment: %v", err) + } + want := clampMood(startMood + MoodEventDelta(MoodEventCompliment)) + if after.GMMood != want { + t.Errorf("mood after compliment = %d, want %d (start %d, delta %d)", + after.GMMood, want, startMood, MoodEventDelta(MoodEventCompliment)) + } +} + +func TestZoneCmd_TauntWithoutRunNoCrash(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@zone-cmd-taunt-norun:example") + zoneCmdTestCharacter(t, uid, 1) + defer cleanupZoneRuns(uid) + p := &AdventurePlugin{} + // No active run — should return cleanly with the no-run nudge. + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "taunt"); err != nil { + t.Fatalf("taunt no-run: %v", err) + } + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "compliment"); err != nil { + t.Fatalf("compliment no-run: %v", err) + } +} + +func TestTauntComplimentResponseLines_Deterministic(t *testing.T) { + const runID = "run-d7-test" + t1 := tauntResponseLine(runID, 0) + t2 := tauntResponseLine(runID, 0) + if t1 == "" || t1 != t2 { + t.Errorf("taunt lines not deterministic: %q vs %q", t1, t2) + } + c1 := complimentResponseLine(runID, 0) + c2 := complimentResponseLine(runID, 0) + if c1 == "" || c1 != c2 { + t.Errorf("compliment lines not deterministic: %q vs %q", c1, c2) + } + if t1 == c1 { + t.Errorf("taunt and compliment shouldn't collide on same key: %q", t1) + } +} diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 8435301..3b59bba 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -360,6 +360,27 @@ func twinBeeLine(zoneID ZoneID, kind GMNarrationType, runID string, roomIdx int) return "🎭 **TwinBee:** " + line } +// tauntResponseLine renders a deterministic TwinBee reply to !zone taunt +// from the prewritten flavor.TauntResponses pool. roomIdx is folded so +// repeated taunts in the same room are stable but cross-room taunts vary. +func tauntResponseLine(runID string, roomIdx int) string { + line := pickLineDeterministic(flavor.TauntResponses, runID, roomIdx^0x4A8D9F25) + if line == "" { + return "" + } + return "🎭 **TwinBee:** " + line +} + +// complimentResponseLine renders a deterministic TwinBee reply to +// !zone compliment from the prewritten flavor.ComplimentResponses pool. +func complimentResponseLine(runID string, roomIdx int) string { + line := pickLineDeterministic(flavor.ComplimentResponses, runID, roomIdx^0x3E172B6C) + if line == "" { + return "" + } + return "🎭 **TwinBee:** " + line +} + // ── Mood event table & application ─────────────────────────────────────────── // GMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.