From 3b4b3edac5e7021df29a456a15eb28b8b7d2b29e Mon Sep 17 00:00:00 2001 From: AK Date: Sun, 2 Aug 2026 05:28:07 -0700 Subject: [PATCH 1/3] feat: add interactive duck hunt arcade mechanics --- config.yaml | 27 ++- docs/games.md | 65 ++++-- plugins/duckhunt.go | 444 ++++++++++++++++++++++++++++++++++++--- plugins/duckhunt_test.go | 64 ++++++ 4 files changed, 554 insertions(+), 46 deletions(-) diff --git a/config.yaml b/config.yaml index d2d26a5..390f288 100644 --- a/config.yaml +++ b/config.yaml @@ -108,9 +108,32 @@ plugins: # Local sports suggestions; add one sport per line to data/sports.txt. sports: {enabled: true, data_file: "data/sports.txt", max_length: 200} car: {enabled: true, data_file: "data/cars.txt", max_length: 240} - # Optional activity-triggered duck hunt. Scores persist in BoltDB. + # Optional activity-triggered duck hunt. Scores and arcade gear persist in BoltDB. # Start/stop controls require an authenticated account listed in owner_accounts. - duckhunt: {enabled: false, minimum_messages: 25, minimum_users: 2, min_delay_seconds: 60, max_delay_seconds: 300, timeout_seconds: 60, flavor_enabled: true, flavor_min_lead_seconds: 15, befriend_enabled: true, min_reaction_seconds: 1, retry_cooldown_seconds: 7} + duckhunt: + enabled: false + minimum_messages: 25 + minimum_users: 2 + min_delay_seconds: 60 + max_delay_seconds: 300 + timeout_seconds: 60 + flavor_enabled: true + flavor_min_lead_seconds: 15 + befriend_enabled: true + min_reaction_seconds: 1 + retry_cooldown_seconds: 7 + # Fictional arcade mechanics only; no real-money wagering or real-world instructions. + minimum_hp: 1 + maximum_hp: 5 + damage_per_shot: 1 + befriend_attempts: 3 + golden_duck_probability: 0.15 + firearm_enabled: true + magazine_size: 6 + starting_ammo: 6 + starting_points: 25 + magazine_cost: 15 + gun_cost: 25 stats: enabled: true http_port: 8082 diff --git a/docs/games.md b/docs/games.md index 0bc935a..9736dd7 100644 --- a/docs/games.md +++ b/docs/games.md @@ -1,7 +1,8 @@ # Games and activities Games use the same command cooldown and outbound queue protections as every -other plugin. No game uses real money or virtual currency. +other plugin. No game uses real money or wagering; some activities, such as +Duck Hunt, use fictional points for local scoring only. ## Blackjack / 21 @@ -133,7 +134,8 @@ and rescheduled after restart; reminders older than 15 days are discarded. ## Duck Hunt Duck Hunt is an optional channel activity event. It is disabled by default and -does not kick users, use virtual currency, or involve wagers. +does not kick users, use real money, or involve wagers. Its points and gear are +fictional arcade mechanics only. ~~~yaml plugins: @@ -149,15 +151,27 @@ plugins: befriend_enabled: true min_reaction_seconds: 1 retry_cooldown_seconds: 7 + minimum_hp: 1 + maximum_hp: 5 + damage_per_shot: 1 + befriend_attempts: 3 + golden_duck_probability: 0.15 + firearm_enabled: true + magazine_size: 6 + starting_ammo: 6 + starting_points: 25 + magazine_cost: 15 + gun_cost: 25 ~~~ Once the activity threshold is reached, GoBot waits a random amount of time -and announces a duck. The first person to shoot or befriend it wins: +and announces a duck. Players can then shoot it over multiple hits or build +trust through repeated befriending attempts: ~~~text [Duck Hunt] · ° · ° · ° \_o< FLAP FLAP! -A wild duck appeared: \_o< QUACK! Type !bang to shoot it! -username shot a duck in 2.137 seconds! You have killed 1 duck in #example. +[Duck Hunt] \_o< GOLDEN DUCK QUACK! HP: 3 | Type !bang to shoot or !bef to befriend! +username hit the GOLDEN DUCK for 1 damage! It has 2 HP left. +20 points ~~~ Before the duck appears, GoBot may send one randomly timed, colorized teaser @@ -166,10 +180,10 @@ cycle, and `flavor_min_lead_seconds` keeps it separated from the actual duck announcement so it does not turn into channel chatter. Duck Hunt includes several randomized messages: four flight-trail/flavor -teasers, multiple duck and quack announcement variants, and eight different -escape actions including flying, flapping, waddling, slipping through reeds, -and zooming away. A hunt uses at most one teaser and one final outcome message, -so the variety does not create a flood. +teasers, multiple duck and quack announcement variants, and escape actions for +flying, flapping, waddling, slipping through reeds, zooming away, and the ninja +duck's smoke-bomb exit. A hunt uses at most one teaser and one final outcome +message, so the variety does not create a flood. Commands: @@ -177,6 +191,10 @@ Commands: !bang shoot an active duck !bef befriend an active duck, if enabled !befriend same as !bef +!buy gun buy the fictional duck gun +!buy magazine buy a fictional spare magazine +!ammo show ammo, spare magazines, and points +!reload load a spare magazine !ducks [nickname] show persistent scores !dh show Duck Hunt status !dh status show Duck Hunt status @@ -189,8 +207,16 @@ account listed in owner_accounts; anyone can shoot or befriend an active duck. Instant shots are treated as likely scripted input and miss. Shots during the early reaction window have a probability of success; slower shots succeed. -Each user gets a short retry cooldown. Invalid shots when no duck is active are -quietly ignored. +Each user gets a short retry cooldown. A successful shot removes +`damage_per_shot` HP; the duck remains active until its HP reaches zero. Golden +ducks are less common and award a larger fictional points bonus. + +`!bef` uses a small trust progression. The duck can warm up to a user without +being tamed immediately; after `befriend_attempts` successful approaches, the +user befriends it and receives a fictional points bonus. If there is no active +duck, `!bang` gives a short no-duck response. If the user had fictional arcade +gear, it may be confiscated as part of the joke; this has no effect outside the +bot's game data. If nobody shoots or befriends the duck before `timeout_seconds` expires, it responds with one randomized escape line such as `The duck escapes into the @@ -212,7 +238,16 @@ Settings: - befriend_enabled: whether !bef and !befriend are available - min_reaction_seconds: minimum reaction time before a shot can succeed - retry_cooldown_seconds: per-user cooldown after a shot attempt - -Scores include ducks shot and ducks befriended, are stored in BoltDB, and -survive restarts. After a duck is resolved or times out, the channel must -reach the activity threshold again before another automatic event is scheduled. +- minimum_hp and maximum_hp: inclusive range for a duck's starting HP +- damage_per_shot: HP removed by each successful shot +- befriend_attempts: trust approaches needed to befriend the duck +- golden_duck_probability: chance that a spawn is a higher-value golden duck +- firearm_enabled: enable the fictional duck-gun/ammo sub-game +- magazine_size and starting_ammo: magazine capacity and ammunition loaded when + a new fictional gun is acquired +- starting_points: fictional points granted when a player first participates +- magazine_cost and gun_cost: fictional point costs for arcade gear + +Scores, fictional points, and player gear are stored in BoltDB and survive +restarts. After a duck is resolved or times out, the channel must reach the +activity threshold again before another automatic event is scheduled. diff --git a/plugins/duckhunt.go b/plugins/duckhunt.go index 4f7d026..bf39cbe 100644 --- a/plugins/duckhunt.go +++ b/plugins/duckhunt.go @@ -11,7 +11,10 @@ import ( "github.com/variablenix/GoBot/storage" ) -const duckHuntBucket = "duckhunt_scores" +const ( + duckHuntBucket = "duckhunt_scores" + duckHuntPlayersBucket = "duckhunt_players" +) type duckHuntConfig struct { minimumMessages int @@ -24,6 +27,17 @@ type duckHuntConfig struct { befriendEnabled bool minReaction time.Duration retryCooldown time.Duration + minHP int + maxHP int + damagePerShot int + trustAttempts int + goldenChance float64 + firearmEnabled bool + magazineSize int + startingAmmo int + startingPoints int64 + magazineCost int64 + gunCost int64 } type duckHuntState struct { @@ -36,16 +50,29 @@ type duckHuntState struct { flavorSent bool active bool stopped bool + hp int + maxHP int + golden bool + trust map[string]int } type duckScore struct { Ducks uint64 `json:"ducks"` Friends uint64 `json:"friends"` + Points int64 `json:"points"` +} + +type duckPlayer struct { + Initialized bool `json:"initialized"` + HasGun bool `json:"has_gun"` + Ammo int `json:"ammo"` + SpareMagazines int `json:"spare_magazines"` + Points int64 `json:"points"` } -// DuckHunt is an optional channel activity event. It does not have rounds or -// wagers: a duck appears after enough activity, and the first successful -// interaction records a persistent per-channel score. +// DuckHunt is an optional channel activity event. It has no real-money or +// wagering mechanics: a duck appears after enough activity, and players use +// fictional points and arcade gear to interact with it. type DuckHunt struct { mu sync.Mutex db *storage.DB @@ -56,10 +83,10 @@ type DuckHunt struct { func (p *DuckHunt) Name() string { return "duckhunt" } func (p *DuckHunt) Commands() []string { - return []string{"duckhunt", "dh", "bang", "befriend", "bef", "ducks"} + return []string{"duckhunt", "dh", "bang", "befriend", "bef", "ducks", "buy", "reload", "ammo"} } func (p *DuckHunt) Help() string { - return "!bang or !bef[riend] — interact with an active duck; !dh start|stop|status — owner controls; !ducks [nick] — show scores" + return "!bang shoots an active duck; !bef befriends it; !ammo, !buy magazine, and !reload manage the fictional arcade gear; !ducks [nick] shows scores; !dh status|start|stop controls activity" } func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { @@ -73,6 +100,17 @@ func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { befriendEnabled := c.Bool("befriend_enabled", true) minReaction := c.Int("min_reaction_seconds", 1) retryCooldown := c.Int("retry_cooldown_seconds", 7) + minHP := c.Int("minimum_hp", 1) + maxHP := c.Int("maximum_hp", 5) + damagePerShot := c.Int("damage_per_shot", 1) + trustAttempts := c.Int("befriend_attempts", 3) + goldenChance := c.Float("golden_duck_probability", 0.15) + firearmEnabled := c.Bool("firearm_enabled", true) + magazineSize := c.Int("magazine_size", 6) + startingAmmo := c.Int("starting_ammo", magazineSize) + startingPoints := int64(c.Int("starting_points", 25)) + magazineCost := int64(c.Int("magazine_cost", 15)) + gunCost := int64(c.Int("gun_cost", 25)) if minimumMessages < 1 { minimumMessages = 1 @@ -98,6 +136,42 @@ func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { if retryCooldown < 1 { retryCooldown = 1 } + if minHP < 1 { + minHP = 1 + } + if maxHP < minHP { + maxHP = minHP + } + if damagePerShot < 1 { + damagePerShot = 1 + } + if trustAttempts < 1 { + trustAttempts = 1 + } + if goldenChance < 0 { + goldenChance = 0 + } + if goldenChance > 1 { + goldenChance = 1 + } + if magazineSize < 1 { + magazineSize = 1 + } + if startingAmmo < 0 { + startingAmmo = 0 + } + if startingAmmo > magazineSize { + startingAmmo = magazineSize + } + if startingPoints < 0 { + startingPoints = 0 + } + if magazineCost < 0 { + magazineCost = 0 + } + if gunCost < 0 { + gunCost = 0 + } p.db = db p.cfg = duckHuntConfig{ @@ -111,15 +185,25 @@ func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { befriendEnabled: befriendEnabled, minReaction: time.Duration(minReaction) * time.Second, retryCooldown: time.Duration(retryCooldown) * time.Second, + minHP: minHP, + maxHP: maxHP, + damagePerShot: damagePerShot, + trustAttempts: trustAttempts, + goldenChance: goldenChance, + firearmEnabled: firearmEnabled, + magazineSize: magazineSize, + startingAmmo: startingAmmo, + startingPoints: startingPoints, + magazineCost: magazineCost, + gunCost: gunCost, } p.states = make(map[string]*duckHuntState) p.attempts = make(map[string]time.Time) return nil } -// Start checks channel states in the background. GoBot intentionally keeps -// this event in memory; only scores are persisted, so a restart cannot create -// a surprise duck in a channel that was quiet before the restart. +// Start checks channel states in the background. Active duck state is kept in +// memory; scores, fictional points, and player gear are persisted. func (p *DuckHunt) Start(b *bot.Bot) { go func() { ticker := time.NewTicker(time.Second) @@ -158,6 +242,15 @@ func (p *DuckHunt) Handle(b *bot.Bot, m bot.Message) bool { case "ducks": p.ducks(b, m, arg) return true + case "buy": + p.buy(b, m, arg) + return true + case "reload": + p.reload(b, m) + return true + case "ammo": + p.ammo(b, m) + return true default: return false } @@ -207,6 +300,10 @@ func (p *DuckHunt) tick(b *bot.Bot) { state.flavorSent = false state.messages = 0 state.users = make(map[string]struct{}) + state.hp = 0 + state.maxHP = 0 + state.golden = false + state.trust = make(map[string]int) continue } if state.stopped { @@ -216,12 +313,7 @@ func (p *DuckHunt) tick(b *bot.Bot) { if now.Sub(state.spawnedAt) < p.cfg.timeout { continue } - state.active = false - state.spawnedAt = time.Time{} - state.flavorAt = time.Time{} - state.flavorSent = false - state.messages = 0 - state.users = make(map[string]struct{}) + p.resetCycleLocked(state) messages = append(messages, outgoing{ target: state.channel, text: randomDuckEscape(), @@ -245,9 +337,17 @@ func (p *DuckHunt) tick(b *bot.Bot) { state.flavorSent = false state.messages = 0 state.users = make(map[string]struct{}) + state.hp = 0 + state.maxHP = 0 + state.golden = false + state.trust = make(map[string]int) + state.maxHP = randomDuckHP(p.cfg) + state.hp = state.maxHP + state.golden = rand.Float64() < p.cfg.goldenChance + state.trust = make(map[string]int) messages = append(messages, outgoing{ target: state.channel, - text: randomDuckAnnouncement(), + text: randomDuckAnnouncementForState(state), }) } p.mu.Unlock() @@ -292,6 +392,10 @@ func (p *DuckHunt) control(b *bot.Bot, m bot.Message, arg string) { state.flavorSent = false state.messages = 0 state.users = make(map[string]struct{}) + state.hp = 0 + state.maxHP = 0 + state.golden = false + state.trust = make(map[string]int) p.mu.Unlock() b.Send(m.ReplyTarget(), ircColor(ircGreen, "Duck Hunt enabled in "+m.Target+".")) case "stop": @@ -309,6 +413,10 @@ func (p *DuckHunt) control(b *bot.Bot, m bot.Message, arg string) { state.flavorSent = false state.messages = 0 state.users = make(map[string]struct{}) + state.hp = 0 + state.maxHP = 0 + state.golden = false + state.trust = make(map[string]int) p.mu.Unlock() b.Send(m.ReplyTarget(), ircColor(ircYellow, "Duck Hunt stopped in "+m.Target+".")) default: @@ -323,6 +431,11 @@ func (p *DuckHunt) interact(b *bot.Bot, m bot.Message, befriend bool) { state := p.states[key] if state == nil || !state.active { p.mu.Unlock() + if befriend { + b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s: there is no duck to befriend in the area...", m.Nick))) + } else { + p.noDuckBang(b, m) + } return } attemptKey := key + "\x00" + strings.ToLower(m.Nick) @@ -331,6 +444,21 @@ func (p *DuckHunt) interact(b *bot.Bot, m bot.Message, befriend bool) { return } p.attempts[attemptKey] = now + player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) + if !befriend && p.cfg.firearmEnabled { + if !player.HasGun { + p.mu.Unlock() + b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s: click... you need a duck gun; use !buy gun", m.Nick))) + return + } + if player.Ammo < 1 { + p.mu.Unlock() + b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s: *click* you're out of ammo; use !reload", m.Nick))) + return + } + player.Ammo-- + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + } elapsed := now.Sub(state.spawnedAt) if elapsed < 0 { @@ -338,27 +466,56 @@ func (p *DuckHunt) interact(b *bot.Bot, m bot.Message, befriend bool) { } if elapsed < p.cfg.minReaction || (elapsed < 7*time.Second && rand.Float64() > hitChance(elapsed)) { p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s missed the duck! Try again in %d seconds.", ircColor(ircRed, "*BANG*"), m.Nick, int(p.cfg.retryCooldown/time.Second))) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s missed the duck! %s", ircColor(ircRed, "*BANG*"), m.Nick, ircColor(ircCyan, fmt.Sprintf("Try again in %d seconds.", int(p.cfg.retryCooldown/time.Second))))) return } - - state.active = false - state.spawnedAt = time.Time{} - state.nextSpawn = time.Time{} - state.flavorAt = time.Time{} - state.flavorSent = false - state.messages = 0 - state.users = make(map[string]struct{}) if befriend { + if state.trust == nil { + state.trust = make(map[string]int) + } + progress := state.trust[strings.ToLower(m.Nick)] + 1 + state.trust[strings.ToLower(m.Nick)] = progress + if progress < p.cfg.trustAttempts { + remaining := state.maxHP + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s *gentle approach* The duck warms up to you, but isn't tamed yet. It has %d HP left; trust %d/%d.", ircColor(ircCyan, "*BEFRIEND*"), m.Nick, remaining, progress, p.cfg.trustAttempts)) + return + } friends := p.incrementFriends(b.Config.NetworkName, m.Target, m.Nick) + bonus := int64(5) + if state.golden { + bonus *= 2 + } + player.Points += bonus + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + golden := duckName(state) + p.resetCycleLocked(state) + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s befriended %s! %s [%s points] You have befriended %d duck%s in %s.", ircColor(ircGreen, "*FRIEND*"), m.Nick, golden, ircColor(ircGreen, "QUACK!"), ircColor(ircGreen, fmt.Sprintf("+%d", bonus)), friends, duckPlural(friends), m.Target)) + return + } + damage := p.cfg.damagePerShot + state.hp -= damage + if state.hp > 0 { + remaining := state.hp + name := duckName(state) + points := shotPoints(state) p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s befriended the duck in %.3f seconds! You have befriended %d duck%s in %s.", ircColor(ircCyan, "*FRIEND*"), m.Nick, elapsed.Seconds(), friends, duckPlural(friends), m.Target)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s hit %s for %d damage! It has %d HP left. %s", ircColor(ircCyan, "*BANG*"), m.Nick, name, damage, remaining, ircColor(ircGreen, fmt.Sprintf("+%d points", points)))) return } kills := p.incrementScore(b.Config.NetworkName, m.Target, m.Nick) + bonus := int64(10) + if state.golden { + bonus *= 2 + } + player.Points += bonus + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + name := duckName(state) + p.resetCycleLocked(state) p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s shot a duck in %.3f seconds! You have killed %d duck%s in %s.", ircColor(ircGreen, "*BANG*"), m.Nick, elapsed.Seconds(), kills, duckPlural(kills), m.Target)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s hit %s for %d damage! It has 0 HP left. %s [%s] You have killed %d duck%s in %s.", ircColor(ircGreen, "*BANG*"), m.Nick, name, damage, ircColor(ircGreen, "KWAK!"), ircColor(ircGreen, fmt.Sprintf("+%d points", bonus)), kills, duckPlural(kills), m.Target)) } func (p *DuckHunt) ducks(b *bot.Bot, m bot.Message, arg string) { @@ -372,7 +529,131 @@ func (p *DuckHunt) ducks(b *bot.Bot, m bot.Message, arg string) { } score := p.readScore(b.Config.NetworkName, m.Target, nick) - b.Send(m.ReplyTarget(), fmt.Sprintf("%s has killed %d duck%s and befriended %d duck%s in %s.", nick, score.Ducks, duckPlural(score.Ducks), score.Friends, duckPlural(score.Friends), m.Target)) + p.mu.Lock() + player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, nick) + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s has killed %d duck%s, befriended %d duck%s, and has %d fictional points in %s.", nick, score.Ducks, duckPlural(score.Ducks), score.Friends, duckPlural(score.Friends), player.Points, m.Target)) +} + +func (p *DuckHunt) ammo(b *bot.Bot, m bot.Message) { + p.mu.Lock() + player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) + p.mu.Unlock() + if !p.cfg.firearmEnabled { + b.Send(m.ReplyTarget(), ircColor(ircCyan, "Duck Hunt arcade gear is disabled.")) + return + } + if !player.HasGun { + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you do not have a duck gun; use !buy gun. You have %d fictional points.", m.Nick, player.Points)) + return + } + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: ammo %d/%d, spare magazines %d, fictional points %d.", m.Nick, player.Ammo, p.cfg.magazineSize, player.SpareMagazines, player.Points)) +} + +func (p *DuckHunt) buy(b *bot.Bot, m bot.Message, arg string) { + item := strings.ToLower(strings.TrimSpace(arg)) + if item != "gun" && item != "duckgun" && item != "magazine" && item != "mag" && item != "ammo" { + b.Send(m.ReplyTarget(), ircColor(ircCyan, "usage: !buy gun|magazine (fictional arcade gear)")) + return + } + if !p.cfg.firearmEnabled { + b.Send(m.ReplyTarget(), ircColor(ircYellow, "Duck Hunt arcade gear is disabled.")) + return + } + p.mu.Lock() + player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) + if item == "gun" || item == "duckgun" { + if player.HasGun { + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you already have a duck gun.", m.Nick)) + return + } + if player.Points < p.cfg.gunCost { + points := player.Points + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: the duck gun costs %d fictional points; you have %d.", m.Nick, p.cfg.gunCost, points)) + return + } + player.Points -= p.cfg.gunCost + player.HasGun = true + player.Ammo = p.cfg.startingAmmo + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: duck gun acquired for %d fictional points; ammo %d/%d. Use !bang during a hunt.", m.Nick, p.cfg.gunCost, player.Ammo, p.cfg.magazineSize)) + return + } + if !player.HasGun { + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: buy a duck gun first with !buy gun.", m.Nick)) + return + } + if player.Points < p.cfg.magazineCost { + points := player.Points + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: a spare magazine costs %d fictional points; you have %d.", m.Nick, p.cfg.magazineCost, points)) + return + } + player.Points -= p.cfg.magazineCost + player.SpareMagazines++ + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: spare magazine purchased for %d fictional points. Spare magazines: %d.", m.Nick, p.cfg.magazineCost, player.SpareMagazines)) +} + +func (p *DuckHunt) reload(b *bot.Bot, m bot.Message) { + if !p.cfg.firearmEnabled { + b.Send(m.ReplyTarget(), ircColor(ircYellow, "Duck Hunt arcade gear is disabled.")) + return + } + p.mu.Lock() + player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) + if !player.HasGun { + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you need a duck gun first; use !buy gun.", m.Nick)) + return + } + if player.Ammo >= p.cfg.magazineSize { + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: your magazine is already full at %d/%d.", m.Nick, player.Ammo, p.cfg.magazineSize)) + return + } + if player.SpareMagazines < 1 { + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: no spare magazines. Use !buy magazine.", m.Nick)) + return + } + player.SpareMagazines-- + player.Ammo = p.cfg.magazineSize + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + p.mu.Unlock() + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: *click* new magazine loaded! Ammo: %d/%d; spare magazines: %d.", m.Nick, player.Ammo, p.cfg.magazineSize, player.SpareMagazines)) +} + +func (p *DuckHunt) noDuckBang(b *bot.Bot, m bot.Message) { + if !p.cfg.firearmEnabled { + b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s: BANG... what did you shoot at? There is no duck in the area.", m.Nick))) + return + } + p.mu.Lock() + player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) + hadGun := player.HasGun + if hadGun { + player.HasGun = false + player.Ammo = 0 + player.SpareMagazines = 0 + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) + } + p.mu.Unlock() + if !hadGun { + b.Send(m.ReplyTarget(), fmt.Sprintf("%s BANG... what did you shoot at? There is no duck in the area. %s", m.Nick, ircColor(ircCyan, "You need a duck gun first; use !buy gun."))) + return + } + messages := []string{ + "BANG... what did you shoot at? There is no duck in the area... [GUN CONFISCATED]", + "That was a very brave shot at absolutely nothing. [GUN CONFISCATED]", + "The reeds file a complaint. No duck spotted. [GUN CONFISCATED]", + } + b.Send(m.ReplyTarget(), ircColor(ircRed, messages[rand.Intn(len(messages))])) } func (p *DuckHunt) incrementScore(network, channel, nick string) uint64 { @@ -408,6 +689,55 @@ func (p *DuckHunt) readScore(network, channel, nick string) duckScore { return saved } +func (p *DuckHunt) loadPlayerLocked(network, channel, nick string) duckPlayer { + player := duckPlayer{ + Initialized: true, + HasGun: false, + Ammo: 0, + Points: p.cfg.startingPoints, + } + if p.db == nil { + return player + } + raw, err := p.db.Get(duckHuntPlayersBucket, duckHuntPlayerKey(network, channel, nick)) + if err == nil { + var saved duckPlayer + if storage.Decode(raw, &saved) == nil { + player = saved + } + } + if !player.Initialized { + player.Initialized = true + if player.Points == 0 { + player.Points = p.cfg.startingPoints + } + } + if !p.cfg.firearmEnabled { + player.HasGun = false + player.Ammo = 0 + player.SpareMagazines = 0 + } + if player.Points < 0 { + player.Points = 0 + } + if player.Ammo < 0 { + player.Ammo = 0 + } + if player.Ammo > p.cfg.magazineSize { + player.Ammo = p.cfg.magazineSize + } + if player.SpareMagazines < 0 { + player.SpareMagazines = 0 + } + return player +} + +func (p *DuckHunt) savePlayerLocked(network, channel, nick string, player duckPlayer) { + if p.db != nil { + _ = p.db.Set(duckHuntPlayersBucket, duckHuntPlayerKey(network, channel, nick), player) + } +} + func (p *DuckHunt) stateLocked(network, channel string) *duckHuntState { key := duckHuntStateKey(network, channel) state := p.states[key] @@ -418,6 +748,20 @@ func (p *DuckHunt) stateLocked(network, channel string) *duckHuntState { return state } +func (p *DuckHunt) resetCycleLocked(state *duckHuntState) { + state.active = false + state.spawnedAt = time.Time{} + state.nextSpawn = time.Time{} + state.flavorAt = time.Time{} + state.flavorSent = false + state.messages = 0 + state.users = make(map[string]struct{}) + state.hp = 0 + state.maxHP = 0 + state.golden = false + state.trust = make(map[string]int) +} + func hitChance(elapsed time.Duration) float64 { if elapsed < time.Second { return 0 @@ -436,6 +780,27 @@ func randomDuckDelay(c duckHuntConfig) time.Duration { return c.minDelay + time.Duration(rand.Intn(span+1))*time.Second } +func randomDuckHP(c duckHuntConfig) int { + if c.maxHP <= c.minHP { + return c.minHP + } + return c.minHP + rand.Intn(c.maxHP-c.minHP+1) +} + +func shotPoints(state *duckHuntState) int64 { + if state != nil && state.golden { + return 20 + } + return 10 +} + +func duckName(state *duckHuntState) string { + if state != nil && state.golden { + return ircColor(ircYellow, "the GOLDEN DUCK") + } + return ircColor(ircCyan, "the duck") +} + func randomDuckFlavorTime(now, nextSpawn time.Time, c duckHuntConfig) time.Time { if !c.flavorEnabled { return time.Time{} @@ -467,11 +832,25 @@ func randomDuckFlavor() string { } func randomDuckAnnouncement() string { + return randomDuckAnnouncementForState(&duckHuntState{hp: 1, maxHP: 1}) +} + +func randomDuckAnnouncementForState(state *duckHuntState) string { ducks := []string{`\_o<`, `\_O<`, `\_0<`, `\_ö<`} noises := []string{"quack!", "QUACK!", "QUACK! QUACK!", "QUACK! flap flap!"} duck := ducks[rand.Intn(len(ducks))] noise := noises[rand.Intn(len(noises))] - return fmt.Sprintf("%s %s %s Type %s to shoot it!", ircColor(ircGreen, "[Duck Hunt]"), ircColor(ircYellow, duck), ircColor(ircCyan, noise), ircColor(ircBold, "!bang")) + hp := 1 + if state != nil && state.maxHP > 0 { + hp = state.maxHP + } + name := "DUCK" + color := ircCyan + if state != nil && state.golden { + name = "GOLDEN DUCK" + color = ircYellow + } + return fmt.Sprintf("%s %s %s %s HP: %d | Type %s to shoot or %s to befriend!", ircColor(ircGreen, "[Duck Hunt]"), ircColor(ircYellow, duck), ircColor(color, name), ircColor(ircCyan, noise), hp, ircColor(ircBold, "!bang"), ircColor(ircBold, "!bef")) } func randomDuckEscape() string { @@ -484,6 +863,9 @@ func randomDuckEscape() string { `The duck slips away through the reeds. Better luck next time!`, `The duck spreads its wings and soars away. \_O<`, `The duck makes a break for it—waddle waddle waddle!`, + `The ninja duck drops a smoke bomb and vanishes! *poof*`, + `The duck moonwalks into the reeds and disappears.`, + `The duck performs an evasive barrel roll and escapes!`, } return ircColor(ircYellow, "[Duck Hunt] "+actions[rand.Intn(len(actions))]) } @@ -502,3 +884,7 @@ func duckHuntStateKey(network, channel string) string { func duckHuntScoreKey(network, channel, nick string) string { return duckHuntStateKey(network, channel) + "\x00" + strings.ToLower(nick) } + +func duckHuntPlayerKey(network, channel, nick string) string { + return duckHuntScoreKey(network, channel, nick) +} diff --git a/plugins/duckhunt_test.go b/plugins/duckhunt_test.go index a361d07..4f40836 100644 --- a/plugins/duckhunt_test.go +++ b/plugins/duckhunt_test.go @@ -26,6 +26,12 @@ func TestDuckHuntDefaults(t *testing.T) { if !plugin.cfg.flavorEnabled || plugin.cfg.flavorMinLead != 15*time.Second { t.Fatalf("unexpected flavor defaults: %+v", plugin.cfg) } + if plugin.cfg.minHP != 1 || plugin.cfg.maxHP != 5 || plugin.cfg.damagePerShot != 1 || plugin.cfg.trustAttempts != 3 { + t.Fatalf("unexpected duck mechanics defaults: %+v", plugin.cfg) + } + if !plugin.cfg.firearmEnabled || plugin.cfg.magazineSize != 6 || plugin.cfg.startingAmmo != 6 || plugin.cfg.startingPoints != 25 { + t.Fatalf("unexpected arcade gear defaults: %+v", plugin.cfg) + } } func TestDuckHuntIncludesBefriendAlias(t *testing.T) { @@ -40,6 +46,18 @@ func TestDuckHuntIncludesBefriendAlias(t *testing.T) { if !found { t.Fatal("expected !bef command alias") } + for _, command := range []string{"buy", "reload", "ammo"} { + found = false + for _, candidate := range plugin.Commands() { + if candidate == command { + found = true + break + } + } + if !found { + t.Fatalf("expected %q command", command) + } + } if !strings.Contains(plugin.Help(), "!bef") { t.Fatal("expected !bef alias in help") } @@ -154,6 +172,42 @@ func TestDuckHuntHitChanceProtectsAgainstInstantShots(t *testing.T) { } } +func TestDuckHuntRandomHPStaysWithinConfiguredRange(t *testing.T) { + cfg := duckHuntConfig{minHP: 2, maxHP: 5} + for i := 0; i < 100; i++ { + got := randomDuckHP(cfg) + if got < cfg.minHP || got > cfg.maxHP { + t.Fatalf("random HP = %d, want %d..%d", got, cfg.minHP, cfg.maxHP) + } + } +} + +func TestDuckHuntPlayerGearPersists(t *testing.T) { + db, err := storage.Open(t.TempDir() + "/bot.db") + if err != nil { + t.Fatalf("open database: %v", err) + } + defer db.Close() + plugin := &DuckHunt{} + if err := plugin.Init(nil, db); err != nil { + t.Fatalf("Init returned error: %v", err) + } + plugin.mu.Lock() + player := plugin.loadPlayerLocked("network", "#channel", "Alice") + player.SpareMagazines = 2 + player.Ammo = 3 + player.Points = 99 + plugin.savePlayerLocked("network", "#channel", "Alice", player) + plugin.mu.Unlock() + + plugin.mu.Lock() + loaded := plugin.loadPlayerLocked("network", "#channel", "alice") + plugin.mu.Unlock() + if loaded.SpareMagazines != 2 || loaded.Ammo != 3 || loaded.Points != 99 { + t.Fatalf("loaded player = %+v, want persisted gear and points", loaded) + } +} + func TestDuckHuntAnnouncementIncludesColorDuckAndQuack(t *testing.T) { announcement := randomDuckAnnouncement() if !strings.Contains(announcement, "\x03") { @@ -167,6 +221,16 @@ func TestDuckHuntAnnouncementIncludesColorDuckAndQuack(t *testing.T) { } } +func TestDuckHuntGoldenAnnouncementIncludesHPAndActions(t *testing.T) { + announcement := randomDuckAnnouncementForState(&duckHuntState{golden: true, hp: 4, maxHP: 4}) + if !strings.Contains(announcement, "GOLDEN DUCK") || !strings.Contains(announcement, "HP: 4") { + t.Fatalf("unexpected golden announcement: %q", announcement) + } + if !strings.Contains(announcement, "!bang") || !strings.Contains(announcement, "!bef") { + t.Fatalf("announcement does not provide actions: %q", announcement) + } +} + func TestDuckHuntFlavorIncludesColorAndMotion(t *testing.T) { flavor := randomDuckFlavor() if !strings.Contains(flavor, "\x03") { From 49800bfd6833c34d638c7b3e26ea366825e20d3b Mon Sep 17 00:00:00 2001 From: AK Date: Sun, 2 Aug 2026 05:47:19 -0700 Subject: [PATCH 2/3] feat: expand Duck Hunt shop and rewards --- config.yaml | 2 +- docs/games.md | 56 +++++++---- plugins/duckhunt.go | 210 ++++++++++++++++++++++++++++++--------- plugins/duckhunt_test.go | 26 ++++- 4 files changed, 226 insertions(+), 68 deletions(-) diff --git a/config.yaml b/config.yaml index 390f288..b971c7e 100644 --- a/config.yaml +++ b/config.yaml @@ -122,7 +122,7 @@ plugins: befriend_enabled: true min_reaction_seconds: 1 retry_cooldown_seconds: 7 - # Fictional arcade mechanics only; no real-money wagering or real-world instructions. + # Points are local to the bot; there is no wagering or external payment. minimum_hp: 1 maximum_hp: 5 damage_per_shot: 1 diff --git a/docs/games.md b/docs/games.md index 9736dd7..6f0d2f2 100644 --- a/docs/games.md +++ b/docs/games.md @@ -1,8 +1,8 @@ # Games and activities Games use the same command cooldown and outbound queue protections as every -other plugin. No game uses real money or wagering; some activities, such as -Duck Hunt, use fictional points for local scoring only. +other plugin. No game uses real money or wagering. Duck Hunt points are local +scores with no value outside the bot. ## Blackjack / 21 @@ -134,8 +134,9 @@ and rescheduled after restart; reminders older than 15 days are discarded. ## Duck Hunt Duck Hunt is an optional channel activity event. It is disabled by default and -does not kick users, use real money, or involve wagers. Its points and gear are -fictional arcade mechanics only. +combines short randomized duck appearances with a small points-and-gear arcade +loop. Points have no value outside the bot; there are no wagers or external +payments. ~~~yaml plugins: @@ -191,8 +192,11 @@ Commands: !bang shoot an active duck !bef befriend an active duck, if enabled !befriend same as !bef -!buy gun buy the fictional duck gun -!buy magazine buy a fictional spare magazine +!shop / !store list arcade gear and point prices +!buy peashooter buy the starter Peashooter (!buy gun also works) +!buy quacker buy the larger Quacker Blaster +!buy golden buy the stronger Golden Wing +!buy magazine buy a spare magazine !ammo show ammo, spare magazines, and points !reload load a spare magazine !ducks [nickname] show persistent scores @@ -209,14 +213,24 @@ Instant shots are treated as likely scripted input and miss. Shots during the early reaction window have a probability of success; slower shots succeed. Each user gets a short retry cooldown. A successful shot removes `damage_per_shot` HP; the duck remains active until its HP reaches zero. Golden -ducks are less common and award a larger fictional points bonus. +ducks are less common and award a larger points bonus. `!bef` uses a small trust progression. The duck can warm up to a user without being tamed immediately; after `befriend_attempts` successful approaches, the -user befriends it and receives a fictional points bonus. If there is no active -duck, `!bang` gives a short no-duck response. If the user had fictional arcade -gear, it may be confiscated as part of the joke; this has no effect outside the -bot's game data. +user befriends it and receives a points bonus. Successful shots also earn +points, and resolving a duck adds a larger completion bonus. Spend those +points in `!shop`: the Peashooter is the baseline item, the Quacker Blaster +has a larger magazine, and the Golden Wing costs more but deals extra damage. +The catalog uses arcade-style names and simple game statistics so the feature +stays focused on gameplay rather than real-world equipment. + +New players receive `starting_points`. A player can buy one item at a time; +buying a different item replaces the current gear and starts a fresh magazine. +Spare magazines are purchased with `!buy magazine` and loaded with `!reload`. +Ammo is consumed by `!bang`, while successful hits and befriending replenish +the player's points over time. If a player fires when no duck is active, the +bot may confiscate their gear and report `[GUN CONFISCATED]`; the player can +earn more points and visit `!shop` again. This is only a game-state penalty. If nobody shoots or befriends the duck before `timeout_seconds` expires, it responds with one randomized escape line such as `The duck escapes into the @@ -242,12 +256,14 @@ Settings: - damage_per_shot: HP removed by each successful shot - befriend_attempts: trust approaches needed to befriend the duck - golden_duck_probability: chance that a spawn is a higher-value golden duck -- firearm_enabled: enable the fictional duck-gun/ammo sub-game -- magazine_size and starting_ammo: magazine capacity and ammunition loaded when - a new fictional gun is acquired -- starting_points: fictional points granted when a player first participates -- magazine_cost and gun_cost: fictional point costs for arcade gear - -Scores, fictional points, and player gear are stored in BoltDB and survive -restarts. After a duck is resolved or times out, the channel must reach the -activity threshold again before another automatic event is scheduled. +- firearm_enabled: enable the arcade gear and ammo sub-game +- magazine_size and starting_ammo: baseline Peashooter capacity and ammunition + loaded when a player buys gear; the Quacker Blaster and Golden Wing adjust + the baseline capacity/damage +- starting_points: points granted when a player first participates +- magazine_cost and gun_cost: base shop prices; the enhanced items add their + own small point premium + +Scores, points, and player gear are stored in BoltDB and survive restarts. +After a duck is resolved or times out, the channel must reach the activity +threshold again before another automatic event is scheduled. diff --git a/plugins/duckhunt.go b/plugins/duckhunt.go index bf39cbe..994c1f6 100644 --- a/plugins/duckhunt.go +++ b/plugins/duckhunt.go @@ -59,20 +59,28 @@ type duckHuntState struct { type duckScore struct { Ducks uint64 `json:"ducks"` Friends uint64 `json:"friends"` - Points int64 `json:"points"` } type duckPlayer struct { - Initialized bool `json:"initialized"` - HasGun bool `json:"has_gun"` - Ammo int `json:"ammo"` - SpareMagazines int `json:"spare_magazines"` - Points int64 `json:"points"` + Initialized bool `json:"initialized"` + HasGun bool `json:"has_gun"` + Weapon string `json:"weapon"` + Ammo int `json:"ammo"` + SpareMagazines int `json:"spare_magazines"` + Points int64 `json:"points"` +} + +type duckWeapon struct { + Key string + Name string + Cost int64 + Damage int + MagazineSize int } // DuckHunt is an optional channel activity event. It has no real-money or // wagering mechanics: a duck appears after enough activity, and players use -// fictional points and arcade gear to interact with it. +// local points and arcade gear to interact with it. type DuckHunt struct { mu sync.Mutex db *storage.DB @@ -83,10 +91,77 @@ type DuckHunt struct { func (p *DuckHunt) Name() string { return "duckhunt" } func (p *DuckHunt) Commands() []string { - return []string{"duckhunt", "dh", "bang", "befriend", "bef", "ducks", "buy", "reload", "ammo"} + return []string{"duckhunt", "dh", "bang", "befriend", "bef", "ducks", "shop", "store", "buy", "reload", "ammo"} } func (p *DuckHunt) Help() string { - return "!bang shoots an active duck; !bef befriends it; !ammo, !buy magazine, and !reload manage the fictional arcade gear; !ducks [nick] shows scores; !dh status|start|stop controls activity" + return "!bang shoots an active duck; !bef befriends it; !shop lists arcade gear; !buy , !ammo, and !reload manage it; !ducks [nick] shows scores; !dh status|start|stop controls activity" +} + +// shopWeapons keeps the arsenal deliberately arcade-like. The names are game +// items, not real-world firearm recommendations or instructions. +func (p *DuckHunt) shopWeapons() []duckWeapon { + baseDamage := p.cfg.damagePerShot + return []duckWeapon{ + { + Key: "peashooter", + Name: "Peashooter", + Cost: p.cfg.gunCost, + Damage: baseDamage, + MagazineSize: p.cfg.magazineSize, + }, + { + Key: "quacker", + Name: "Quacker Blaster", + Cost: p.cfg.gunCost + 15, + Damage: baseDamage, + MagazineSize: p.cfg.magazineSize + 2, + }, + { + Key: "golden", + Name: "Golden Wing", + Cost: p.cfg.gunCost + 50, + Damage: baseDamage + 1, + MagazineSize: maxInt(2, p.cfg.magazineSize-2), + }, + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func (p *DuckHunt) weaponForPlayer(player duckPlayer) duckWeapon { + weapons := p.shopWeapons() + key := strings.ToLower(strings.TrimSpace(player.Weapon)) + for _, weapon := range weapons { + if weapon.Key == key { + return weapon + } + } + return weapons[0] +} + +func (p *DuckHunt) findWeapon(key string) (duckWeapon, bool) { + key = strings.ToLower(strings.TrimSpace(key)) + if key == "gun" || key == "duckgun" || key == "pea" { + key = "peashooter" + } + for _, weapon := range p.shopWeapons() { + if weapon.Key == key { + return weapon, true + } + } + return duckWeapon{}, false } func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { @@ -203,7 +278,7 @@ func (p *DuckHunt) Init(c bot.PluginConfig, db *storage.DB) error { } // Start checks channel states in the background. Active duck state is kept in -// memory; scores, fictional points, and player gear are persisted. +// memory; scores, points, and player gear are persisted. func (p *DuckHunt) Start(b *bot.Bot) { go func() { ticker := time.NewTicker(time.Second) @@ -242,6 +317,9 @@ func (p *DuckHunt) Handle(b *bot.Bot, m bot.Message) bool { case "ducks": p.ducks(b, m, arg) return true + case "shop", "store": + p.shop(b, m) + return true case "buy": p.buy(b, m, arg) return true @@ -448,7 +526,7 @@ func (p *DuckHunt) interact(b *bot.Bot, m bot.Message, befriend bool) { if !befriend && p.cfg.firearmEnabled { if !player.HasGun { p.mu.Unlock() - b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s: click... you need a duck gun; use !buy gun", m.Nick))) + b.Send(m.ReplyTarget(), ircColor(ircYellow, fmt.Sprintf("%s: click... visit !shop and buy some arcade gear first", m.Nick))) return } if player.Ammo < 1 { @@ -494,12 +572,15 @@ func (p *DuckHunt) interact(b *bot.Bot, m bot.Message, befriend bool) { b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s befriended %s! %s [%s points] You have befriended %d duck%s in %s.", ircColor(ircGreen, "*FRIEND*"), m.Nick, golden, ircColor(ircGreen, "QUACK!"), ircColor(ircGreen, fmt.Sprintf("+%d", bonus)), friends, duckPlural(friends), m.Target)) return } - damage := p.cfg.damagePerShot + weapon := p.weaponForPlayer(player) + damage := weapon.Damage state.hp -= damage if state.hp > 0 { remaining := state.hp name := duckName(state) points := shotPoints(state) + player.Points += points + p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) p.mu.Unlock() b.Send(m.ReplyTarget(), fmt.Sprintf("%s %s hit %s for %d damage! It has %d HP left. %s", ircColor(ircCyan, "*BANG*"), m.Nick, name, damage, remaining, ircColor(ircGreen, fmt.Sprintf("+%d points", points)))) return @@ -532,7 +613,7 @@ func (p *DuckHunt) ducks(b *bot.Bot, m bot.Message, arg string) { p.mu.Lock() player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, nick) p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s has killed %d duck%s, befriended %d duck%s, and has %d fictional points in %s.", nick, score.Ducks, duckPlural(score.Ducks), score.Friends, duckPlural(score.Friends), player.Points, m.Target)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s has killed %d duck%s, befriended %d duck%s, and has %d points in %s.", nick, score.Ducks, duckPlural(score.Ducks), score.Friends, duckPlural(score.Friends), player.Points, m.Target)) } func (p *DuckHunt) ammo(b *bot.Bot, m bot.Message) { @@ -544,17 +625,42 @@ func (p *DuckHunt) ammo(b *bot.Bot, m bot.Message) { return } if !player.HasGun { - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you do not have a duck gun; use !buy gun. You have %d fictional points.", m.Nick, player.Points)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you do not have arcade gear; use !shop. You have %d points.", m.Nick, player.Points)) return } - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: ammo %d/%d, spare magazines %d, fictional points %d.", m.Nick, player.Ammo, p.cfg.magazineSize, player.SpareMagazines, player.Points)) + weapon := p.weaponForPlayer(player) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: %s ammo %d/%d, spare magazines %d, points %d.", m.Nick, weapon.Name, player.Ammo, weapon.MagazineSize, player.SpareMagazines, player.Points)) +} + +func (p *DuckHunt) shop(b *bot.Bot, m bot.Message) { + if !p.cfg.firearmEnabled { + b.Send(m.ReplyTarget(), ircColor(ircYellow, "Duck Hunt arcade gear is disabled.")) + return + } + parts := make([]string, 0, len(p.shopWeapons())+1) + for _, weapon := range p.shopWeapons() { + name := weapon.Name + if weapon.Key == "golden" { + name = ircColor(ircYellow, name) + } else { + name = ircColor(ircCyan, name) + } + parts = append(parts, fmt.Sprintf("!buy %s (%s): %d points, %d dmg, %d-cap mag", weapon.Key, name, weapon.Cost, weapon.Damage, weapon.MagazineSize)) + } + parts = append(parts, fmt.Sprintf("!buy magazine: %d points", p.cfg.magazineCost)) + b.Send(m.ReplyTarget(), "Duck Hunt shop: "+strings.Join(parts, " | ")) } func (p *DuckHunt) buy(b *bot.Bot, m bot.Message, arg string) { item := strings.ToLower(strings.TrimSpace(arg)) - if item != "gun" && item != "duckgun" && item != "magazine" && item != "mag" && item != "ammo" { - b.Send(m.ReplyTarget(), ircColor(ircCyan, "usage: !buy gun|magazine (fictional arcade gear)")) - return + if item == "mag" || item == "ammo" { + item = "magazine" + } + if item != "magazine" { + if _, ok := p.findWeapon(item); !ok { + b.Send(m.ReplyTarget(), ircColor(ircCyan, "usage: !shop or !buy peashooter|quacker|golden|magazine")) + return + } } if !p.cfg.firearmEnabled { b.Send(m.ReplyTarget(), ircColor(ircYellow, "Duck Hunt arcade gear is disabled.")) @@ -562,42 +668,45 @@ func (p *DuckHunt) buy(b *bot.Bot, m bot.Message, arg string) { } p.mu.Lock() player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) - if item == "gun" || item == "duckgun" { - if player.HasGun { + if item != "magazine" { + weapon, _ := p.findWeapon(item) + if player.HasGun && player.Weapon == weapon.Key { p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you already have a duck gun.", m.Nick)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you already have the %s.", m.Nick, weapon.Name)) return } - if player.Points < p.cfg.gunCost { + if player.Points < weapon.Cost { points := player.Points p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: the duck gun costs %d fictional points; you have %d.", m.Nick, p.cfg.gunCost, points)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: the %s costs %d points; you have %d.", m.Nick, weapon.Name, weapon.Cost, points)) return } - player.Points -= p.cfg.gunCost + player.Points -= weapon.Cost player.HasGun = true - player.Ammo = p.cfg.startingAmmo + player.Weapon = weapon.Key + player.Ammo = minInt(p.cfg.startingAmmo, weapon.MagazineSize) + player.SpareMagazines = 0 p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: duck gun acquired for %d fictional points; ammo %d/%d. Use !bang during a hunt.", m.Nick, p.cfg.gunCost, player.Ammo, p.cfg.magazineSize)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: %s acquired for %d points; ammo %d/%d. Use !bang during a hunt.", m.Nick, weapon.Name, weapon.Cost, player.Ammo, weapon.MagazineSize)) return } if !player.HasGun { p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: buy a duck gun first with !buy gun.", m.Nick)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: buy arcade gear first with !shop.", m.Nick)) return } if player.Points < p.cfg.magazineCost { points := player.Points p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: a spare magazine costs %d fictional points; you have %d.", m.Nick, p.cfg.magazineCost, points)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: a spare magazine costs %d points; you have %d.", m.Nick, p.cfg.magazineCost, points)) return } player.Points -= p.cfg.magazineCost player.SpareMagazines++ p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: spare magazine purchased for %d fictional points. Spare magazines: %d.", m.Nick, p.cfg.magazineCost, player.SpareMagazines)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: spare magazine purchased for %d points. Spare magazines: %d.", m.Nick, p.cfg.magazineCost, player.SpareMagazines)) } func (p *DuckHunt) reload(b *bot.Bot, m bot.Message) { @@ -609,12 +718,13 @@ func (p *DuckHunt) reload(b *bot.Bot, m bot.Message) { player := p.loadPlayerLocked(b.Config.NetworkName, m.Target, m.Nick) if !player.HasGun { p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you need a duck gun first; use !buy gun.", m.Nick)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: you need arcade gear first; use !shop.", m.Nick)) return } - if player.Ammo >= p.cfg.magazineSize { + weapon := p.weaponForPlayer(player) + if player.Ammo >= weapon.MagazineSize { p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: your magazine is already full at %d/%d.", m.Nick, player.Ammo, p.cfg.magazineSize)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: your %s magazine is already full at %d/%d.", m.Nick, weapon.Name, player.Ammo, weapon.MagazineSize)) return } if player.SpareMagazines < 1 { @@ -623,10 +733,10 @@ func (p *DuckHunt) reload(b *bot.Bot, m bot.Message) { return } player.SpareMagazines-- - player.Ammo = p.cfg.magazineSize + player.Ammo = weapon.MagazineSize p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) p.mu.Unlock() - b.Send(m.ReplyTarget(), fmt.Sprintf("%s: *click* new magazine loaded! Ammo: %d/%d; spare magazines: %d.", m.Nick, player.Ammo, p.cfg.magazineSize, player.SpareMagazines)) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s: *click* new %s magazine loaded! Ammo: %d/%d; spare magazines: %d.", m.Nick, weapon.Name, player.Ammo, weapon.MagazineSize, player.SpareMagazines)) } func (p *DuckHunt) noDuckBang(b *bot.Bot, m bot.Message) { @@ -639,13 +749,14 @@ func (p *DuckHunt) noDuckBang(b *bot.Bot, m bot.Message) { hadGun := player.HasGun if hadGun { player.HasGun = false + player.Weapon = "" player.Ammo = 0 player.SpareMagazines = 0 p.savePlayerLocked(b.Config.NetworkName, m.Target, m.Nick, player) } p.mu.Unlock() if !hadGun { - b.Send(m.ReplyTarget(), fmt.Sprintf("%s BANG... what did you shoot at? There is no duck in the area. %s", m.Nick, ircColor(ircCyan, "You need a duck gun first; use !buy gun."))) + b.Send(m.ReplyTarget(), fmt.Sprintf("%s BANG... what did you shoot at? There is no duck in the area. %s", m.Nick, ircColor(ircCyan, "Visit !shop before taking aim."))) return } messages := []string{ @@ -696,14 +807,13 @@ func (p *DuckHunt) loadPlayerLocked(network, channel, nick string) duckPlayer { Ammo: 0, Points: p.cfg.startingPoints, } - if p.db == nil { - return player - } - raw, err := p.db.Get(duckHuntPlayersBucket, duckHuntPlayerKey(network, channel, nick)) - if err == nil { - var saved duckPlayer - if storage.Decode(raw, &saved) == nil { - player = saved + if p.db != nil { + raw, err := p.db.Get(duckHuntPlayersBucket, duckHuntPlayerKey(network, channel, nick)) + if err == nil { + var saved duckPlayer + if storage.Decode(raw, &saved) == nil { + player = saved + } } } if !player.Initialized { @@ -714,17 +824,27 @@ func (p *DuckHunt) loadPlayerLocked(network, channel, nick string) duckPlayer { } if !p.cfg.firearmEnabled { player.HasGun = false + player.Weapon = "" player.Ammo = 0 player.SpareMagazines = 0 } + if player.HasGun && strings.TrimSpace(player.Weapon) == "" { + // Migrate players created by the earlier generic-gun implementation. + player.Weapon = "peashooter" + } + if !player.HasGun { + player.Weapon = "" + } if player.Points < 0 { player.Points = 0 } if player.Ammo < 0 { player.Ammo = 0 } - if player.Ammo > p.cfg.magazineSize { - player.Ammo = p.cfg.magazineSize + if player.HasGun { + if weapon := p.weaponForPlayer(player); player.Ammo > weapon.MagazineSize { + player.Ammo = weapon.MagazineSize + } } if player.SpareMagazines < 0 { player.SpareMagazines = 0 diff --git a/plugins/duckhunt_test.go b/plugins/duckhunt_test.go index 4f40836..231805a 100644 --- a/plugins/duckhunt_test.go +++ b/plugins/duckhunt_test.go @@ -46,7 +46,7 @@ func TestDuckHuntIncludesBefriendAlias(t *testing.T) { if !found { t.Fatal("expected !bef command alias") } - for _, command := range []string{"buy", "reload", "ammo"} { + for _, command := range []string{"shop", "store", "buy", "reload", "ammo"} { found = false for _, candidate := range plugin.Commands() { if candidate == command { @@ -63,6 +63,26 @@ func TestDuckHuntIncludesBefriendAlias(t *testing.T) { } } +func TestDuckHuntShopCatalog(t *testing.T) { + plugin := &DuckHunt{} + if err := plugin.Init(nil, nil); err != nil { + t.Fatalf("Init returned error: %v", err) + } + weapons := plugin.shopWeapons() + if len(weapons) != 3 { + t.Fatalf("shop has %d weapons, want 3", len(weapons)) + } + if weapon, ok := plugin.findWeapon("gun"); !ok || weapon.Key != "peashooter" { + t.Fatalf("generic gun alias resolved to %+v, want peashooter", weapon) + } + if weapons[1].MagazineSize <= weapons[0].MagazineSize { + t.Fatalf("Quacker Blaster should have a larger magazine: %+v", weapons) + } + if weapons[2].Damage <= weapons[0].Damage || weapons[2].Cost <= weapons[0].Cost { + t.Fatalf("Golden Wing should cost more and do more damage: %+v", weapons) + } +} + func TestDuckHuntSchedulesAfterActivityThreshold(t *testing.T) { plugin := &DuckHunt{} if err := plugin.Init(bot.PluginConfig{ @@ -197,13 +217,15 @@ func TestDuckHuntPlayerGearPersists(t *testing.T) { player.SpareMagazines = 2 player.Ammo = 3 player.Points = 99 + player.HasGun = true + player.Weapon = "quacker" plugin.savePlayerLocked("network", "#channel", "Alice", player) plugin.mu.Unlock() plugin.mu.Lock() loaded := plugin.loadPlayerLocked("network", "#channel", "alice") plugin.mu.Unlock() - if loaded.SpareMagazines != 2 || loaded.Ammo != 3 || loaded.Points != 99 { + if loaded.SpareMagazines != 2 || loaded.Ammo != 3 || loaded.Points != 99 || loaded.Weapon != "quacker" { t.Fatalf("loaded player = %+v, want persisted gear and points", loaded) } } From 33988baf20560818563c61530a98d16f4d08093e Mon Sep 17 00:00:00 2001 From: AK Date: Sun, 2 Aug 2026 05:57:51 -0700 Subject: [PATCH 3/3] fix: simplify Duck Hunt announcement --- docs/games.md | 8 +++++++- plugins/duckhunt.go | 21 ++++++++++----------- plugins/duckhunt_test.go | 22 +++++++++++++++++++--- plugins/formatting.go | 1 + 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/games.md b/docs/games.md index 6f0d2f2..3ab54e4 100644 --- a/docs/games.md +++ b/docs/games.md @@ -171,10 +171,16 @@ trust through repeated befriending attempts: ~~~text [Duck Hunt] · ° · ° · ° \_o< FLAP FLAP! -[Duck Hunt] \_o< GOLDEN DUCK QUACK! HP: 3 | Type !bang to shoot or !bef to befriend! +[Duck Hunt] \_o< QUACK! HP: 3 | Type !bang to shoot or !bef to befriend! username hit the GOLDEN DUCK for 1 damage! It has 2 HP left. +20 points ~~~ +The active-duck announcement stays compact for terminal clients: `\_o< QUACK!`. +When IRC colors are supported, the duck uses a soft tan approximation, green +head, and yellow bill. The `GOLDEN DUCK` label is reserved for hit, befriending, and +escape messages, where it is shown in yellow; it is not inserted into the duck +ASCII itself. + Before the duck appears, GoBot may send one randomly timed, colorized teaser such as a flight trail, quack, or flap. There is at most one teaser per hunt cycle, and `flavor_min_lead_seconds` keeps it separated from the actual duck diff --git a/plugins/duckhunt.go b/plugins/duckhunt.go index 994c1f6..4d47648 100644 --- a/plugins/duckhunt.go +++ b/plugins/duckhunt.go @@ -956,21 +956,20 @@ func randomDuckAnnouncement() string { } func randomDuckAnnouncementForState(state *duckHuntState) string { - ducks := []string{`\_o<`, `\_O<`, `\_0<`, `\_ö<`} - noises := []string{"quack!", "QUACK!", "QUACK! QUACK!", "QUACK! flap flap!"} - duck := ducks[rand.Intn(len(ducks))] - noise := noises[rand.Intn(len(noises))] + // Keep the active-duck announcement compact and recognizable in both + // graphical and terminal IRC clients. The body uses mIRC orange as a soft + // tan approximation, the head is green, and the bill is yellow. + duck := coloredDuckASCII() + noise := ircColor(ircCyan, "QUACK!") hp := 1 if state != nil && state.maxHP > 0 { hp = state.maxHP } - name := "DUCK" - color := ircCyan - if state != nil && state.golden { - name = "GOLDEN DUCK" - color = ircYellow - } - return fmt.Sprintf("%s %s %s %s HP: %d | Type %s to shoot or %s to befriend!", ircColor(ircGreen, "[Duck Hunt]"), ircColor(ircYellow, duck), ircColor(color, name), ircColor(ircCyan, noise), hp, ircColor(ircBold, "!bang"), ircColor(ircBold, "!bef")) + return fmt.Sprintf("%s %s %s HP: %d | Type %s to shoot or %s to befriend!", ircColor(ircGreen, "[Duck Hunt]"), duck, noise, hp, ircColor(ircBold, "!bang"), ircColor(ircBold, "!bef")) +} + +func coloredDuckASCII() string { + return ircColor(ircTan, `\_`) + ircColor(ircGreen, "o") + ircColor(ircYellow, "<") } func randomDuckEscape() string { diff --git a/plugins/duckhunt_test.go b/plugins/duckhunt_test.go index 231805a..7f8005e 100644 --- a/plugins/duckhunt_test.go +++ b/plugins/duckhunt_test.go @@ -83,6 +83,22 @@ func TestDuckHuntShopCatalog(t *testing.T) { } } +func TestDuckHuntAnnouncementKeepsDuckASCIICompact(t *testing.T) { + announcement := randomDuckAnnouncementForState(&duckHuntState{golden: true, maxHP: 3}) + if !strings.Contains(announcement, "QUACK!") { + t.Fatalf("announcement = %q, want QUACK!", announcement) + } + if !strings.Contains(announcement, "HP: 3") { + t.Fatalf("announcement = %q, want HP: 3", announcement) + } + if strings.Contains(announcement, "GOLDEN DUCK") { + t.Fatalf("announcement = %q, should not put GOLDEN DUCK in the duck body", announcement) + } + if !strings.Contains(announcement, "\\_") || !strings.Contains(announcement, "o") || !strings.Contains(announcement, "<") { + t.Fatalf("announcement = %q, want compact duck ASCII", announcement) + } +} + func TestDuckHuntSchedulesAfterActivityThreshold(t *testing.T) { plugin := &DuckHunt{} if err := plugin.Init(bot.PluginConfig{ @@ -243,10 +259,10 @@ func TestDuckHuntAnnouncementIncludesColorDuckAndQuack(t *testing.T) { } } -func TestDuckHuntGoldenAnnouncementIncludesHPAndActions(t *testing.T) { +func TestDuckHuntAnnouncementIncludesHPAndActions(t *testing.T) { announcement := randomDuckAnnouncementForState(&duckHuntState{golden: true, hp: 4, maxHP: 4}) - if !strings.Contains(announcement, "GOLDEN DUCK") || !strings.Contains(announcement, "HP: 4") { - t.Fatalf("unexpected golden announcement: %q", announcement) + if strings.Contains(announcement, "GOLDEN DUCK") || !strings.Contains(announcement, "HP: 4") { + t.Fatalf("unexpected announcement: %q", announcement) } if !strings.Contains(announcement, "!bang") || !strings.Contains(announcement, "!bef") { t.Fatalf("announcement does not provide actions: %q", announcement) diff --git a/plugins/formatting.go b/plugins/formatting.go index db7ad46..5c80c1c 100644 --- a/plugins/formatting.go +++ b/plugins/formatting.go @@ -9,6 +9,7 @@ const ( ircBold = "\x02" ircGreen = "\x0303" ircRed = "\x0304" + ircTan = "\x0307" // mIRC orange, a practical tan approximation ircCyan = "\x0311" ircYellow = "\x0308" )