From 3d6bc1a0661f16a87a07a8c84b36399be393565b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:47:49 -0700 Subject: [PATCH] Add WOTD language rotation, fancy word detection, arena improvements, and death consolidation - Rotate WOTD between Portuguese, French, and English daily with cognate filtering - Replace LLM-based fancy word detection with DreamDict frequency data (batch API + cache) - Track fancy word usage per user and add Wordsmith archetype - Restore full arena combat flavor text and add player miss actions - Consolidate death logic into Kill() method with 2-hour lockout - Fix arena death XP grant missing level-up check - Fix LLM wotd_used classification by injecting actual WOTD into prompt Co-Authored-By: Claude Opus 4.6 --- internal/db/db.go | 2 + internal/dreamclient/client.go | 26 ++ internal/plugin/adventure.go | 5 +- internal/plugin/adventure_arena.go | 6 +- internal/plugin/adventure_arena_combat.go | 90 ++++--- internal/plugin/adventure_character.go | 7 + internal/plugin/archetype.go | 18 +- internal/plugin/llm_passive.go | 58 +++-- internal/plugin/wotd.go | 277 +++++++++++++++------- 9 files changed, 350 insertions(+), 139 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index b4ec996..001c3f0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -73,6 +73,7 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE adventure_characters ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_inventory ADD COLUMN slot TEXT NOT NULL DEFAULT ''`, `ALTER TABLE adventure_inventory ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE user_stats ADD COLUMN fancy_words INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -243,6 +244,7 @@ CREATE TABLE IF NOT EXISTS user_stats ( total_emojis INTEGER DEFAULT 0, night_messages INTEGER DEFAULT 0, morning_messages INTEGER DEFAULT 0, + fancy_words INTEGER DEFAULT 0, updated_at INTEGER DEFAULT (unixepoch()) ); diff --git a/internal/dreamclient/client.go b/internal/dreamclient/client.go index 0e538fc..21c2cba 100644 --- a/internal/dreamclient/client.go +++ b/internal/dreamclient/client.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" ) @@ -189,6 +190,31 @@ func (c *Client) Frequency(word, lang string) (int, error) { return result.Frequency, nil } +// FrequencyBatch returns frequency scores for multiple words in a single request. +func (c *Client) FrequencyBatch(words []string, lang string) (map[string]int, error) { + u := c.baseURL + "/frequency/batch?" + url.Values{ + "words": {strings.Join(words, ",")}, + "lang": {lang}, + }.Encode() + resp, err := c.httpClient.Get(u) + if err != nil { + return nil, fmt.Errorf("dreamdict: frequency batch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("dreamdict: frequency batch: status %d", resp.StatusCode) + } + + var result struct { + Frequencies map[string]int `json:"frequencies"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("dreamdict: frequency batch: decode: %w", err) + } + return result.Frequencies, nil +} + // Health checks if the DreamDict service is reachable and returns stats. func (c *Client) Health() (*HealthResponse, error) { resp, err := c.httpClient.Get(c.baseURL + "/health") diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 7db6c90..9b1dd79 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -675,10 +675,7 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow)) } } else { - char.Alive = false - now := time.Now().UTC() - deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) - char.DeadUntil = &deadUntil + char.Kill() char.GrudgeLocation = loc.Name } } else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) { diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 647e659..8d3747d 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -534,13 +534,11 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c lostEarnings := run.Earnings - // Kill the character (locked out for 2 hours) - char.Alive = false + char.Kill() now := time.Now().UTC() - deadUntil := now.Add(2 * time.Hour) - char.DeadUntil = &deadUntil char.ArenaLosses++ char.CombatXP += arenaParticipationXP // +60 flat participation XP + checkAdvLevelUp(char, "combat") if err := saveAdvCharacter(char); err != nil { slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err) } diff --git a/internal/plugin/adventure_arena_combat.go b/internal/plugin/adventure_arena_combat.go index 6952a5e..479bfec 100644 --- a/internal/plugin/adventure_arena_combat.go +++ b/internal/plugin/adventure_arena_combat.go @@ -199,7 +199,12 @@ func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, pic if currentPlayerHP < 0 { currentPlayerHP = 0 } - r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg) + // Mix in player-miss actions (~30% of enemy_hit rounds) + if rand.IntN(100) < 30 { + r.Text = pickFrom(arenaPlayerMissActions, picker.playerMiss, dmg) + } else { + r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg) + } case "block": r.Text = pickFromNoFmt(arenaBlockActions, picker.block) @@ -297,6 +302,7 @@ func splitDamage(total, n int) []int { type actionPicker struct { enemy map[int]bool player map[int]bool + playerMiss map[int]bool block map[int]bool environment map[int]bool } @@ -305,6 +311,7 @@ func newActionPicker() *actionPicker { return &actionPicker{ enemy: make(map[int]bool), player: make(map[int]bool), + playerMiss: make(map[int]bool), block: make(map[int]bool), environment: make(map[int]bool), } @@ -407,12 +414,12 @@ func arenaLoseCloser(winnerName string, lastRound int) string { // Enemy actions — hit the player. %d is damage. var arenaEnemyActions = []string{ - "The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots.", - "The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity hurts more than the hit. %d damage.", + "The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots. They do not go with that top on this planet nor any other.", + "The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity of the move hurts more than the hit itself. %d damage.", "The enemy questions your life choices. You pause to genuinely reflect. They hit you during the pause. %d damage.", "The enemy delivers a full monologue. You listen to the whole thing. It was actually pretty good. %d damage from the time lost.", - "The enemy compliments you unexpectedly. You thank them. They snicker. %d damage.", - "The enemy points at something behind you. You don't fall for it. They throw a projectile that bounces off the wall and hits you in the back of the head. %d damage.", + "The enemy compliments you unexpectedly. You thank them. They snicker because you actually believed them and revealed to everyone that you're somehow a bigger buffoon than previously known. %d damage.", + "The enemy points at something behind you. You don't fall for it. They throw a projectile which bounces off the wall and hits you in the back of the head. What an amazing trick shot. %d damage. The crowd roars in laughter at the spectacle. But mostly at you.", "The enemy pulls out their phone and starts filming. You perform for the camera. This was a mistake. %d damage.", "The enemy sneezes directly in your face. You lose your turn being disgusted. %d damage while you process this.", "The enemy whispers something. You lean in to hear it. %d damage. There was nothing worth hearing.", @@ -422,57 +429,76 @@ var arenaEnemyActions = []string{ "The enemy yawns mid-fight. Not performatively. Genuinely. %d damage while you process the disrespect.", "The enemy pauses to stretch before attacking. You wait. You don't know why you waited. %d damage when they finish.", "The enemy hits you with the flat of their blade. A choice. A message. %d damage. The message is received.", - "The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. %d damage.", + "The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. This was the plan. %d damage.", "The enemy sighs before hitting you. Like they had somewhere better to be. %d damage.", "The enemy recounts a mildly interesting story mid-fight. You get drawn in. %d damage before the ending, which was not worth it.", "The enemy raises one eyebrow at you and then attacks. The eyebrow did more damage than the hit. %d damage total.", - "The enemy adjusts their grip, rolls their shoulders, and hits you with the bare minimum of effort. %d damage.", + "The enemy adjusts their grip, rolls their shoulders, and hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.", } -// Player actions — hit the enemy. %d is damage. +// Player actions — hit the enemy. %d is damage to enemy. var arenaPlayerHitActions = []string{ - "You make a joke using a painfully dated reference. While the enemy ponders what on earth you could mean, you strike. %d damage.", + "You make a joke using a painfully dated reference. While the enemy stands there pondering what on earth you could possibly be referring to, you seize the opportunity and land a critical hit. %d damage. Your jokes are always great at leaving people dazed and confused.", "You attempt a battle cry. It comes out as a question. The enemy is briefly confused. You hit them for %d damage before they recover.", "You wind up for a big hit and connect for %d damage. You pulled something. The enemy doesn't know this yet.", "You hit the enemy for %d damage. They seem fine. You are less fine about this than they are.", "You connect cleanly for %d damage and immediately look at your hand like you're surprised it worked. You were.", - "You score a clean hit for %d damage and immediately start explaining to no one how you did that. Nobody asked.", + "You score a clean hit for %d damage and immediately start explaining to no one in particular how you did that. Nobody asked. The fight is still happening.", "You land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.", } +// Player actions — player's turn goes wrong. %d is damage to player. +var arenaPlayerMissActions = []string{ + "You reach for your weapon and grab the wrong item. You are holding a receipt. The enemy hits you for %d damage. You find this receipt later and it's actually useful.", + "You make prolonged eye contact with a spectator. It goes on too long. The enemy hits you for %d damage. The spectator looks away first.", + "Your shoelace comes untied. You are wearing boots. You address this. The enemy does not wait. %d damage.", + "You sneeze at a critical moment. The enemy respectfully waits. Then hits you for %d damage. There was no respect involved actually.", + "You perform a move you saw in a film once. It does not work like in the film. %d damage. The physics were always wrong in that film.", + "You get distracted by a food vendor passing the arena perimeter. So does the enemy. You recover second. %d damage.", + "You attempt to intimidate the enemy. They laugh. Genuinely. This is worse than if they hadn't. You take %d damage from the experience.", + "You slip on something. There is nothing to slip on. %d damage. The arena floor is flat and dry. You will be thinking about this.", + "You decide mid-swing to do something different. The original plan was better. %d damage.", + "You attempt a combo you've been mentally rehearsing for weeks. It goes fine until the third move. %d damage.", + "You feint left. The enemy doesn't move. You feint right. They still don't move. You just stand there feinting at someone who is not playing along. The enemy hits you. %d damage.", + "You remember reading something about fighting once. You implement it. It was about chess. %d damage.", + "You close your eyes for the strike because it feels more dramatic. You miss. The enemy doesn't. %d damage.", + "You decide this is the moment for something new. It is not the moment for something new. %d damage. File this under lessons.", +} + // Block/dodge actions — no damage. var arenaBlockActions = []string{ "You swing with conviction. The enemy sidesteps it with the energy of someone who has somewhere else to be. Nothing happens. You both reset.", "The enemy lunges. You step aside. They continue past you for several feet and have to walk back. The pause is awkward for everyone.", - "You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally.", + "You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally. You don't know their relationship so it probably did, but also you were faster.", "The enemy's attack grazes you but doesn't connect. They seem more annoyed by this than you are relieved.", - "You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.", - "The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked.", - "You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it.", - "The enemy blocks your poor-timed strike with their forearm. This speaks more about your striking abilities than their forearm.", - "You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The pillar gets no credit.", - "The enemy telegraphs the attack so clearly that you block it before they've finished committing. They look briefly embarrassed.", - "You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional.", - "The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena.", - "You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other.", - "The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects.", - "You sidestep a strike that wasn't aimed at you. The enemy had already redirected. Both end up slightly confused.", + "You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was. Then the fight continues.", + "The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked. You will be thinking about how unnecessary it was.", + "You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it. It was still a good parry.", + "The enemy blocks your hilariously poor-timed strike with their forearm. This speaks less about the strength of their forearm and much more so about the pathetic nature of your striking abilities.", + "You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The enemy didn't do that. The pillar gets no credit either.", + "The enemy telegraphs the attack so clearly that you block it before they've finished committing to it. They look briefly embarrassed. They recover. The fight continues.", + "You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional. The enemy hesitates, which was also not intentional. Nothing lands.", + "The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena. They retrieve a backup weapon from somewhere. Nobody asks where they got it.", + "You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other. Someone has to move first. It's them. The fight continues.", + "The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects. You land. The fight continues.", + "You sidestep a strike that wasn't aimed at you. The enemy had already redirected. You both end up slightly confused about where the other one is. The round resolves without damage.", + "A referee walks through the arena on the way to somewhere else. Eye contact is made with both fighters. They keep walking. There is a beat. The fight resumes.", } // Environmental actions — damage to player. %d is damage. var arenaEnvironmentalActions = []string{ - "Your mother calls in the middle of battle asking about grandchildren. The enemy hits you for %d damage while you answer on speakerphone.", - "A bird lands between you and the enemy. Both stop. The bird leaves. The enemy recovers first. %d damage.", - "A spectator is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.", - "The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage.", - "An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. The enemy hits you for %d damage during this.", + "Your mother calls in the middle of battle asking when you're giving her grandchildren. The enemy hits you for %d damage while you work out how to answer that on speakerphone.", + "A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.", + "A spectator in the front row is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.", + "The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage. The announcer mispronounces it again.", + "An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. Mutual acknowledgment. The enemy hits you for %d damage during this social transaction.", "Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.", "A cloud passes in front of the sun at the wrong moment. %d damage. The cloud did not mean anything by it.", - "The arena's background music cuts out unexpectedly. The silence is louder than the fight. %d damage in the disorientation.", - "The arena PA crackles and announces something completely unrelated. You both look up. The enemy looks back down first. %d damage.", + "The arena's background music cuts out unexpectedly. The silence is louder than the fight. The enemy hits you for %d damage in the disorientation.", + "The arena PA crackles and announces something completely unrelated to your fight. You both look up. The enemy looks back down first. %d damage.", "Something falls from the spectator area. Nobody claims it. You both look at it. The enemy decides faster. %d damage.", - "A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. The enemy uses the reset better. %d damage.", - "The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round working out if that changes anything. %d damage.", + "A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. You both needed that break more than you'd like to admit. The enemy uses the reset better. %d damage.", + "The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round trying to work out if that changes anything. It does not. %d damage while you calculate.", "The arena sells a limited merch item at exactly this moment. The announcement is enthusiastic. You are briefly curious. %d damage.", - "The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear. %d damage.", + "The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear from the enemy's corner. %d damage.", } diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index e0f289f..1bd15a4 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -195,6 +195,13 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool { return time.Since(*c.DeathReprieveLast) >= 168*time.Hour } +// Kill marks the character as dead with a 2-hour respawn timer. +func (c *AdventureCharacter) Kill() { + c.Alive = false + deadUntil := time.Now().UTC().Add(2 * time.Hour) + c.DeadUntil = &deadUntil +} + // ── Equipment Score ────────────────────────────────────────────────────────── func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int { diff --git a/internal/plugin/archetype.go b/internal/plugin/archetype.go index da1482d..7260beb 100644 --- a/internal/plugin/archetype.go +++ b/internal/plugin/archetype.go @@ -68,6 +68,9 @@ const ( thAdvDiverseMaxShare = 40 thGearheadMinMasterwork = 3 + // Communication (vocabulary) + thWordsmithMinFancyWords = 10 + // Social thPatronMinRepGiven = 5 thPatronRatioMultiplier = 2 @@ -86,6 +89,7 @@ var archetypeFlavors = map[string]string{ "Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.", "Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.", "Linkmaster": "The community's unofficial curator. Their tab count is not your business.", + "Wordsmith": "Uses words most people have to look up. The thesaurus fears them.", // Temporal "Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.", @@ -226,6 +230,7 @@ type userData struct { totalEmojis int nightMsgs int morningMsgs int + fancyWords int // sentiment_stats sentPositive int @@ -273,10 +278,12 @@ func loadUserData(d *sql.DB, userID string) userData { // user_stats d.QueryRow(`SELECT total_messages, total_words, total_links, total_images, - total_questions, total_exclamations, total_emojis, night_messages, morning_messages + total_questions, total_exclamations, total_emojis, night_messages, morning_messages, + COALESCE(fancy_words, 0) FROM user_stats WHERE user_id = ?`, userID).Scan( &u.totalMsgs, &u.totalWords, &u.totalLinks, &u.totalImages, - &u.totalQuestions, &u.totalExcl, &u.totalEmojis, &u.nightMsgs, &u.morningMsgs) + &u.totalQuestions, &u.totalExcl, &u.totalEmojis, &u.nightMsgs, &u.morningMsgs, + &u.fancyWords) // sentiment_stats d.QueryRow(`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0) @@ -438,6 +445,13 @@ func evaluateArchetypes(u userData, pct communityPercentiles) []archetypeResult }) } + if u.fancyWords >= thWordsmithMinFancyWords { + results = append(results, archetypeResult{ + Name: "Wordsmith", Category: "Communication", + SignalScore: clampSignal(float64(u.fancyWords) / float64(thWordsmithMinFancyWords*3)), + }) + } + // ── Temporal ── if u.totalMsgs >= thNightOwlMinMsgs { diff --git a/internal/plugin/llm_passive.go b/internal/plugin/llm_passive.go index 2b6808d..b151813 100644 --- a/internal/plugin/llm_passive.go +++ b/internal/plugin/llm_passive.go @@ -157,6 +157,18 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error { return nil } + // Check for fancy words on every message (independent of LLM sampling) + if p.dict != nil { + go func() { + if p.hasFancyWord(ctx.Body) { + _ = p.SendReact(ctx.RoomID, ctx.EventID, "\U0001f393") // 🎓 graduation cap + db.Exec("llm: track fancy word", + `UPDATE user_stats SET fancy_words = fancy_words + 1 WHERE user_id = ?`, + string(ctx.Sender)) + } + }() + } + // Pre-filter: only classify messages that match certain criteria var fmtBody string if ctx.Event != nil { @@ -464,12 +476,6 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { if result.WOTDUsed { _ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book } - // Check for rare/sophisticated words via DreamDict frequency data - if p.dict != nil { - if p.hasFancyWord(item.Body) { - _ = p.SendReact(item.RoomID, item.EventID, "\U0001f393") // 🎓 graduation cap - } - } if result.GratitudeTarget != "" { _ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart } @@ -567,26 +573,48 @@ const fancyWordMaxFreq = 100 // fancyWordMinLength filters out short words that may just be uncommon abbreviations. const fancyWordMinLength = 6 +// fancyWordCache caches frequency lookups to avoid repeated requests. +var fancyWordCache sync.Map // word (string) -> freq (int) + // hasFancyWord checks if any word in the message is a rare/sophisticated English word -// by looking up its frequency score in DreamDict. +// by looking up frequency scores in DreamDict via a single batch request. func (p *LLMPassivePlugin) hasFancyWord(message string) bool { - words := strings.Fields(strings.ToLower(message)) - for _, w := range words { - // Strip punctuation from edges + // Extract candidate words + var candidates []string + var cached []string + for _, w := range strings.Fields(strings.ToLower(message)) { w = strings.Trim(w, ".,!?;:\"'()[]{}…—–-") if len(w) < fancyWordMinLength { continue } - // Skip URLs and mentions if strings.Contains(w, "://") || strings.HasPrefix(w, "@") { continue } - freq, err := p.dict.Frequency(w, "en") - if err != nil { + // Check cache first + if freq, ok := fancyWordCache.Load(w); ok { + if f := freq.(int); f > 0 && f <= fancyWordMaxFreq { + return true + } + cached = append(cached, w) continue } - // freq == 0 means unknown word (not in dictionary), skip it. - // Low but nonzero frequency means rare but real word. + candidates = append(candidates, w) + } + + if len(candidates) == 0 { + return false + } + + // Single batch request for all uncached words + freqs, err := p.dict.FrequencyBatch(candidates, "en") + if err != nil { + slog.Debug("hasFancyWord: batch lookup failed", "err", err) + return false + } + + for _, w := range candidates { + freq := freqs[w] // 0 if not in result (unknown word) + fancyWordCache.Store(w, freq) if freq > 0 && freq <= fancyWordMaxFreq { return true } diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index 6472a8a..26c5a49 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -37,7 +37,7 @@ func (p *WOTDPlugin) Name() string { return "wotd" } func (p *WOTDPlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "wotd", Description: "Show today's Palavra do Dia", Usage: "!wotd [force]", Category: "Lookup & Reference"}, + {Name: "wotd", Description: "Show today's Word of the Day", Usage: "!wotd [force]", Category: "Lookup & Reference"}, } } @@ -70,8 +70,85 @@ func (p *WOTDPlugin) OnMessage(ctx MessageContext) error { return nil } -// Prefetch picks today's Palavra do Dia from DreamDict and stores it in the database. -// Prefers pt-PT words with at least one definition and one English translation. +// pickWord attempts to find a suitable word in the given language. +// Returns word, definition, partOfSpeech, translationsJSON. +// For non-English languages, requires English translations and filters cognates. +// For all languages, fetches synonyms and cross-translations. +func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) { + // Define which other languages to translate into + type transTarget struct { + lang string + key string + } + var targets []transTarget + switch lang { + case "pt-PT": + targets = []transTarget{{"en", "en"}, {"fr", "fr"}} + case "fr": + targets = []transTarget{{"en", "en"}, {"pt-PT", "pt-PT"}} + case "en": + targets = []transTarget{{"fr", "fr"}, {"pt-PT", "pt-PT"}} + } + + for attempt := 0; attempt < 100; attempt++ { + candidate, err := p.dict.RandomWord(lang, "", 4, 14) + if err != nil { + continue + } + + defs, err := p.dict.Define(candidate, lang) + if err != nil || len(defs) == 0 { + continue + } + + transMap := map[string][]string{} + valid := true + + for _, t := range targets { + trans, _ := p.dict.Translate(candidate, lang, t.lang) + if len(trans) == 0 { + valid = false + break + } + transMap[t.key] = trans + } + if !valid { + continue + } + + // For non-English words, filter cognates where the English translation + // is just the word itself + if lang != "en" { + if enTrans, ok := transMap["en"]; ok { + meaningfulEn := false + for _, t := range enTrans { + if !strings.EqualFold(t, candidate) { + meaningfulEn = true + break + } + } + if !meaningfulEn { + continue + } + } + } + + // Fetch synonyms in the word's own language + synonyms, _ := p.dict.Synonyms(candidate, lang) + if len(synonyms) > 0 { + transMap["syn"] = synonyms + } + + var translationsJSON string + if data, err := json.Marshal(transMap); err == nil { + translationsJSON = string(data) + } + return candidate, defs[0].Gloss, defs[0].POS, translationsJSON + } + return "", "", "", "" +} + +// Prefetch picks today's Word of the Day from DreamDict and stores it in the database. func (p *WOTDPlugin) Prefetch() error { return p.prefetchWord(false) } @@ -94,68 +171,41 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { } } - // Pick a random pt-PT word with definitions and both English + French translations. - // Fall back to an English word if no valid pt-PT candidate is found. - var word, definition, partOfSpeech, translationsJSON string + // Rotate language by day: pt-PT, fr, en + langs := []string{"pt-PT", "fr", "en"} + dayOfYear := time.Now().UTC().YearDay() + lang := langs[dayOfYear%len(langs)] - for attempt := 0; attempt < 100; attempt++ { - candidate, err := p.dict.RandomWord("pt-PT", "", 4, 14) - if err != nil { - slog.Warn("wotd: random word attempt failed", "err", err) - continue + word, definition, partOfSpeech, translationsJSON := p.pickWord(lang) + + // Fallback through other languages if primary fails + if word == "" { + for _, fallback := range langs { + if fallback == lang { + continue + } + slog.Warn("wotd: failed to find word", "lang", lang, "fallback", fallback) + word, definition, partOfSpeech, translationsJSON = p.pickWord(fallback) + if word != "" { + lang = fallback + break + } } - - defs, err := p.dict.Define(candidate, "pt-PT") - if err != nil || len(defs) == 0 { - continue - } - - // Require at least one English and one French translation - enTrans, _ := p.dict.Translate(candidate, "pt-PT", "en") - if len(enTrans) == 0 { - continue - } - - frTrans, _ := p.dict.Translate(candidate, "pt-PT", "fr") - if len(frTrans) == 0 { - continue - } - - word = candidate - definition = defs[0].Gloss - partOfSpeech = defs[0].POS - - // Store translations as JSON in the example column - transMap := map[string][]string{} - transMap["en"] = enTrans - transMap["fr"] = frTrans - if data, err := json.Marshal(transMap); err == nil { - translationsJSON = string(data) - } - break } - // Fallback: pick an English word if pt-PT search somehow fails if word == "" { - slog.Warn("wotd: pt-PT search failed, falling back to English word") - for { - candidate, err := p.dict.RandomWord("en", "", 4, 14) - if err != nil { - continue - } - defs, err := p.dict.Define(candidate, "en") - if err != nil || len(defs) == 0 { - continue - } - word = candidate - definition = defs[0].Gloss - partOfSpeech = defs[0].POS - transMap := map[string][]string{"en": {candidate}} - if data, err := json.Marshal(transMap); err == nil { - translationsJSON = string(data) - } - break - } + return fmt.Errorf("wotd: failed to find a word in any language") + } + + // Store the language in the translations JSON so formatWOTD knows which language was picked + var transMap map[string][]string + json.Unmarshal([]byte(translationsJSON), &transMap) + if transMap == nil { + transMap = map[string][]string{} + } + transMap["_lang"] = []string{lang} + if data, err := json.Marshal(transMap); err == nil { + translationsJSON = string(data) } if force { @@ -182,8 +232,9 @@ func (p *WOTDPlugin) prefetchWord(force bool) error { return nil } -// hasEnglishTranslation checks whether the stored translations JSON includes English. -func hasEnglishTranslation(translationsJSON string) bool { +// hasTranslations checks whether the stored translations JSON has at least one translation +// (excluding metadata keys like _lang and syn). +func hasTranslations(translationsJSON string) bool { if translationsJSON == "" { return false } @@ -191,7 +242,15 @@ func hasEnglishTranslation(translationsJSON string) bool { if json.Unmarshal([]byte(translationsJSON), &transMap) != nil { return false } - return len(transMap["en"]) > 0 + for k, v := range transMap { + if strings.HasPrefix(k, "_") || k == "syn" { + continue + } + if len(v) > 0 { + return true + } + } + return false } // PostWOTD posts today's Word of the Day to the given room and marks it as posted. @@ -226,7 +285,7 @@ func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error { } // Re-prefetch if stored word is missing English translation - if !hasEnglishTranslation(example) { + if !hasTranslations(example) { slog.Warn("wotd: stored word missing English translation, forcing re-prefetch", "word", word) if err := p.prefetchWord(true); err != nil { return fmt.Errorf("wotd: re-prefetch failed: %w", err) @@ -273,7 +332,7 @@ func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error { } // Re-prefetch if stored word is missing English translation - if !hasEnglishTranslation(example) { + if !hasTranslations(example) { slog.Warn("wotd: stored word missing English translation, forcing re-prefetch", "word", word) if err := p.prefetchWord(true); err != nil { slog.Error("wotd: re-prefetch failed", "err", err) @@ -294,12 +353,47 @@ func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, msg) } +var wotdHeaders = map[string]string{ + "pt-PT": "📖 **Palavra do Dia** — %s\n\n", + "fr": "📖 **Mot du Jour** — %s\n\n", + "en": "📖 **Word of the Day** — %s\n\n", +} + +var wotdFlags = map[string]string{ + "pt-PT": "🇵🇹", + "fr": "🇫🇷", + "en": "🇬🇧", +} + +var wotdSynonymLabels = map[string]string{ + "pt-PT": "Sinónimos", + "fr": "Synonymes", + "en": "Synonyms", +} + func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON string) string { now := time.Now().UTC() dateStr := now.Format("Monday, 2 January 2006") + // Determine the source language from stored metadata + lang := "pt-PT" // default for backwards compat with old entries + var transMap map[string][]string + if translationsJSON != "" { + json.Unmarshal([]byte(translationsJSON), &transMap) + } + if transMap != nil { + if l, ok := transMap["_lang"]; ok && len(l) > 0 { + lang = l[0] + } + } + var sb strings.Builder - sb.WriteString(fmt.Sprintf("📖 **Palavra do Dia** — %s\n\n", dateStr)) + + header := wotdHeaders[lang] + if header == "" { + header = "📖 **Word of the Day** — %s\n\n" + } + sb.WriteString(fmt.Sprintf(header, dateStr)) if partOfSpeech != "" { sb.WriteString(fmt.Sprintf("✨ **%s** (%s)\n\n", word, partOfSpeech)) @@ -307,34 +401,53 @@ func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON sb.WriteString(fmt.Sprintf("✨ **%s**\n\n", word)) } - // Portuguese definition + // Definition in the word's own language + flag := wotdFlags[lang] + if flag == "" { + flag = lang + } if definition != "" { - sb.WriteString(fmt.Sprintf("🇵🇹 pt-PT\n %s\n\n", definition)) + sb.WriteString(fmt.Sprintf("%s %s\n %s\n", flag, lang, definition)) } - // Parse translations from JSON stored in example column - if translationsJSON != "" { - var transMap map[string][]string - if json.Unmarshal([]byte(translationsJSON), &transMap) == nil { - if enTrans, ok := transMap["en"]; ok && len(enTrans) > 0 { - display := enTrans - if len(display) > 5 { - display = display[:5] - } - sb.WriteString(fmt.Sprintf("🇬🇧 en\n %s\n\n", strings.Join(display, ", "))) + // Synonyms + if transMap != nil { + if syns, ok := transMap["syn"]; ok && len(syns) > 0 { + display := syns + if len(display) > 5 { + display = display[:5] } - if frTrans, ok := transMap["fr"]; ok && len(frTrans) > 0 { - display := frTrans + label := wotdSynonymLabels[lang] + if label == "" { + label = "Synonyms" + } + sb.WriteString(fmt.Sprintf(" %s: %s\n", label, strings.Join(display, ", "))) + } + sb.WriteString("\n") + + // Translations into other languages + for _, tLang := range []string{"en", "fr", "pt-PT"} { + if tLang == lang { + continue + } + if trans, ok := transMap[tLang]; ok && len(trans) > 0 { + display := trans if len(display) > 5 { display = display[:5] } - sb.WriteString(fmt.Sprintf("🇫🇷 fr\n %s\n\n", strings.Join(display, ", "))) + tFlag := wotdFlags[tLang] + if tFlag == "" { + tFlag = tLang + } + sb.WriteString(fmt.Sprintf("%s %s\n %s\n\n", tFlag, tLang, strings.Join(display, ", "))) } } + } else { + sb.WriteString("\n") } sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n") - sb.WriteString(fmt.Sprintf("Learn more: `!define %s pt-PT`\n", word)) + sb.WriteString(fmt.Sprintf("Learn more: `!define %s %s`\n", word, lang)) sb.WriteString("Use this word in a message today to earn 25 XP!") return sb.String() }