Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,16 +615,23 @@ The `attack` plugin provides short, playful target-based actions and messages:
!compliment Alice
!high5 Alice
!gift Alice
~~~

The canonical form is `!attack <style> <nick>`. Aliases include `!bite`,
`!fight`, `!glomp`, `!insult`, `!kill`, `!lart`, `!present`, `!spank`, and
`!stab`. Action styles use standard IRC CTCP ACTION formatting, so clients
usually render them like `/me` messages; compliment, flirt, and insult use
ordinary messages. Targets must be a single IRC-style nickname. Targeting
GoBot or `self` makes GoBot perform the playful action toward the sender.
Templates are built in, capped to short safe text, and contain no real-world
instructions.
!spank Alice
!attack pokemon Alice
!strax
~~~

The canonical form is `!attack <style> [nick]`; most styles require a nickname.
Aliases include `!bite`,
`!fight`, `!glomp`, `!insult`, `!kill`, `!lart`, `!present`, `!spank`,
`!stab`, `!bdsm`, `!clinton`, `!trump`, `!lurve`, and `!pokemon`, plus
CloudBot-style aliases such as `!end`, `!sexup`, `!jackmeoff`, `!dominate`,
`!spar`, `!challenge`, `!luff`, and `!luv`. `!strax`, `!nk`, and `!westworld`
also work without a target. Action styles use standard IRC CTCP ACTION
formatting, so clients usually render them like `/me` messages; compliment,
flirt, insult, lurve, pokemon, strax, nk, and westworld use ordinary messages.
Targets must be a single IRC-style nickname. Targeting GoBot or `self` makes
GoBot perform the playful action toward the sender. Templates are built in,
capped to short safe text, and contain no real-world instructions.

## Firearm and weapons catalog

Expand Down Expand Up @@ -818,8 +825,9 @@ project--
- tell queues a message and delivers it when the addressed nickname next speaks.
- karma tracks case-insensitive thing++ and thing-- changes.
- thing++ and thing-- also work as standalone ordinary chat messages. GoBot
confirms each update in one compact line, such as
`🆙 Karma boost! thing gained 1 karma ✨ (🎯 1 in #chat | 🌐 2 global)`;
confirms each update in one compact line, replacing `<nickname>` with the
actual nickname being changed, such as
`🆙 Karma boost! KnownSyntax gained 1 karma ✨ (🎯 1 in #chat | 🌐 2 global)`;
command messages are not treated as karma changes.
- In a channel, Karma reports the channel total and the global total, for
example `🎯 9 in #chat | 🌐 19 global`; private messages report global Karma.
Expand Down
87 changes: 74 additions & 13 deletions plugins/attack.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,29 @@ const (
)

type attackDefinition struct {
name string
response attackResponse
templates []string
name string
response attackResponse
templates []string
optionalTarget bool
}

var attackDefinitions = map[string]attackDefinition{
"bite": {
name: "bite", response: attackAction,
templates: []string{"gives {target} a tiny cartoon bite.", "nibbles {target} with maximum theatricality."},
},
"bdsm": {
name: "bdsm", response: attackAction,
templates: []string{"offers {target} a clearly consensual, completely theatrical roleplay challenge.", "invites {target} to a consent-first battle of dramatic poses."},
},
"compliment": {
name: "compliment", response: attackMessage,
templates: []string{"{target}, your charisma stat is dangerously high.", "{target}, you are doing an excellent job being you."},
},
"clinton": {
name: "clinton", response: attackAction,
templates: []string{"gives {target} a famously presidential wave.", "offers {target} an overdramatic campaign-trail high five."},
},
"fight": {
name: "fight", response: attackAction,
templates: []string{"challenges {target} to a duel of rock-paper-scissors.", "squares up against {target} with a pool noodle."},
Expand Down Expand Up @@ -64,6 +73,18 @@ var attackDefinitions = map[string]attackDefinition{
name: "lart", response: attackAction,
templates: []string{"larts {target} with a giant foam mallet.", "delivers a highly theatrical lart to {target}."},
},
"lurve": {
name: "lurve", response: attackMessage,
templates: []string{"{target}, you are lurved with maximum wholesome theatricality.", "{target}, please accept this highly enthusiastic declaration of lurve."},
},
"nk": {
name: "nk", response: attackMessage, optionalTarget: true,
templates: []string{"broadcasts a completely fictional, wildly over-the-top propaganda slogan."},
},
"pokemon": {
name: "pokemon", response: attackMessage,
templates: []string{"tosses a cartoon Poké Ball near {target}; no actual catching occurs."},
},
"present": {
name: "present", response: attackAction,
templates: []string{"gives {target} a suspiciously well-wrapped present.", "presents {target} with a gift containing exactly one surprise."},
Expand All @@ -80,16 +101,33 @@ var attackDefinitions = map[string]attackDefinition{
name: "stab", response: attackAction,
templates: []string{"stabs {target} with a harmless foam sword.", "attempts a dramatic foam-sword stab at {target}."},
},
"strax": {
name: "strax", response: attackMessage, optionalTarget: true,
templates: []string{"recites a very serious report about the tactical importance of sandwiches."},
},
"trump": {
name: "trump", response: attackAction,
templates: []string{"hands {target} an extremely overconfident gold-star trophy.", "delivers {target} a spectacularly exaggerated thumbs-up."},
},
"westworld": {
name: "westworld", response: attackMessage, optionalTarget: true,
templates: []string{"recites a mysterious fictional-western android monologue about loops and dust."},
},
}

var attackCommandOrder = []string{
"attack", "bite", "compliment", "fight", "flirt", "glomp", "highfive", "hug", "insult", "kill", "lart", "present", "slap", "spank", "stab",
"attack", "bdsm", "bite", "clinton", "compliment", "fight", "flirt", "glomp", "highfive", "hug", "insult", "kill", "lart", "lurve", "nk", "pokemon", "present", "slap", "spank", "stab", "strax", "trump", "westworld",
}

var attackAliases = map[string]string{
"attack": "attack",
"bdsm": "bdsm",
"bite": "bite",
"clinton": "clinton",
"compliment": "compliment",
"challenge": "fight",
"dominate": "bdsm",
"end": "kill",
"fight": "fight",
"fite": "fight",
"flirt": "flirt",
Expand All @@ -101,11 +139,22 @@ var attackAliases = map[string]string{
"insult": "insult",
"kill": "kill",
"lart": "lart",
"luff": "lurve",
"luv": "lurve",
"lurve": "lurve",
"nk": "nk",
"pokemon": "pokemon",
"present": "present",
"gift": "present",
"sexup": "flirt",
"slap": "slap",
"spar": "fight",
"spank": "spank",
"stab": "stab",
"strax": "strax",
"trump": "trump",
"westworld": "westworld",
"jackmeoff": "flirt",
}

// Attack provides short, playful target-based actions inspired by classic IRC
Expand All @@ -118,7 +167,7 @@ func (p *Attack) Name() string { return "attack" }
func (p *Attack) Commands() []string {
commands := make([]string, len(attackCommandOrder))
copy(commands, attackCommandOrder)
commands = append(commands, "fite", "gift", "high5", "hi5")
commands = append(commands, "challenge", "dominate", "end", "fite", "gift", "high5", "hi5", "jackmeoff", "luff", "luv", "sexup", "spar")
return commands
}

Expand All @@ -143,12 +192,15 @@ func (p *Attack) Handle(b *bot.Bot, m bot.Message) bool {
b.Send(m.ReplyTarget(), attackUsage(canonical))
return true
}
if !validAttackTarget(target) {
definition := attackDefinitions[style]
if !definition.optionalTarget && !validAttackTarget(target) {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "attack targets must be a single valid nickname"))
return true
}
if target != "" && !validAttackTarget(target) {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "attack targets must be a single valid nickname"))
return true
}

definition := attackDefinitions[style]
actor := cleanExternalText(m.Nick)
if actor == "" {
actor = "someone"
Expand All @@ -174,28 +226,37 @@ func (p *Attack) Handle(b *bot.Bot, m bot.Message) bool {
func parseAttackArguments(command, arg string) (string, string, bool) {
fields := strings.Fields(arg)
if command == "attack" {
if len(fields) != 2 {
if len(fields) < 1 || len(fields) > 2 {
return "", "", false
}
style, ok := attackAliases[strings.ToLower(fields[0])]
if !ok || style == "attack" {
return "", "", false
}
if len(fields) == 1 {
if !attackDefinitions[style].optionalTarget {
return "", "", false
}
return style, "", true
}
return style, fields[1], true
}
if len(fields) != 1 {
style, ok := attackAliases[strings.ToLower(command)]
if !ok || style == "attack" {
return "", "", false
}
style, ok := attackAliases[command]
if !ok || style == "attack" {
if len(fields) == 0 && attackDefinitions[style].optionalTarget {
return style, "", true
}
if len(fields) != 1 {
return "", "", false
}
return style, fields[0], true
}

func attackUsage(command string) string {
if command == "attack" {
return ircColor(ircYellow, "usage: !attack <style> <nick> (styles: bite, compliment, fight, flirt, glomp, highfive, hug, insult, kill, lart, present, slap, spank, stab)")
return ircColor(ircYellow, "usage: !attack <style> [nick] (styles: bdsm, bite, clinton, compliment, fight, flirt, glomp, highfive, hug, insult, kill, lart, lurve, nk, pokemon, present, slap, spank, stab, strax, trump, westworld)")
}
return ircColor(ircYellow, fmt.Sprintf("usage: !%s <nick>", command))
}
Expand Down
28 changes: 28 additions & 0 deletions plugins/attack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func TestAttackCommandParsing(t *testing.T) {
if _, _, ok := parseAttackArguments("attack", "unknown Alice"); ok {
t.Fatal("unknown attack style unexpectedly parsed")
}
style, target, ok = parseAttackArguments("attack", "strax")
if !ok || style != "strax" || target != "" {
t.Fatalf("optional-target attack parse = (%q, %q, %v)", style, target, ok)
}
style, target, ok = parseAttackArguments("spank", "Alice")
if !ok || style != "spank" || target != "Alice" {
t.Fatalf("spank parse = (%q, %q, %v)", style, target, ok)
}
}

func TestAttackTargetsRejectUnsafeText(t *testing.T) {
Expand Down Expand Up @@ -76,3 +84,23 @@ func TestAttackDefinitionsHaveSafeTemplates(t *testing.T) {
}
}
}

func TestAttackCommandCoverage(t *testing.T) {
for _, command := range []string{"spank", "bdsm", "clinton", "trump", "lurve", "pokemon", "strax", "nk", "westworld", "sexup", "jackmeoff", "end", "dominate", "luff", "luv", "spar", "challenge"} {
if _, ok := attackAliases[command]; !ok {
t.Errorf("missing attack command or alias %q", command)
}
}
}

func TestAttackCommandsIncludeEveryDefinition(t *testing.T) {
commands := make(map[string]bool)
for _, command := range (&Attack{}).Commands() {
commands[command] = true
}
for name := range attackDefinitions {
if !commands[name] {
t.Errorf("definition %q is not exposed as a command", name)
}
}
}
20 changes: 15 additions & 5 deletions plugins/karma.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func (p *Karma) Handle(b *bot.Bot, m bot.Message) bool {

type karmaUpdate struct {
key string
displayKey string
delta int
channel string
channelValue int
Expand All @@ -97,7 +98,8 @@ func (p *Karma) applyTextChanges(network, channel, text string) []karmaUpdate {
if match[1] < len(text) && isKarmaWordByte(text[match[1]]) {
continue
}
key := strings.ToLower(text[match[4]:match[5]])
displayKey := text[match[4]:match[5]]
key := strings.ToLower(displayKey)
delta := -1
if text[match[6]:match[7]] == "++" {
delta = 1
Expand All @@ -106,7 +108,7 @@ func (p *Karma) applyTextChanges(network, channel, text string) []karmaUpdate {
if err != nil {
continue
}
updates = append(updates, karmaUpdate{key: key, delta: delta, channel: channel, channelValue: channelValue, globalValue: globalValue})
updates = append(updates, karmaUpdate{key: key, displayKey: displayKey, delta: delta, channel: channel, channelValue: channelValue, globalValue: globalValue})
}
return updates
}
Expand Down Expand Up @@ -147,7 +149,7 @@ func formatKarmaUpdates(updates []karmaUpdate) string {
milestones := make([]string, 0)
for _, update := range updates {
if milestone := crossedKarmaMilestone(update); milestone > 0 {
milestones = append(milestones, fmt.Sprintf("%s has reached %+d karma! 🏆", update.key, milestone))
milestones = append(milestones, fmt.Sprintf("%s has reached %+d karma! 🏆", karmaDisplayName(update), milestone))
}
}
if len(milestones) > 0 {
Expand All @@ -171,16 +173,24 @@ func crossedKarmaMilestone(update karmaUpdate) int {
}

func formatKarmaUpdateDetail(update karmaUpdate) string {
name := karmaDisplayName(update)
if update.channel == "" {
return fmt.Sprintf("%s %+d (global total %+d)", update.key, update.delta, update.globalValue)
return fmt.Sprintf("%s %+d (global total %+d)", name, update.delta, update.globalValue)
}
action := "gained"
amount := update.delta
if amount < 0 {
action = "lost"
amount = -amount
}
return fmt.Sprintf("%s %s %d karma", update.key, action, amount)
return fmt.Sprintf("%s %s %d karma", name, action, amount)
}

func karmaDisplayName(update karmaUpdate) string {
if update.displayKey != "" {
return update.displayKey
}
return update.key
}

func formatKarmaScope(update karmaUpdate) string {
Expand Down
10 changes: 8 additions & 2 deletions plugins/karma_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@ func TestKarmaUpdateMessageIsColorfulAndCompact(t *testing.T) {
}

func TestKarmaUpdateIncludesChannelAndGlobalTotals(t *testing.T) {
message := formatKarmaUpdates([]karmaUpdate{{key: "project", delta: 1, channel: "#chat", channelValue: 9, globalValue: 19}})
if !strings.Contains(message, "project gained 1 karma") || !strings.Contains(message, "(🎯 9 in #chat | 🌐 19 global)") {
message := formatKarmaUpdates([]karmaUpdate{{key: "knownsyntax", displayKey: "KnownSyntax", delta: 1, channel: "#chat", channelValue: 9, globalValue: 19}})
if !strings.Contains(message, "KnownSyntax gained 1 karma") || !strings.Contains(message, "(🎯 9 in #chat | 🌐 19 global)") {
t.Fatalf("message %q does not contain scoped totals", message)
}
if strings.Contains(message, "knownsyntax gained") {
t.Fatalf("karma announcement lost nickname casing: %q", message)
}
}

func TestKarmaDecorationsKeepEmojiSeparated(t *testing.T) {
Expand Down Expand Up @@ -113,6 +116,9 @@ func TestKarmaTracksChannelAndGlobalTotals(t *testing.T) {
if channel != 0 || global != 3 {
t.Fatalf("secondary #chat totals = (%d, %d), want (0, 3)", channel, global)
}
if updates := p.applyTextChanges("primary", "#chat", "KnownSyntax++"); len(updates) != 1 || updates[0].displayKey != "KnownSyntax" {
t.Fatalf("expected display nickname to be preserved, got %v", updates)
}
}

func TestKarmaPreservesLegacyGlobalTotals(t *testing.T) {
Expand Down