mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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_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 slot TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE adventure_inventory ADD COLUMN skill_source 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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
@@ -243,6 +244,7 @@ CREATE TABLE IF NOT EXISTS user_stats (
|
|||||||
total_emojis INTEGER DEFAULT 0,
|
total_emojis INTEGER DEFAULT 0,
|
||||||
night_messages INTEGER DEFAULT 0,
|
night_messages INTEGER DEFAULT 0,
|
||||||
morning_messages INTEGER DEFAULT 0,
|
morning_messages INTEGER DEFAULT 0,
|
||||||
|
fancy_words INTEGER DEFAULT 0,
|
||||||
updated_at INTEGER DEFAULT (unixepoch())
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,6 +190,31 @@ func (c *Client) Frequency(word, lang string) (int, error) {
|
|||||||
return result.Frequency, nil
|
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.
|
// Health checks if the DreamDict service is reachable and returns stats.
|
||||||
func (c *Client) Health() (*HealthResponse, error) {
|
func (c *Client) Health() (*HealthResponse, error) {
|
||||||
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
||||||
|
|||||||
@@ -675,10 +675,7 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
|||||||
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
|
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
char.Alive = false
|
char.Kill()
|
||||||
now := time.Now().UTC()
|
|
||||||
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
|
||||||
char.DeadUntil = &deadUntil
|
|
||||||
char.GrudgeLocation = loc.Name
|
char.GrudgeLocation = loc.Name
|
||||||
}
|
}
|
||||||
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
|
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
|
||||||
|
|||||||
@@ -534,13 +534,11 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
|||||||
|
|
||||||
lostEarnings := run.Earnings
|
lostEarnings := run.Earnings
|
||||||
|
|
||||||
// Kill the character (locked out for 2 hours)
|
char.Kill()
|
||||||
char.Alive = false
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
deadUntil := now.Add(2 * time.Hour)
|
|
||||||
char.DeadUntil = &deadUntil
|
|
||||||
char.ArenaLosses++
|
char.ArenaLosses++
|
||||||
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
||||||
|
checkAdvLevelUp(char, "combat")
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
|
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,12 @@ func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, pic
|
|||||||
if currentPlayerHP < 0 {
|
if currentPlayerHP < 0 {
|
||||||
currentPlayerHP = 0
|
currentPlayerHP = 0
|
||||||
}
|
}
|
||||||
|
// 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)
|
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
|
||||||
|
}
|
||||||
|
|
||||||
case "block":
|
case "block":
|
||||||
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
|
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
|
||||||
@@ -297,6 +302,7 @@ func splitDamage(total, n int) []int {
|
|||||||
type actionPicker struct {
|
type actionPicker struct {
|
||||||
enemy map[int]bool
|
enemy map[int]bool
|
||||||
player map[int]bool
|
player map[int]bool
|
||||||
|
playerMiss map[int]bool
|
||||||
block map[int]bool
|
block map[int]bool
|
||||||
environment map[int]bool
|
environment map[int]bool
|
||||||
}
|
}
|
||||||
@@ -305,6 +311,7 @@ func newActionPicker() *actionPicker {
|
|||||||
return &actionPicker{
|
return &actionPicker{
|
||||||
enemy: make(map[int]bool),
|
enemy: make(map[int]bool),
|
||||||
player: make(map[int]bool),
|
player: make(map[int]bool),
|
||||||
|
playerMiss: make(map[int]bool),
|
||||||
block: make(map[int]bool),
|
block: make(map[int]bool),
|
||||||
environment: 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.
|
// Enemy actions — hit the player. %d is damage.
|
||||||
var arenaEnemyActions = []string{
|
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 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 hurts more than the hit. %d damage.",
|
"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 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 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 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 that bounces off the wall and hits you in the back of the head. %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 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 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.",
|
"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 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 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 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 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 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 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{
|
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 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 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 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 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.",
|
"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.
|
// Block/dodge actions — no damage.
|
||||||
var arenaBlockActions = []string{
|
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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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 poor-timed strike with their forearm. This speaks more about your striking abilities than their forearm.",
|
"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 pillar gets no credit.",
|
"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. They look briefly embarrassed.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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. Both end up slightly confused.",
|
"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.
|
// Environmental actions — damage to player. %d is damage.
|
||||||
var arenaEnvironmentalActions = []string{
|
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.",
|
"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 stop. The bird leaves. The enemy recovers first. %d damage.",
|
"A bird lands between you and the enemy. Both combatants 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.",
|
"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 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. The enemy hits you for %d damage during this.",
|
"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.",
|
"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.",
|
"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'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. You both look up. The enemy looks back down first. %d damage.",
|
"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.",
|
"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.",
|
"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 working out if that changes anything. %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 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.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool {
|
|||||||
return time.Since(*c.DeathReprieveLast) >= 168*time.Hour
|
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 ──────────────────────────────────────────────────────────
|
// ── Equipment Score ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
|
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ const (
|
|||||||
thAdvDiverseMaxShare = 40
|
thAdvDiverseMaxShare = 40
|
||||||
thGearheadMinMasterwork = 3
|
thGearheadMinMasterwork = 3
|
||||||
|
|
||||||
|
// Communication (vocabulary)
|
||||||
|
thWordsmithMinFancyWords = 10
|
||||||
|
|
||||||
// Social
|
// Social
|
||||||
thPatronMinRepGiven = 5
|
thPatronMinRepGiven = 5
|
||||||
thPatronRatioMultiplier = 2
|
thPatronRatioMultiplier = 2
|
||||||
@@ -86,6 +89,7 @@ var archetypeFlavors = map[string]string{
|
|||||||
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
|
"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.",
|
"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.",
|
"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
|
// Temporal
|
||||||
"Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.",
|
"Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.",
|
||||||
@@ -226,6 +230,7 @@ type userData struct {
|
|||||||
totalEmojis int
|
totalEmojis int
|
||||||
nightMsgs int
|
nightMsgs int
|
||||||
morningMsgs int
|
morningMsgs int
|
||||||
|
fancyWords int
|
||||||
|
|
||||||
// sentiment_stats
|
// sentiment_stats
|
||||||
sentPositive int
|
sentPositive int
|
||||||
@@ -273,10 +278,12 @@ func loadUserData(d *sql.DB, userID string) userData {
|
|||||||
|
|
||||||
// user_stats
|
// user_stats
|
||||||
d.QueryRow(`SELECT total_messages, total_words, total_links, total_images,
|
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(
|
FROM user_stats WHERE user_id = ?`, userID).Scan(
|
||||||
&u.totalMsgs, &u.totalWords, &u.totalLinks, &u.totalImages,
|
&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
|
// sentiment_stats
|
||||||
d.QueryRow(`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0)
|
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 ──
|
// ── Temporal ──
|
||||||
|
|
||||||
if u.totalMsgs >= thNightOwlMinMsgs {
|
if u.totalMsgs >= thNightOwlMinMsgs {
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return nil
|
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
|
// Pre-filter: only classify messages that match certain criteria
|
||||||
var fmtBody string
|
var fmtBody string
|
||||||
if ctx.Event != nil {
|
if ctx.Event != nil {
|
||||||
@@ -464,12 +476,6 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
if result.WOTDUsed {
|
if result.WOTDUsed {
|
||||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book
|
_ = 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 != "" {
|
if result.GratitudeTarget != "" {
|
||||||
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
|
_ = 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.
|
// fancyWordMinLength filters out short words that may just be uncommon abbreviations.
|
||||||
const fancyWordMinLength = 6
|
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
|
// 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 {
|
func (p *LLMPassivePlugin) hasFancyWord(message string) bool {
|
||||||
words := strings.Fields(strings.ToLower(message))
|
// Extract candidate words
|
||||||
for _, w := range words {
|
var candidates []string
|
||||||
// Strip punctuation from edges
|
var cached []string
|
||||||
|
for _, w := range strings.Fields(strings.ToLower(message)) {
|
||||||
w = strings.Trim(w, ".,!?;:\"'()[]{}…—–-")
|
w = strings.Trim(w, ".,!?;:\"'()[]{}…—–-")
|
||||||
if len(w) < fancyWordMinLength {
|
if len(w) < fancyWordMinLength {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip URLs and mentions
|
|
||||||
if strings.Contains(w, "://") || strings.HasPrefix(w, "@") {
|
if strings.Contains(w, "://") || strings.HasPrefix(w, "@") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
freq, err := p.dict.Frequency(w, "en")
|
// Check cache first
|
||||||
if err != nil {
|
if freq, ok := fancyWordCache.Load(w); ok {
|
||||||
|
if f := freq.(int); f > 0 && f <= fancyWordMaxFreq {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cached = append(cached, w)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// freq == 0 means unknown word (not in dictionary), skip it.
|
candidates = append(candidates, w)
|
||||||
// Low but nonzero frequency means rare but real word.
|
}
|
||||||
|
|
||||||
|
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 {
|
if freq > 0 && freq <= fancyWordMaxFreq {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (p *WOTDPlugin) Name() string { return "wotd" }
|
|||||||
|
|
||||||
func (p *WOTDPlugin) Commands() []CommandDef {
|
func (p *WOTDPlugin) Commands() []CommandDef {
|
||||||
return []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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch picks today's Palavra do Dia from DreamDict and stores it in the database.
|
// pickWord attempts to find a suitable word in the given language.
|
||||||
// Prefers pt-PT words with at least one definition and one English translation.
|
// 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 {
|
func (p *WOTDPlugin) Prefetch() error {
|
||||||
return p.prefetchWord(false)
|
return p.prefetchWord(false)
|
||||||
}
|
}
|
||||||
@@ -94,69 +171,42 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick a random pt-PT word with definitions and both English + French translations.
|
// Rotate language by day: pt-PT, fr, en
|
||||||
// Fall back to an English word if no valid pt-PT candidate is found.
|
langs := []string{"pt-PT", "fr", "en"}
|
||||||
var word, definition, partOfSpeech, translationsJSON string
|
dayOfYear := time.Now().UTC().YearDay()
|
||||||
|
lang := langs[dayOfYear%len(langs)]
|
||||||
|
|
||||||
for attempt := 0; attempt < 100; attempt++ {
|
word, definition, partOfSpeech, translationsJSON := p.pickWord(lang)
|
||||||
candidate, err := p.dict.RandomWord("pt-PT", "", 4, 14)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("wotd: random word attempt failed", "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
defs, err := p.dict.Define(candidate, "pt-PT")
|
// Fallback through other languages if primary fails
|
||||||
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 == "" {
|
if word == "" {
|
||||||
slog.Warn("wotd: pt-PT search failed, falling back to English word")
|
for _, fallback := range langs {
|
||||||
for {
|
if fallback == lang {
|
||||||
candidate, err := p.dict.RandomWord("en", "", 4, 14)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
defs, err := p.dict.Define(candidate, "en")
|
slog.Warn("wotd: failed to find word", "lang", lang, "fallback", fallback)
|
||||||
if err != nil || len(defs) == 0 {
|
word, definition, partOfSpeech, translationsJSON = p.pickWord(fallback)
|
||||||
continue
|
if word != "" {
|
||||||
}
|
lang = fallback
|
||||||
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if word == "" {
|
||||||
|
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 {
|
if force {
|
||||||
// Delete existing entry for today so the INSERT below replaces it
|
// Delete existing entry for today so the INSERT below replaces it
|
||||||
@@ -182,8 +232,9 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasEnglishTranslation checks whether the stored translations JSON includes English.
|
// hasTranslations checks whether the stored translations JSON has at least one translation
|
||||||
func hasEnglishTranslation(translationsJSON string) bool {
|
// (excluding metadata keys like _lang and syn).
|
||||||
|
func hasTranslations(translationsJSON string) bool {
|
||||||
if translationsJSON == "" {
|
if translationsJSON == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -191,7 +242,15 @@ func hasEnglishTranslation(translationsJSON string) bool {
|
|||||||
if json.Unmarshal([]byte(translationsJSON), &transMap) != nil {
|
if json.Unmarshal([]byte(translationsJSON), &transMap) != nil {
|
||||||
return false
|
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.
|
// 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
|
// 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)
|
slog.Warn("wotd: stored word missing English translation, forcing re-prefetch", "word", word)
|
||||||
if err := p.prefetchWord(true); err != nil {
|
if err := p.prefetchWord(true); err != nil {
|
||||||
return fmt.Errorf("wotd: re-prefetch failed: %w", err)
|
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
|
// 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)
|
slog.Warn("wotd: stored word missing English translation, forcing re-prefetch", "word", word)
|
||||||
if err := p.prefetchWord(true); err != nil {
|
if err := p.prefetchWord(true); err != nil {
|
||||||
slog.Error("wotd: re-prefetch failed", "err", err)
|
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)
|
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 {
|
func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON string) string {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
dateStr := now.Format("Monday, 2 January 2006")
|
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
|
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 != "" {
|
if partOfSpeech != "" {
|
||||||
sb.WriteString(fmt.Sprintf("✨ **%s** (%s)\n\n", word, 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))
|
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 != "" {
|
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
|
// Synonyms
|
||||||
if translationsJSON != "" {
|
if transMap != nil {
|
||||||
var transMap map[string][]string
|
if syns, ok := transMap["syn"]; ok && len(syns) > 0 {
|
||||||
if json.Unmarshal([]byte(translationsJSON), &transMap) == nil {
|
display := syns
|
||||||
if enTrans, ok := transMap["en"]; ok && len(enTrans) > 0 {
|
|
||||||
display := enTrans
|
|
||||||
if len(display) > 5 {
|
if len(display) > 5 {
|
||||||
display = display[:5]
|
display = display[:5]
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("🇬🇧 en\n %s\n\n", strings.Join(display, ", ")))
|
label := wotdSynonymLabels[lang]
|
||||||
|
if label == "" {
|
||||||
|
label = "Synonyms"
|
||||||
}
|
}
|
||||||
if frTrans, ok := transMap["fr"]; ok && len(frTrans) > 0 {
|
sb.WriteString(fmt.Sprintf(" %s: %s\n", label, strings.Join(display, ", ")))
|
||||||
display := frTrans
|
}
|
||||||
|
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 {
|
if len(display) > 5 {
|
||||||
display = 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("━━━━━━━━━━━━━━━━━━━━\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!")
|
sb.WriteString("Use this word in a message today to earn 25 XP!")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user