@@ -0,0 +1,872 @@
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
// 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." ,
// 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
// 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
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 )
// 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 ) ) ,
} )
}
// ── 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 ( )
}