mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is semantics-preserving, and build/vet/test are green either side. Split out from the code-review fixes that follow so those stay reviewable instead of hiding inside a wall of realignment.
887 lines
28 KiB
Go
887 lines
28 KiB
Go
package plugin
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"gogobee/internal/db"
|
||
)
|
||
|
||
// ─── Threshold Constants ────<E29480><E29480>──────────────────────────────────────
|
||
|
||
const (
|
||
// Communication
|
||
thNovelistAvgWords = 7
|
||
thNovelistMinMsgs = 100
|
||
thMinimalistMaxAvgWords = 4
|
||
thMinimalistMinMsgs = 50
|
||
thInquisitorPct = 8
|
||
thInquisitorMinMsgs = 50
|
||
thEnthusiastPct = 8
|
||
thEnthusiastMinMsgs = 50
|
||
thChatterboxMinMsgs = 500
|
||
thChatterboxMinAvgWords = 5
|
||
thLinkmasterPct = 5
|
||
thLinkmasterMinMsgs = 50
|
||
|
||
// Temporal
|
||
thNightOwlPct = 40
|
||
thNightOwlMinMsgs = 100
|
||
thEarlyBirdPct = 40
|
||
thEarlyBirdMinMsgs = 100
|
||
|
||
// Emotional (LLM-gated)
|
||
thEmotionalMinClassified = 100
|
||
thCheerleaderPosPct = 50
|
||
thPhilosopherNeutPct = 40
|
||
thPhilosopherQPct = 10
|
||
thPhilosopherAvgWords = 8
|
||
thAgitatorNegPct = 30
|
||
thAgitatorMinMsgs = 200
|
||
thWildcardStdDev = 0.5
|
||
thWildcardMinClassified = 150
|
||
thHypeMachineExclPct = 20
|
||
thHypeMachinePosPct = 60
|
||
|
||
// Economy
|
||
thBrokeSpiritedMaxBalance = 100.0
|
||
thDegenerateMinLosses = 10
|
||
|
||
// Games
|
||
thSharkWinRate = 55
|
||
thSharkMinGames = 15
|
||
thWordleMinPuzzles = 10
|
||
thArenaChampMinTier = 4
|
||
thArenaChampWinRate = 50
|
||
thArenaCowardMinRuns = 3
|
||
thArenaCowardMaxAvgTier = 2
|
||
thTriviaNerdMinCorrect = 10
|
||
|
||
// Adventure
|
||
thAdvMinDays = 10
|
||
thAdvDiverseMinDays = 15
|
||
thAdvDiverseMinTypes = 3
|
||
thAdvDiverseMaxShare = 40
|
||
thGearheadMinMasterwork = 3
|
||
|
||
// Communication (vocabulary)
|
||
thWordsmithMinFancyWords = 10
|
||
|
||
// Social
|
||
thPatronMinRepGiven = 5
|
||
thPatronRatioMultiplier = 2
|
||
|
||
// Display
|
||
maxDisplayArchetypes = 6
|
||
)
|
||
|
||
// ─── Flavor Text ─────<E29480><E29480><EFBFBD>─────────────────────────────────────────────
|
||
|
||
var archetypeFlavors = map[string]string{
|
||
// Communication
|
||
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
|
||
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
|
||
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
|
||
"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.",
|
||
"Early Bird": "Up before anyone else. Has already formed opinions about the day.",
|
||
|
||
// Emotional
|
||
"Cheerleader": "Lifts the room. Probably the reason someone didn't log off that one time.",
|
||
"Philosopher": "Thinks out loud. Asks questions that don't have comfortable answers.",
|
||
"Agitator": "Keeps things interesting. You don't always agree but you're never bored.",
|
||
"Wildcard": "You never quite know what you're getting. That's the charm. Mostly.",
|
||
"Hype Machine": "Arrived and immediately made everything louder. The room is better for it.",
|
||
|
||
// Economy
|
||
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
|
||
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
|
||
"Broke But Spirited": "Down but not out. The pot fears them anyway.",
|
||
|
||
// Games
|
||
"Shark": "Wins more than they should. GogoBee has opinions about this.",
|
||
"Wordle Devotee": "Shows up every day. Rain or shine. Five letters at a time.",
|
||
"Arena Champion": "Walks into the arena and the monsters check their insurance.",
|
||
"Arena Coward": "Shows up. Wins a little. Leaves immediately. Smart, actually.",
|
||
"Trivia Nerd": "Has retained an unreasonable amount of information.",
|
||
|
||
// Adventure
|
||
"Dungeon Crawler": "Always in the dungeon. The dungeon knows their name by now.",
|
||
"The Miner": "Steady. Patient. Has more ore than anyone needs. Still mining.",
|
||
"The Forager": "Out in the field while everyone else is underground. Knows where the good stuff is.",
|
||
"The Angler": "Patient beyond reason. Has caught things nobody else has caught.",
|
||
"The Merchant": "Buys. Sells. Optimizes. The economy is a puzzle and they're solving it.",
|
||
"Resting Face": "Technically participating. The numbers are still going up. Good enough.",
|
||
"The Adventurer": "Does a bit of everything. Refuses to specialize. Somehow works.",
|
||
"Gearhead": "Has the good stuff. Spent the time getting it. You can tell.",
|
||
|
||
// Social
|
||
"Patron": "Gives credit generously. Asks for nothing in return. Suspicious, honestly.",
|
||
"Reactor": "Expresses everything through emoji. An entire emotional life, no words required.",
|
||
|
||
// Fallback
|
||
"Regular": "Shows up. Participates. The backbone of any community, honestly.",
|
||
}
|
||
|
||
// ─<><E29480>─ Mutual Exclusions ────<E29480><E29480>────────────────────────────────────────
|
||
|
||
var mutualExclusions = [][2]string{
|
||
{"Novelist", "Minimalist"},
|
||
{"Early Bird", "Night Owl"},
|
||
{"Arena Champion", "Arena Coward"},
|
||
{"Whale", "Broke But Spirited"},
|
||
}
|
||
|
||
// ─── Core Types ────────────<E29480><E29480><EFBFBD>───────────────────────────────────────
|
||
|
||
type archetypeResult struct {
|
||
Name string
|
||
Category string
|
||
SignalScore float64
|
||
Flavor string
|
||
}
|
||
|
||
// ─── Community Percentiles ──────────────────────<E29480><E29480><EFBFBD>──────────────────
|
||
|
||
type communityPercentiles struct {
|
||
whaleBalanceThreshold float64 // top 10%
|
||
repGivenP75 int // top 25% rep given
|
||
reactionsGivenP75 int // top 25% reactions given
|
||
medianMsgCount int // median total_messages
|
||
}
|
||
|
||
func computePercentiles(d *sql.DB) communityPercentiles {
|
||
var p communityPercentiles
|
||
|
||
// Whale: top 10% balance
|
||
var userCount int
|
||
d.QueryRow(`SELECT COUNT(*) FROM euro_balances WHERE balance > 0`).Scan(&userCount)
|
||
offset := userCount / 10
|
||
if offset < 1 {
|
||
offset = 1
|
||
}
|
||
d.QueryRow(`SELECT balance FROM euro_balances ORDER BY balance DESC LIMIT 1 OFFSET ?`, offset).Scan(&p.whaleBalanceThreshold)
|
||
|
||
// Rep given: top 25%
|
||
repGiven := queryIntList(d, `SELECT COUNT(*) FROM rep_cooldowns GROUP BY giver ORDER BY COUNT(*) DESC`)
|
||
p.repGivenP75 = percentile75(repGiven)
|
||
|
||
// Reactions given: top 25%
|
||
reactionsGiven := queryIntList(d, `SELECT COUNT(*) FROM reaction_log GROUP BY sender ORDER BY COUNT(*) DESC`)
|
||
p.reactionsGivenP75 = percentile75(reactionsGiven)
|
||
|
||
// Median message count
|
||
msgCounts := queryIntList(d, `SELECT total_messages FROM user_stats ORDER BY total_messages`)
|
||
if len(msgCounts) > 0 {
|
||
p.medianMsgCount = msgCounts[len(msgCounts)/2]
|
||
}
|
||
|
||
return p
|
||
}
|
||
|
||
func queryIntList(d *sql.DB, query string) []int {
|
||
rows, err := d.Query(query)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
var result []int
|
||
for rows.Next() {
|
||
var v int
|
||
if rows.Scan(&v) == nil {
|
||
result = append(result, v)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func percentile75(sorted []int) int {
|
||
if len(sorted) == 0 {
|
||
return 0
|
||
}
|
||
idx := len(sorted) / 4 // top 25% = first quarter of desc-sorted list
|
||
if idx >= len(sorted) {
|
||
idx = len(sorted) - 1
|
||
}
|
||
return sorted[idx]
|
||
}
|
||
|
||
// ─── Per-User Data Loaders ──────────────────────<E29480><E29480>──────────────────
|
||
|
||
type userData struct {
|
||
userID string
|
||
|
||
// user_stats
|
||
totalMsgs int
|
||
totalWords int
|
||
totalLinks int
|
||
totalImages int
|
||
totalQuestions int
|
||
totalExcl int
|
||
totalEmojis int
|
||
nightMsgs int
|
||
morningMsgs int
|
||
fancyWords int
|
||
|
||
// sentiment_stats
|
||
sentPositive int
|
||
sentNegative int
|
||
sentNeutral int
|
||
|
||
// llm_classifications
|
||
classifiedCount int
|
||
sentVariance float64
|
||
|
||
// economy
|
||
balance float64
|
||
gamblingLosses int
|
||
recentGaming bool
|
||
|
||
// games
|
||
bjPlayed, bjWon int
|
||
hmPlayed, hmWon int
|
||
holdemPlayed int
|
||
holdemNetPositive bool
|
||
unoSoloPlayed, unoSoloWon int
|
||
unoMultiPlayed, unoMultiWon int
|
||
wordlePlayed int
|
||
triviaCorrect int
|
||
|
||
// arena
|
||
arenaHighestTier int
|
||
arenaRuns int
|
||
arenaWins int
|
||
arenaAvgCashTier float64
|
||
|
||
// adventure
|
||
advDays int
|
||
advActivities map[string]int // activity_type -> count
|
||
masterworkCount int
|
||
|
||
// social
|
||
repGiven int
|
||
repReceived int
|
||
reactionsGiven int
|
||
}
|
||
|
||
func loadUserData(d *sql.DB, userID string) userData {
|
||
u := userData{userID: userID, advActivities: make(map[string]int)}
|
||
|
||
// user_stats
|
||
d.QueryRow(`SELECT total_messages, total_words, total_links, total_images,
|
||
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.fancyWords)
|
||
|
||
// sentiment_stats
|
||
d.QueryRow(`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0)
|
||
FROM sentiment_stats WHERE user_id = ?`, userID).Scan(
|
||
&u.sentPositive, &u.sentNegative, &u.sentNeutral)
|
||
|
||
// llm_classifications count + variance
|
||
d.QueryRow(`SELECT COUNT(*) FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.classifiedCount)
|
||
if u.classifiedCount > 1 {
|
||
d.QueryRow(`SELECT AVG(sentiment_score * sentiment_score) - AVG(sentiment_score) * AVG(sentiment_score)
|
||
FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.sentVariance)
|
||
if u.sentVariance < 0 {
|
||
u.sentVariance = 0
|
||
}
|
||
}
|
||
|
||
// economy
|
||
d.QueryRow(`SELECT COALESCE(balance, 0) FROM euro_balances WHERE user_id = ?`, userID).Scan(&u.balance)
|
||
|
||
// Count actual gambling losses (not bets/antes which are always negative)
|
||
d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? AND amount < 0
|
||
AND reason IN ('holdem_loss')`,
|
||
userID).Scan(&u.gamblingLosses)
|
||
// Add blackjack losses (games played minus games won)
|
||
var bjLosses int
|
||
d.QueryRow(`SELECT MAX(COALESCE(games_played,0) - COALESCE(games_won,0), 0) FROM blackjack_scores WHERE user_id = ?`,
|
||
userID).Scan(&bjLosses)
|
||
u.gamblingLosses += bjLosses
|
||
|
||
var recentCount int
|
||
d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ?
|
||
AND created_at > datetime('now', '-30 days')
|
||
AND reason IN ('blackjack_bet','blackjack_win','holdem_loss','holdem_win',
|
||
'uno_wager','uno_win','uno_multi_ante','uno_multi_win')`,
|
||
userID).Scan(&recentCount)
|
||
u.recentGaming = recentCount > 0
|
||
|
||
// blackjack
|
||
d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM blackjack_scores WHERE user_id = ?`,
|
||
userID).Scan(&u.bjPlayed, &u.bjWon)
|
||
|
||
// hangman
|
||
d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM hangman_scores WHERE user_id = ?`,
|
||
userID).Scan(&u.hmPlayed, &u.hmWon)
|
||
|
||
// holdem
|
||
var totalWon, totalLost int
|
||
d.QueryRow(`SELECT COALESCE(hands_played,0), COALESCE(total_won,0), COALESCE(total_lost,0) FROM holdem_scores WHERE user_id = ?`,
|
||
userID).Scan(&u.holdemPlayed, &totalWon, &totalLost)
|
||
u.holdemNetPositive = totalWon > totalLost
|
||
|
||
// uno solo
|
||
d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result='player_win' THEN 1 ELSE 0 END), 0) FROM uno_games WHERE player_id = ?`,
|
||
userID).Scan(&u.unoSoloPlayed, &u.unoSoloWon)
|
||
|
||
// uno multi
|
||
d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE player_ids LIKE ?`,
|
||
"%"+userID+"%").Scan(&u.unoMultiPlayed)
|
||
d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE winner_id = ?`,
|
||
userID).Scan(&u.unoMultiWon)
|
||
|
||
// wordle
|
||
d.QueryRow(`SELECT COALESCE(puzzles_played,0) FROM wordle_stats WHERE user_id = ?`,
|
||
userID).Scan(&u.wordlePlayed)
|
||
|
||
// trivia
|
||
d.QueryRow(`SELECT COALESCE(SUM(correct),0) FROM trivia_scores WHERE user_id = ?`,
|
||
userID).Scan(&u.triviaCorrect)
|
||
|
||
// arena
|
||
d.QueryRow(`SELECT COALESCE(highest_tier,0), COALESCE(total_runs,0) FROM arena_stats WHERE user_id = ?`,
|
||
userID).Scan(&u.arenaHighestTier, &u.arenaRuns)
|
||
|
||
d.QueryRow(`SELECT COUNT(*) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`,
|
||
userID).Scan(&u.arenaWins)
|
||
|
||
d.QueryRow(`SELECT COALESCE(AVG(tier), 0) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`,
|
||
userID).Scan(&u.arenaAvgCashTier)
|
||
|
||
// adventure
|
||
d.QueryRow(`SELECT COUNT(DISTINCT DATE(logged_at)) FROM adventure_activity_log WHERE user_id = ?`,
|
||
userID).Scan(&u.advDays)
|
||
|
||
rows, err := d.Query(`SELECT activity_type, COUNT(*) FROM adventure_activity_log WHERE user_id = ? GROUP BY activity_type`, userID)
|
||
if err == nil {
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var atype string
|
||
var cnt int
|
||
if rows.Scan(&atype, &cnt) == nil {
|
||
u.advActivities[atype] = cnt
|
||
}
|
||
}
|
||
}
|
||
|
||
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id = ? AND masterwork = 1`,
|
||
userID).Scan(&u.masterworkCount)
|
||
|
||
// social
|
||
d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE giver = ?`, userID).Scan(&u.repGiven)
|
||
d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE receiver = ?`, userID).Scan(&u.repReceived)
|
||
d.QueryRow(`SELECT COUNT(*) FROM reaction_log WHERE sender = ?`, userID).Scan(&u.reactionsGiven)
|
||
|
||
return u
|
||
}
|
||
|
||
// <20><><EFBFBD>── Archetype Evaluators ────────<E29480><E29480><EFBFBD>─────────────────────────────────
|
||
|
||
func evaluateArchetypes(u userData, pct communityPercentiles) []archetypeResult {
|
||
var results []archetypeResult
|
||
|
||
m := max1arch(u.totalMsgs)
|
||
|
||
// ── Communication Style ──
|
||
|
||
avgWords := u.totalWords / m
|
||
if u.totalMsgs >= thNovelistMinMsgs && avgWords >= thNovelistAvgWords {
|
||
results = append(results, archetypeResult{
|
||
Name: "Novelist", Category: "Communication",
|
||
SignalScore: clampSignal(float64(avgWords-thNovelistAvgWords) / float64(thNovelistAvgWords)),
|
||
})
|
||
}
|
||
|
||
if u.totalMsgs >= thMinimalistMinMsgs && avgWords <= thMinimalistMaxAvgWords {
|
||
results = append(results, archetypeResult{
|
||
Name: "Minimalist", Category: "Communication",
|
||
SignalScore: clampSignal(float64(thMinimalistMaxAvgWords-avgWords+1) / float64(thMinimalistMaxAvgWords)),
|
||
})
|
||
}
|
||
|
||
qPct := u.totalQuestions * 100 / m
|
||
if u.totalMsgs >= thInquisitorMinMsgs && qPct >= thInquisitorPct {
|
||
results = append(results, archetypeResult{
|
||
Name: "Inquisitor", Category: "Communication",
|
||
SignalScore: clampSignal(float64(qPct-thInquisitorPct) / float64(thInquisitorPct)),
|
||
})
|
||
}
|
||
|
||
ePct := u.totalExcl * 100 / m
|
||
if u.totalMsgs >= thEnthusiastMinMsgs && ePct >= thEnthusiastPct {
|
||
results = append(results, archetypeResult{
|
||
Name: "Enthusiast", Category: "Communication",
|
||
SignalScore: clampSignal(float64(ePct-thEnthusiastPct) / float64(thEnthusiastPct)),
|
||
})
|
||
}
|
||
|
||
if u.totalMsgs >= thChatterboxMinMsgs && avgWords >= thChatterboxMinAvgWords {
|
||
results = append(results, archetypeResult{
|
||
Name: "Chatterbox", Category: "Communication",
|
||
SignalScore: clampSignal(float64(u.totalMsgs-thChatterboxMinMsgs) / float64(thChatterboxMinMsgs)),
|
||
})
|
||
}
|
||
|
||
lPct := u.totalLinks * 100 / m
|
||
if u.totalMsgs >= thLinkmasterMinMsgs && lPct >= thLinkmasterPct {
|
||
results = append(results, archetypeResult{
|
||
Name: "Linkmaster", Category: "Communication",
|
||
SignalScore: clampSignal(float64(lPct-thLinkmasterPct) / float64(thLinkmasterPct)),
|
||
})
|
||
}
|
||
|
||
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 {
|
||
nightPct := u.nightMsgs * 100 / m
|
||
if nightPct >= thNightOwlPct {
|
||
results = append(results, archetypeResult{
|
||
Name: "Night Owl", Category: "Temporal",
|
||
SignalScore: clampSignal(float64(nightPct-thNightOwlPct) / float64(100-thNightOwlPct)),
|
||
})
|
||
}
|
||
}
|
||
|
||
if u.totalMsgs >= thEarlyBirdMinMsgs {
|
||
morningPct := u.morningMsgs * 100 / m
|
||
if morningPct >= thEarlyBirdPct {
|
||
results = append(results, archetypeResult{
|
||
Name: "Early Bird", Category: "Temporal",
|
||
SignalScore: clampSignal(float64(morningPct-thEarlyBirdPct) / float64(100-thEarlyBirdPct)),
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── Emotional Signature (LLM-gated) ──
|
||
|
||
sentTotal := u.sentPositive + u.sentNegative + u.sentNeutral
|
||
if u.classifiedCount >= thEmotionalMinClassified && sentTotal > 0 {
|
||
posPct := u.sentPositive * 100 / sentTotal
|
||
negPct := u.sentNegative * 100 / sentTotal
|
||
neutPct := u.sentNeutral * 100 / sentTotal
|
||
|
||
// Cheerleader
|
||
if posPct >= thCheerleaderPosPct && u.repGiven >= pct.repGivenP75 && u.reactionsGiven >= pct.reactionsGivenP75 {
|
||
results = append(results, archetypeResult{
|
||
Name: "Cheerleader", Category: "Emotional",
|
||
SignalScore: clampSignal(float64(posPct-thCheerleaderPosPct) / float64(100-thCheerleaderPosPct)),
|
||
})
|
||
}
|
||
|
||
// Philosopher
|
||
if neutPct >= thPhilosopherNeutPct && qPct >= thPhilosopherQPct && avgWords >= thPhilosopherAvgWords {
|
||
results = append(results, archetypeResult{
|
||
Name: "Philosopher", Category: "Emotional",
|
||
SignalScore: clampSignal(float64(neutPct-thPhilosopherNeutPct) / float64(100-thPhilosopherNeutPct)),
|
||
})
|
||
}
|
||
|
||
// Agitator
|
||
if negPct >= thAgitatorNegPct && u.totalMsgs >= thAgitatorMinMsgs {
|
||
results = append(results, archetypeResult{
|
||
Name: "Agitator", Category: "Emotional",
|
||
SignalScore: clampSignal(float64(negPct-thAgitatorNegPct) / float64(100-thAgitatorNegPct)),
|
||
})
|
||
}
|
||
|
||
// Hype Machine
|
||
if ePct >= thHypeMachineExclPct && posPct >= thHypeMachinePosPct && u.reactionsGiven >= pct.reactionsGivenP75 {
|
||
results = append(results, archetypeResult{
|
||
Name: "Hype Machine", Category: "Emotional",
|
||
SignalScore: clampSignal((float64(ePct-thHypeMachineExclPct)/float64(thHypeMachineExclPct) +
|
||
float64(posPct-thHypeMachinePosPct)/float64(100-thHypeMachinePosPct)) / 2),
|
||
})
|
||
}
|
||
}
|
||
|
||
// Wildcard (separate min classified threshold)
|
||
if u.classifiedCount >= thWildcardMinClassified {
|
||
stddev := math.Sqrt(u.sentVariance)
|
||
if stddev >= thWildcardStdDev {
|
||
results = append(results, archetypeResult{
|
||
Name: "Wildcard", Category: "Emotional",
|
||
SignalScore: clampSignal((stddev - thWildcardStdDev) / thWildcardStdDev),
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── Economy ──
|
||
|
||
if pct.whaleBalanceThreshold > 0 && u.balance >= pct.whaleBalanceThreshold {
|
||
results = append(results, archetypeResult{
|
||
Name: "Whale", Category: "Economy",
|
||
SignalScore: clampSignal(u.balance / pct.whaleBalanceThreshold / 2),
|
||
})
|
||
}
|
||
|
||
if u.gamblingLosses >= thDegenerateMinLosses && u.recentGaming {
|
||
results = append(results, archetypeResult{
|
||
Name: "Degenerate", Category: "Economy",
|
||
SignalScore: clampSignal(float64(u.gamblingLosses) / float64(thDegenerateMinLosses*3)),
|
||
})
|
||
}
|
||
|
||
if u.balance < thBrokeSpiritedMaxBalance && u.recentGaming {
|
||
results = append(results, archetypeResult{
|
||
Name: "Broke But Spirited", Category: "Economy",
|
||
SignalScore: clampSignal((thBrokeSpiritedMaxBalance - u.balance) / thBrokeSpiritedMaxBalance),
|
||
})
|
||
}
|
||
|
||
// ── Games ──
|
||
|
||
totalGames := u.bjPlayed + u.hmPlayed + u.unoSoloPlayed + u.unoMultiPlayed
|
||
totalWins := u.bjWon + u.hmWon + u.unoSoloWon + u.unoMultiWon
|
||
if u.holdemPlayed > 0 {
|
||
totalGames += u.holdemPlayed
|
||
if u.holdemNetPositive {
|
||
totalWins += u.holdemPlayed / 2 // approximate: net positive counts as ~50% wins
|
||
}
|
||
}
|
||
if totalGames >= thSharkMinGames {
|
||
winRate := totalWins * 100 / totalGames
|
||
if winRate >= thSharkWinRate {
|
||
results = append(results, archetypeResult{
|
||
Name: "Shark", Category: "Games",
|
||
SignalScore: clampSignal(float64(winRate-thSharkWinRate) / float64(100-thSharkWinRate)),
|
||
})
|
||
}
|
||
}
|
||
|
||
if u.wordlePlayed >= thWordleMinPuzzles {
|
||
results = append(results, archetypeResult{
|
||
Name: "Wordle Devotee", Category: "Games",
|
||
SignalScore: clampSignal(float64(u.wordlePlayed-thWordleMinPuzzles) / float64(thWordleMinPuzzles)),
|
||
})
|
||
}
|
||
|
||
if u.arenaHighestTier >= thArenaChampMinTier && u.arenaRuns > 0 {
|
||
arenaWinRate := u.arenaWins * 100 / max1arch(u.arenaRuns)
|
||
if arenaWinRate >= thArenaChampWinRate {
|
||
results = append(results, archetypeResult{
|
||
Name: "Arena Champion", Category: "Games",
|
||
SignalScore: clampSignal(float64(u.arenaHighestTier-thArenaChampMinTier+1) / 2.0),
|
||
})
|
||
}
|
||
}
|
||
|
||
if u.arenaWins >= thArenaCowardMinRuns && u.arenaAvgCashTier > 0 && u.arenaAvgCashTier <= float64(thArenaCowardMaxAvgTier) {
|
||
results = append(results, archetypeResult{
|
||
Name: "Arena Coward", Category: "Games",
|
||
SignalScore: clampSignal((float64(thArenaCowardMaxAvgTier) - u.arenaAvgCashTier + 1) / float64(thArenaCowardMaxAvgTier)),
|
||
})
|
||
}
|
||
|
||
if u.triviaCorrect >= thTriviaNerdMinCorrect {
|
||
results = append(results, archetypeResult{
|
||
Name: "Trivia Nerd", Category: "Games",
|
||
SignalScore: clampSignal(float64(u.triviaCorrect-thTriviaNerdMinCorrect) / float64(thTriviaNerdMinCorrect)),
|
||
})
|
||
}
|
||
|
||
// ── Adventure ──
|
||
|
||
if u.advDays >= thAdvMinDays {
|
||
totalAdv := 0
|
||
for _, c := range u.advActivities {
|
||
totalAdv += c
|
||
}
|
||
if totalAdv > 0 {
|
||
// Find plurality activity
|
||
var topActivity string
|
||
var topCount int
|
||
for act, cnt := range u.advActivities {
|
||
if cnt > topCount {
|
||
topActivity = act
|
||
topCount = cnt
|
||
}
|
||
}
|
||
topShare := topCount * 100 / totalAdv
|
||
|
||
activityArchetypes := map[string]string{
|
||
"dungeon": "Dungeon Crawler",
|
||
"mining": "The Miner",
|
||
"foraging": "The Forager",
|
||
"fishing": "The Angler",
|
||
"shop": "The Merchant",
|
||
"rest": "Resting Face",
|
||
}
|
||
|
||
// Check for diverse adventurer first
|
||
if len(u.advActivities) >= thAdvDiverseMinTypes && u.advDays >= thAdvDiverseMinDays && topShare <= thAdvDiverseMaxShare {
|
||
results = append(results, archetypeResult{
|
||
Name: "The Adventurer", Category: "Adventure",
|
||
SignalScore: clampSignal(float64(len(u.advActivities)) / float64(thAdvDiverseMinTypes+2)),
|
||
})
|
||
} else if name, ok := activityArchetypes[topActivity]; ok {
|
||
results = append(results, archetypeResult{
|
||
Name: name, Category: "Adventure",
|
||
SignalScore: clampSignal(float64(topShare) / 100.0),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if u.masterworkCount >= thGearheadMinMasterwork {
|
||
results = append(results, archetypeResult{
|
||
Name: "Gearhead", Category: "Adventure",
|
||
SignalScore: clampSignal(float64(u.masterworkCount) / float64(thGearheadMinMasterwork*2)),
|
||
})
|
||
}
|
||
|
||
// ── Social ──
|
||
|
||
if u.repGiven >= thPatronMinRepGiven {
|
||
repReceived := max1arch(u.repReceived)
|
||
if u.repGiven >= repReceived*thPatronRatioMultiplier {
|
||
results = append(results, archetypeResult{
|
||
Name: "Patron", Category: "Social",
|
||
SignalScore: clampSignal(float64(u.repGiven) / float64(thPatronMinRepGiven*3)),
|
||
})
|
||
}
|
||
}
|
||
|
||
if u.reactionsGiven >= pct.reactionsGivenP75 && pct.reactionsGivenP75 > 0 && u.totalMsgs <= pct.medianMsgCount {
|
||
results = append(results, archetypeResult{
|
||
Name: "Reactor", Category: "Social",
|
||
SignalScore: clampSignal(float64(u.reactionsGiven) / float64(pct.reactionsGivenP75*2)),
|
||
})
|
||
}
|
||
|
||
// Fill in flavor text
|
||
for i := range results {
|
||
results[i].Flavor = archetypeFlavors[results[i].Name]
|
||
}
|
||
|
||
// Apply mutual exclusions
|
||
results = applyExclusions(results)
|
||
|
||
// Sort by signal score desc
|
||
sort.Slice(results, func(i, j int) bool {
|
||
return results[i].SignalScore > results[j].SignalScore
|
||
})
|
||
|
||
return results
|
||
}
|
||
|
||
func applyExclusions(results []archetypeResult) []archetypeResult {
|
||
nameIdx := make(map[string]int)
|
||
for i, r := range results {
|
||
nameIdx[r.Name] = i
|
||
}
|
||
|
||
remove := make(map[int]bool)
|
||
for _, pair := range mutualExclusions {
|
||
idxA, hasA := nameIdx[pair[0]]
|
||
idxB, hasB := nameIdx[pair[1]]
|
||
if hasA && hasB {
|
||
if results[idxA].SignalScore >= results[idxB].SignalScore {
|
||
remove[idxB] = true
|
||
} else {
|
||
remove[idxA] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(remove) == 0 {
|
||
return results
|
||
}
|
||
|
||
var filtered []archetypeResult
|
||
for i, r := range results {
|
||
if !remove[i] {
|
||
filtered = append(filtered, r)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
// ─── Cache Read/Write ────────<E29480><E29480>─────────────────────────────────────
|
||
|
||
// GetUserArchetypes returns cached archetypes for a user, sorted by signal_score desc.
|
||
func GetUserArchetypes(userID string) []archetypeResult {
|
||
d := db.Get()
|
||
rows, err := d.Query(`SELECT archetype, category, signal_score, flavor
|
||
FROM user_archetypes WHERE user_id = ? ORDER BY signal_score DESC`, userID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
|
||
var results []archetypeResult
|
||
for rows.Next() {
|
||
var r archetypeResult
|
||
if rows.Scan(&r.Name, &r.Category, &r.SignalScore, &r.Flavor) == nil {
|
||
results = append(results, r)
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// GetUserArchetypesLimited returns up to maxDisplayArchetypes for profile cards.
|
||
func GetUserArchetypesLimited(userID string) []archetypeResult {
|
||
results := GetUserArchetypes(userID)
|
||
if len(results) > maxDisplayArchetypes {
|
||
results = results[:maxDisplayArchetypes]
|
||
}
|
||
if len(results) == 0 {
|
||
return []archetypeResult{{
|
||
Name: "Regular", Category: "Fallback",
|
||
Flavor: archetypeFlavors["Regular"],
|
||
}}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// ─── Refresh Engine ─────────────────────<E29480><E29480>──────────────────────────
|
||
|
||
// RefreshAllArchetypes recalculates archetypes for all users and writes to user_archetypes.
|
||
func RefreshAllArchetypes() {
|
||
d := db.Get()
|
||
|
||
// Get all user IDs
|
||
rows, err := d.Query(`SELECT user_id FROM user_stats WHERE total_messages > 0`)
|
||
if err != nil {
|
||
slog.Error("archetype: failed to list users", "err", err)
|
||
return
|
||
}
|
||
var userIDs []string
|
||
for rows.Next() {
|
||
var uid string
|
||
if rows.Scan(&uid) == nil {
|
||
userIDs = append(userIDs, uid)
|
||
}
|
||
}
|
||
rows.Close()
|
||
|
||
if len(userIDs) == 0 {
|
||
return
|
||
}
|
||
|
||
// Compute community percentiles once
|
||
pct := computePercentiles(d)
|
||
|
||
// Evaluate each user
|
||
type userResult struct {
|
||
userID string
|
||
archs []archetypeResult
|
||
}
|
||
var allResults []userResult
|
||
|
||
for _, uid := range userIDs {
|
||
u := loadUserData(d, uid)
|
||
archs := evaluateArchetypes(u, pct)
|
||
if len(archs) > 0 {
|
||
allResults = append(allResults, userResult{uid, archs})
|
||
}
|
||
}
|
||
|
||
// Write to cache in a transaction
|
||
tx, err := d.Begin()
|
||
if err != nil {
|
||
slog.Error("archetype: failed to begin tx", "err", err)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(`DELETE FROM user_archetypes`)
|
||
if err != nil {
|
||
tx.Rollback()
|
||
slog.Error("archetype: failed to clear cache", "err", err)
|
||
return
|
||
}
|
||
|
||
stmt, err := tx.Prepare(`INSERT INTO user_archetypes (user_id, archetype, category, signal_score, flavor)
|
||
VALUES (?, ?, ?, ?, ?)`)
|
||
if err != nil {
|
||
tx.Rollback()
|
||
slog.Error("archetype: failed to prepare insert", "err", err)
|
||
return
|
||
}
|
||
defer stmt.Close()
|
||
|
||
totalAssigned := 0
|
||
for _, ur := range allResults {
|
||
for _, a := range ur.archs {
|
||
if _, err := stmt.Exec(ur.userID, a.Name, a.Category, a.SignalScore, a.Flavor); err != nil {
|
||
slog.Error("archetype: insert failed", "user", ur.userID, "arch", a.Name, "err", err)
|
||
}
|
||
totalAssigned++
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
slog.Error("archetype: commit failed", "err", err)
|
||
return
|
||
}
|
||
|
||
slog.Info("archetype: refresh complete", "users", len(allResults), "archetypes_assigned", totalAssigned)
|
||
}
|
||
|
||
// ─── Helpers ─────────<E29480><E29480><EFBFBD>─────────────────────────────────────────────
|
||
|
||
func max1arch(n int) int {
|
||
if n < 1 {
|
||
return 1
|
||
}
|
||
return n
|
||
}
|
||
|
||
func clampSignal(v float64) float64 {
|
||
if v < 0 {
|
||
return 0
|
||
}
|
||
if v > 1 {
|
||
return 1
|
||
}
|
||
return v
|
||
}
|
||
|
||
// FormatArchetypeNames returns a " · " separated list of archetype names.
|
||
func FormatArchetypeNames(archs []archetypeResult) string {
|
||
if len(archs) == 0 {
|
||
return "Regular"
|
||
}
|
||
names := make([]string, len(archs))
|
||
for i, a := range archs {
|
||
names[i] = a.Name
|
||
}
|
||
return strings.Join(names, " · ")
|
||
}
|
||
|
||
// FormatArchetypesFull returns a full display with names and flavor text.
|
||
func FormatArchetypesFull(archs []archetypeResult) string {
|
||
if len(archs) == 0 {
|
||
return fmt.Sprintf("**Regular**\n_%s_", archetypeFlavors["Regular"])
|
||
}
|
||
var sb strings.Builder
|
||
for i, a := range archs {
|
||
if i > 0 {
|
||
sb.WriteString("\n")
|
||
}
|
||
sb.WriteString(fmt.Sprintf("**%s**\n_%s_", a.Name, a.Flavor))
|
||
}
|
||
return sb.String()
|
||
}
|