package main import ( "context" "encoding/json" "errors" "fmt" "math" "sort" "strconv" "strings" "sync" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) // ── journey-node fetch cache ──────────────────────────────────────────────── // Journey fetches return ~300-800 rows per character through an SSH tunnel, // which makes them visibly slow. A short TTL cache keeps the common // "open modal → close → reopen" loop snappy. Mutations call // invalidateJourneyCache(accountID) to drop stale entries. const journeyCacheTTL = 30 * time.Second type journeyCacheEntry struct { nodes []journeyNode cached time.Time } var ( journeyCacheMu sync.RWMutex journeyCache = map[int64]journeyCacheEntry{} ) func invalidateJourneyCache(accountID int64) { journeyCacheMu.Lock() delete(journeyCache, accountID) journeyCacheMu.Unlock() } // Used by mutations keyed on player_id where we don't have the account_id handy // (progression unlock, contract completion). A single-user admin tool, so // dropping every entry is cheap. func invalidateAllJourneyCache() { journeyCacheMu.Lock() journeyCache = map[int64]journeyCacheEntry{} journeyCacheMu.Unlock() } // ── data fetch commands ─────────────────────────────────────────────────────── func cmdFetchPlayers() Msg { if globalDB == nil { return msgPlayers{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT a.id, COALESCE(a.owner_account_id, 0), COALESCE(ps.character_name, convert_from(e.encrypted_funcom_id, 'UTF8'), ''), COALESCE(ps.player_controller_id, 0), COALESCE(ac."user", ''), a.class, COALESCE(a.map, ''), COALESCE(af.faction_id, 0), COALESCE(ps.online_status::text, 'Offline') FROM dune.actors a LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id LEFT JOIN dune.encrypted_accounts e ON e.id = a.owner_account_id LEFT JOIN dune.accounts ac ON ac.id = a.owner_account_id`+factionByAccountJoin+` WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1 ORDER BY a.id`, gmIdentityAccountID) if err != nil { return msgPlayers{err: err} } defer rows.Close() var players []playerInfo for rows.Next() { var p playerInfo if err := rows.Scan(&p.ID, &p.AccountID, &p.Name, &p.ControllerID, &p.FLSID, &p.Class, &p.Map, &p.FactionID, &p.OnlineStatus); err != nil { continue } p.Class = shortClass(p.Class) players = append(players, p) } if rows.Err() != nil { return msgPlayers{err: rows.Err()} } return msgPlayers{rows: players} } // labeledCount is one (label, count) row of a server-wide distribution on the // Players dashboard (#130) — e.g. players per map. type labeledCount struct { Label string `json:"label"` Count int64 `json:"count"` } // factionStat is one faction's player count + economy totals on the Players // dashboard (#130). "Unaligned" buckets characters with no dune.player_faction // row (i.e. players who never picked a faction). type factionStat struct { Faction string `json:"faction"` Players int64 `json:"players"` Solaris int64 `json:"solaris"` Scrip int64 `json:"scrip"` AvgLevel float64 `json:"avg_level"` } // serverStats is the Postgres-derived half of the Players dashboard summary // (#130): population counts, a per-map distribution, and economy totals. type serverStats struct { TotalPlayers int64 `json:"total_players"` OnlinePlayers int64 `json:"online_players"` ByMap []labeledCount `json:"by_map"` ByFaction []factionStat `json:"by_faction"` TotalSolaris int64 `json:"total_solaris"` TotalScrip int64 `json:"total_scrip"` } // serverSummary is the full Players-dashboard payload: serverStats plus the // session-derived playtime + activity trend (from sessions.db). type serverSummary struct { TotalPlayers int64 `json:"total_players"` OnlinePlayers int64 `json:"online_players"` ByMap []labeledCount `json:"by_map"` ByFaction []factionStat `json:"by_faction"` TotalSolaris int64 `json:"total_solaris"` TotalScrip int64 `json:"total_scrip"` AvgCharLevel float64 `json:"avg_char_level"` TotalPlaytimeSecs int64 `json:"total_playtime_secs"` ActivityTrend []activityPoint `json:"activity_trend"` TrendDays int `json:"trend_days"` } const ( // factionByAccountJoin resolves a player's faction by ACCOUNT. Faction is // stored on the PlayerController actor, NOT the PlayerCharacter, so joining // dune.player_faction directly onto the character actor (pf.actor_id = a.id) // misses and mis-buckets aligned players as "Unaligned". Both actors share // owner_account_id, so we resolve through a per-account derived table. The // outer query must alias the character actor as "a"; this exposes af.faction_id. factionByAccountJoin = ` LEFT JOIN ( SELECT DISTINCT fa.owner_account_id AS account_id, pf.faction_id FROM dune.player_faction pf JOIN dune.actors fa ON fa.id = pf.actor_id ) af ON af.account_id = a.owner_account_id` // Population counts. The seeded GM identity ($1) is excluded — it is not a // real player. online_status compares against the enum literal (see // cmdFetchOnlineAccountIDs), summed via CASE to match existing query style. serverCountsSQL = ` SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN ps.online_status = 'Online' THEN 1 ELSE 0 END), 0) AS online FROM dune.actors a LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1` serverByMapSQL = ` SELECT COALESCE(NULLIF(a.map, ''), 'Unknown') AS label, COUNT(*) AS count FROM dune.actors a WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1 GROUP BY label ORDER BY count DESC, label` // Economy totals across all balances. Solaris is identified by the game's // own dune.get_solaris_id(); everything else is treated as scrip. serverEconomySQL = ` SELECT COALESCE(SUM(CASE WHEN currency_id = dune.get_solaris_id() THEN balance ELSE 0 END), 0) AS solaris, COALESCE(SUM(CASE WHEN currency_id <> dune.get_solaris_id() THEN balance ELSE 0 END), 0) AS scrip FROM dune.player_virtual_currency_balances` // Players + economy grouped by faction. LEFT JOINs so characters with no // dune.player_faction row fall into "Unaligned"; COUNT(DISTINCT a.id) stays // correct despite the currency-row fan-out from the balances join. Verified // read-only against the test VM before shipping. serverByFactionSQL = ` SELECT COALESCE(f.name, 'Unaligned') AS faction, COUNT(DISTINCT a.id) AS players, COALESCE(SUM(CASE WHEN vcb.currency_id = dune.get_solaris_id() THEN vcb.balance ELSE 0 END), 0) AS solaris, COALESCE(SUM(CASE WHEN vcb.currency_id <> dune.get_solaris_id() THEN vcb.balance ELSE 0 END), 0) AS scrip FROM dune.actors a` + factionByAccountJoin + ` LEFT JOIN dune.factions f ON f.id = af.faction_id LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id LEFT JOIN dune.player_virtual_currency_balances vcb ON vcb.player_controller_id = ps.player_controller_id WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1 GROUP BY faction ORDER BY players DESC, faction` // Cumulative character XP per player (DuneCharacter FLevelComponent), fed // through xpToLevel to compute the server's average character level. serverCharXPSQL = ` SELECT COALESCE((fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, 0) AS xp FROM dune.actors a JOIN dune.actor_fgl_entities afe ON afe.actor_id = a.id AND afe.slot_name = 'DuneCharacter' JOIN dune.fgl_entities fe ON fe.entity_id = afe.entity_id WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1` // Per-player (faction, char XP) for the per-faction average level (#130 ext). // Same DuneCharacter XP source as serverCharXPSQL, joined to faction (LEFT // JOINs → "Unaligned" bucket). Averaged in Go via xpToLevel. serverFactionXPSQL = ` SELECT COALESCE(f.name, 'Unaligned') AS faction, COALESCE((fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, 0) AS xp FROM dune.actors a JOIN dune.actor_fgl_entities afe ON afe.actor_id = a.id AND afe.slot_name = 'DuneCharacter' JOIN dune.fgl_entities fe ON fe.entity_id = afe.entity_id` + factionByAccountJoin + ` LEFT JOIN dune.factions f ON f.id = af.faction_id WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1` ) // cmdFetchServerStats computes the Postgres-derived dashboard aggregates (#130): // player counts, the per-map distribution, and economy totals. func cmdFetchServerStats(ctx context.Context, pool *pgxpool.Pool) (serverStats, error) { var s serverStats if err := pool.QueryRow(ctx, serverCountsSQL, gmIdentityAccountID).Scan(&s.TotalPlayers, &s.OnlinePlayers); err != nil { return serverStats{}, fmt.Errorf("server counts: %w", err) } byMap, err := scanLabeledCounts(ctx, pool, serverByMapSQL, gmIdentityAccountID) if err != nil { return serverStats{}, fmt.Errorf("server by-map: %w", err) } s.ByMap = byMap if err := pool.QueryRow(ctx, serverEconomySQL).Scan(&s.TotalSolaris, &s.TotalScrip); err != nil { return serverStats{}, fmt.Errorf("server economy: %w", err) } byFaction, err := scanFactionStats(ctx, pool, serverByFactionSQL, gmIdentityAccountID) if err != nil { return serverStats{}, fmt.Errorf("server by-faction: %w", err) } levels, err := factionAvgLevels(ctx, pool) if err != nil { return serverStats{}, fmt.Errorf("server faction levels: %w", err) } for i := range byFaction { byFaction[i].AvgLevel = levels[byFaction[i].Faction] } s.ByFaction = byFaction return s, nil } // factionXP pairs a character's faction with its cumulative character XP, for // the per-faction average level (#130 ext). type factionXP struct { Faction string XP int64 } // avgLevelsByFaction averages per-character levels (via xpToLevel) within each // faction. Pure + testable; empty input → empty map. func avgLevelsByFaction(pairs []factionXP) map[string]float64 { sum := map[string]int{} cnt := map[string]int{} for _, p := range pairs { sum[p.Faction] += xpToLevel(p.XP) cnt[p.Faction]++ } out := make(map[string]float64, len(cnt)) for fac, n := range cnt { out[fac] = float64(sum[fac]) / float64(n) } return out } // factionAvgLevels queries per-player (faction, char XP) and returns the mean // character level per faction. func factionAvgLevels(ctx context.Context, pool *pgxpool.Pool) (map[string]float64, error) { rows, err := pool.Query(ctx, serverFactionXPSQL, gmIdentityAccountID) if err != nil { return nil, fmt.Errorf("faction xp: %w", err) } defer rows.Close() var pairs []factionXP for rows.Next() { var p factionXP if err := rows.Scan(&p.Faction, &p.XP); err != nil { return nil, fmt.Errorf("scan faction xp: %w", err) } pairs = append(pairs, p) } if err := rows.Err(); err != nil { return nil, err } return avgLevelsByFaction(pairs), nil } // serverAccountFactionSQL maps each player account to its current faction name // for the faction-growth trend (#130 ext). "Unaligned" when no faction row. const serverAccountFactionSQL = ` SELECT a.owner_account_id, COALESCE(f.name, 'Unaligned') FROM dune.actors a` + factionByAccountJoin + ` LEFT JOIN dune.factions f ON f.id = af.faction_id WHERE a.class ILIKE '%PlayerCharacter%' AND a.owner_account_id <> $1` // cmdFetchAccountFactions returns account_id -> current faction name. func cmdFetchAccountFactions(ctx context.Context, pool *pgxpool.Pool) (map[int64]string, error) { rows, err := pool.Query(ctx, serverAccountFactionSQL, gmIdentityAccountID) if err != nil { return nil, fmt.Errorf("account factions: %w", err) } defer rows.Close() out := map[int64]string{} for rows.Next() { var acct int64 var fac string if err := rows.Scan(&acct, &fac); err != nil { return nil, fmt.Errorf("scan account faction: %w", err) } out[acct] = fac } return out, rows.Err() } // ── Guilds (#117 Phase A — read-only) ──────────────────────────────────────── // // Schema (all dune.): guilds(guild_id, guild_name, guild_description, // guild_faction → factions.id); guild_members(player_id → actors.id, guild_id, // role_id); guild_invites(invite_id, guild_id, player_id → actors.id, // sender_player_id → actors.id, invite_sent_timespan). role_id is an in-game // rank enum not modelled in the DB, so it is surfaced numerically. Names resolve // actors.id → actors.owner_account_id → player_state.character_name. var errGuildNotFound = errors.New("guild not found") type guildSummary struct { GuildID int64 `json:"guild_id"` Name string `json:"name"` Description string `json:"description"` FactionID int16 `json:"faction_id"` FactionName string `json:"faction_name"` MemberCount int64 `json:"member_count"` } type guildMember struct { PlayerID int64 `json:"player_id"` RoleID int16 `json:"role_id"` CharacterName string `json:"character_name"` } type guildInvite struct { InviteID int64 `json:"invite_id"` PlayerID int64 `json:"player_id"` CharacterName string `json:"character_name"` SenderID int64 `json:"sender_player_id"` SenderName string `json:"sender_name"` } type guildDetail struct { guildSummary Members []guildMember `json:"members"` Invites []guildInvite `json:"invites"` } // guildMemberDisplayName returns the character name, or a stable "Actor " // fallback when the name can't be resolved (the actor row exists — FK-guaranteed // — but has no player_state, e.g. a never-fully-initialised or system actor). func guildMemberDisplayName(charName string, actorID int64) string { if strings.TrimSpace(charName) != "" { return charName } return fmt.Sprintf("Actor %d", actorID) } // guildSummarySelect is the shared SELECT for list + detail; callers append the // ORDER BY (list) or WHERE g.guild_id = $1 (detail). const guildSummarySelect = ` SELECT g.guild_id, COALESCE(g.guild_name, ''), COALESCE(g.guild_description, ''), g.guild_faction, COALESCE(f.name, ''), (SELECT count(*) FROM dune.guild_members m WHERE m.guild_id = g.guild_id) FROM dune.guilds g LEFT JOIN dune.factions f ON f.id = g.guild_faction` func cmdFetchGuilds(ctx context.Context, pool *pgxpool.Pool) ([]guildSummary, error) { rows, err := pool.Query(ctx, guildSummarySelect+` ORDER BY g.guild_name NULLS LAST, g.guild_id`) if err != nil { return nil, fmt.Errorf("list guilds: %w", err) } defer rows.Close() out := make([]guildSummary, 0, 16) for rows.Next() { var g guildSummary if err := rows.Scan(&g.GuildID, &g.Name, &g.Description, &g.FactionID, &g.FactionName, &g.MemberCount); err != nil { return nil, fmt.Errorf("scan guild: %w", err) } out = append(out, g) } return out, rows.Err() } const guildMembersSQL = ` SELECT m.player_id, COALESCE(m.role_id, 0), COALESCE(ps.character_name, '') FROM dune.guild_members m JOIN dune.actors a ON a.id = m.player_id LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id WHERE m.guild_id = $1 ORDER BY m.role_id, m.player_id` const guildInvitesSQL = ` SELECT i.invite_id, i.player_id, COALESCE(ps.character_name, ''), i.sender_player_id, COALESCE(sps.character_name, '') FROM dune.guild_invites i JOIN dune.actors a ON a.id = i.player_id LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id LEFT JOIN dune.actors sa ON sa.id = i.sender_player_id LEFT JOIN dune.player_state sps ON sps.account_id = sa.owner_account_id WHERE i.guild_id = $1 ORDER BY i.invite_id` func scanGuildMembers(ctx context.Context, pool *pgxpool.Pool, guildID int64) ([]guildMember, error) { rows, err := pool.Query(ctx, guildMembersSQL, guildID) if err != nil { return nil, fmt.Errorf("guild members %d: %w", guildID, err) } defer rows.Close() out := make([]guildMember, 0, 16) for rows.Next() { var m guildMember if err := rows.Scan(&m.PlayerID, &m.RoleID, &m.CharacterName); err != nil { return nil, fmt.Errorf("scan guild member: %w", err) } m.CharacterName = guildMemberDisplayName(m.CharacterName, m.PlayerID) out = append(out, m) } return out, rows.Err() } func scanGuildInvites(ctx context.Context, pool *pgxpool.Pool, guildID int64) ([]guildInvite, error) { rows, err := pool.Query(ctx, guildInvitesSQL, guildID) if err != nil { return nil, fmt.Errorf("guild invites %d: %w", guildID, err) } defer rows.Close() out := make([]guildInvite, 0, 8) for rows.Next() { var iv guildInvite if err := rows.Scan(&iv.InviteID, &iv.PlayerID, &iv.CharacterName, &iv.SenderID, &iv.SenderName); err != nil { return nil, fmt.Errorf("scan guild invite: %w", err) } iv.CharacterName = guildMemberDisplayName(iv.CharacterName, iv.PlayerID) iv.SenderName = guildMemberDisplayName(iv.SenderName, iv.SenderID) out = append(out, iv) } return out, rows.Err() } func cmdFetchGuildDetail(ctx context.Context, pool *pgxpool.Pool, guildID int64) (guildDetail, error) { var d guildDetail err := pool.QueryRow(ctx, guildSummarySelect+` WHERE g.guild_id = $1`, guildID).Scan( &d.GuildID, &d.Name, &d.Description, &d.FactionID, &d.FactionName, &d.MemberCount) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return guildDetail{}, errGuildNotFound } return guildDetail{}, fmt.Errorf("guild %d: %w", guildID, err) } if d.Members, err = scanGuildMembers(ctx, pool, guildID); err != nil { return guildDetail{}, err } if d.Invites, err = scanGuildInvites(ctx, pool, guildID); err != nil { return guildDetail{}, err } return d, nil } // ── Guild mutations (#117 Phase B) ─────────────────────────────────────────── // Writes go through the game's own stored procs (dune.edit_guild_description, // promote/demote_guild_member): each self-acquires pg_advisory_xact_lock(601145) // and pg_notify('guild_notify_channel', ...) so the live game applies the change — // the same safe pattern as faction mutations. Guild NAME has no game proc, so it // is a raw UPDATE (lock-guarded, uniqueness-checked) that the game only reflects // after it reloads guild data (e.g. a server restart). const ( guildRoleMember = 50 guildRoleAdmin = 100 ) var errGuildNameTaken = errors.New("guild name already taken") // guildRoleSetProc picks the game proc for a role change: promoting to admin (100) // transfers the single admin slot via promote_guild_member; any lower role goes // through demote_guild_member, which refuses to demote the sitting admin. func guildRoleSetProc(newRole int16) string { if newRole == guildRoleAdmin { return "promote_guild_member" } return "demote_guild_member" } func cmdEditGuildDescription(ctx context.Context, pool *pgxpool.Pool, guildID int64, desc string) error { if _, err := pool.Exec(ctx, `SELECT dune.edit_guild_description($1, $2)`, guildID, desc); err != nil { return fmt.Errorf("edit guild %d description: %w", guildID, err) } return nil } func cmdSetGuildMemberRole(ctx context.Context, pool *pgxpool.Pool, guildID, playerID int64, newRole int16) error { // Static query strings (no concatenation) selected by the allowlisted helper. var q string switch guildRoleSetProc(newRole) { case "promote_guild_member": q = `SELECT dune.promote_guild_member($1, $2, $3)` default: q = `SELECT dune.demote_guild_member($1, $2, $3)` } if _, err := pool.Exec(ctx, q, guildID, playerID, newRole); err != nil { return fmt.Errorf("set guild %d member %d role %d: %w", guildID, playerID, newRole, err) } return nil } // cmdEditGuildName renames a guild. No game proc exists for this, so it is a raw // UPDATE wrapped in a transaction that takes the same advisory lock the game's // guild procs use, and rejects a name already in use (case-insensitive, mirroring // create_guild). No pg_notify verb exists for a rename, so the game only reflects // the new name after it reloads guild data (e.g. a server restart). func cmdEditGuildName(ctx context.Context, pool *pgxpool.Pool, guildID int64, name string) error { tx, err := pool.Begin(ctx) if err != nil { return fmt.Errorf("begin rename guild %d: %w", guildID, err) } defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, `SELECT dune.guilds_get_exclusive_operation_lock()`); err != nil { return fmt.Errorf("lock for rename guild %d: %w", guildID, err) } var taken bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM dune.guilds WHERE guild_name ILIKE $1 AND guild_id <> $2)`, name, guildID).Scan(&taken); err != nil { return fmt.Errorf("check guild name: %w", err) } if taken { return errGuildNameTaken } ct, err := tx.Exec(ctx, `UPDATE dune.guilds SET guild_name = $1 WHERE guild_id = $2`, name, guildID) if err != nil { return fmt.Errorf("rename guild %d: %w", guildID, err) } if ct.RowsAffected() == 0 { return errGuildNotFound } return tx.Commit(ctx) } // ── Landsraad (#117 Phase A — read-only) ───────────────────────────────────── // // The Landsraad is the weekly political endgame: a term cycle // (landsraad_decree_term) with a 25-task board (landsraad_tasks, each keyed to a // noble house) and electable server-wide decrees (landsraad_decrees). This is a // read-only overview — the latest term + the decree catalogue + that term's task // board. Faction/decree ids resolve to names; nullable election fields → "". type landsraadTerm struct { TermID int64 `json:"term_id"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` TestTerm bool `json:"test_term"` ReigningFaction string `json:"reigning_faction"` ActiveDecree string `json:"active_decree"` ElectedDecree string `json:"elected_decree"` WinningFaction string `json:"winning_faction"` } type landsraadDecree struct { ID int64 `json:"id"` Name string `json:"name"` Weight float64 `json:"weight"` Disabled bool `json:"disabled"` } type landsraadTask struct { ID int64 `json:"id"` BoardIndex int16 `json:"board_index"` House string `json:"house"` Completed bool `json:"completed"` WinningFaction string `json:"winning_faction"` Sysselraad bool `json:"sysselraad"` GoalAmount int `json:"goal_amount"` } type landsraadOverview struct { Term *landsraadTerm `json:"term"` Decrees []landsraadDecree `json:"decrees"` Tasks []landsraadTask `json:"tasks"` } // landsraadHouseName strips the "DA_House" prefix from a task's house_name // (e.g. "DA_HouseHagal" -> "Hagal"). Unprefixed values pass through unchanged. func landsraadHouseName(raw string) string { return strings.TrimPrefix(raw, "DA_House") } func cmdFetchLandsraad(ctx context.Context, pool *pgxpool.Pool) (landsraadOverview, error) { var ov landsraadOverview term, err := fetchLandsraadTerm(ctx, pool) if err != nil { return ov, err } ov.Term = term if ov.Decrees, err = fetchLandsraadDecrees(ctx, pool); err != nil { return ov, err } if term != nil { if ov.Tasks, err = fetchLandsraadTasks(ctx, pool, term.TermID); err != nil { return ov, err } } return ov, nil } const landsraadTermSQL = ` SELECT t.term_id, t.start_time, t.end_time, t.test_term, COALESCE(rf.name, ''), COALESCE(ad.decree_name, ''), COALESCE(ed.decree_name, ''), COALESCE(wf.name, '') FROM dune.landsraad_decree_term t LEFT JOIN dune.factions rf ON rf.id = t.reigning_faction_id LEFT JOIN dune.landsraad_decrees ad ON ad.id = t.active_decree_id LEFT JOIN dune.landsraad_decrees ed ON ed.id = t.elected_decree_id LEFT JOIN dune.factions wf ON wf.id = t.winning_faction_id ORDER BY t.term_id DESC LIMIT 1` // fetchLandsraadTerm returns the latest term, or (nil, nil) when none exist. func fetchLandsraadTerm(ctx context.Context, pool *pgxpool.Pool) (*landsraadTerm, error) { var t landsraadTerm err := pool.QueryRow(ctx, landsraadTermSQL).Scan( &t.TermID, &t.StartTime, &t.EndTime, &t.TestTerm, &t.ReigningFaction, &t.ActiveDecree, &t.ElectedDecree, &t.WinningFaction) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("landsraad term: %w", err) } return &t, nil } func fetchLandsraadDecrees(ctx context.Context, pool *pgxpool.Pool) ([]landsraadDecree, error) { rows, err := pool.Query(ctx, ` SELECT id, decree_name, weight::float8, disabled FROM dune.landsraad_decrees ORDER BY id`) if err != nil { return nil, fmt.Errorf("landsraad decrees: %w", err) } defer rows.Close() out := make([]landsraadDecree, 0, 16) for rows.Next() { var d landsraadDecree if err := rows.Scan(&d.ID, &d.Name, &d.Weight, &d.Disabled); err != nil { return nil, fmt.Errorf("scan decree: %w", err) } out = append(out, d) } return out, rows.Err() } const landsraadTasksSQL = ` SELECT t.id, t.board_index, t.house_name, t.completed, COALESCE(wf.name, ''), t.sysselraad, t.goal_amount FROM dune.landsraad_tasks t LEFT JOIN dune.factions wf ON wf.id = t.winning_faction_id WHERE t.term_id = $1 ORDER BY t.board_index, t.id` func fetchLandsraadTasks(ctx context.Context, pool *pgxpool.Pool, termID int64) ([]landsraadTask, error) { rows, err := pool.Query(ctx, landsraadTasksSQL, termID) if err != nil { return nil, fmt.Errorf("landsraad tasks %d: %w", termID, err) } defer rows.Close() out := make([]landsraadTask, 0, 32) for rows.Next() { var tk landsraadTask var house string if err := rows.Scan(&tk.ID, &tk.BoardIndex, &house, &tk.Completed, &tk.WinningFaction, &tk.Sysselraad, &tk.GoalAmount); err != nil { return nil, fmt.Errorf("scan task: %w", err) } tk.House = landsraadHouseName(house) out = append(out, tk) } return out, rows.Err() } // factionTrendPoint is one day's per-faction value (faction name -> value). type factionTrendPoint struct { Day string `json:"day"` Values map[string]float64 `json:"values"` } // factionTrends is the faction-growth time series (#130 ext): a sorted faction // list (the chart's lines) + per-day values. The data is approximate — it comes // from stat_snapshots, which only capture players who were online during a poll, // and uses each account's CURRENT faction. type factionTrends struct { Metric string `json:"metric"` Factions []string `json:"factions"` Points []factionTrendPoint `json:"points"` } // factionSnapAcc is an intermediate accumulator used by bucketFactionTrends. type factionSnapAcc struct { sum float64 n int } // accumulateFactionSnaps groups snapshots by day and faction into accumulators. // metric "level" → sum of xpToLevel; otherwise → sum of Solaris. func accumulateFactionSnaps(snaps []daySnap, acctFaction map[int64]string, metric string) ( byDay map[string]map[string]*factionSnapAcc, order []string, factionSet map[string]bool, ) { byDay = map[string]map[string]*factionSnapAcc{} factionSet = map[string]bool{} for _, s := range snaps { fac := acctFaction[s.AccountID] if fac == "" { fac = "Unaligned" } factionSet[fac] = true if byDay[s.Day] == nil { byDay[s.Day] = map[string]*factionSnapAcc{} order = append(order, s.Day) } a := byDay[s.Day][fac] if a == nil { a = &factionSnapAcc{} byDay[s.Day][fac] = a } if metric == "level" { a.sum += float64(xpToLevel(s.CharXP)) } else { a.sum += float64(s.Solaris) } a.n++ } return } // bucketFactionTrends aggregates per-account daily snapshots into a per-day, // per-faction series. Pure + testable (xpToLevel is its only dependency). // metric "level" → average character level per faction; otherwise → summed Solaris. func bucketFactionTrends(snaps []daySnap, acctFaction map[int64]string, metric string) factionTrends { byDay, order, factionSet := accumulateFactionSnaps(snaps, acctFaction, metric) sort.Strings(order) factions := make([]string, 0, len(factionSet)) for f := range factionSet { factions = append(factions, f) } sort.Strings(factions) points := make([]factionTrendPoint, 0, len(order)) for _, day := range order { vals := make(map[string]float64, len(byDay[day])) for fac, a := range byDay[day] { if metric == "level" { vals[fac] = a.sum / float64(a.n) } else { vals[fac] = a.sum } } points = append(points, factionTrendPoint{Day: day, Values: vals}) } return factionTrends{Metric: metric, Factions: factions, Points: points} } // scanLabeledCounts runs a (label text, count bigint) query and returns the // rows as a never-nil slice (empty → [], so the JSON is [] not null). func scanLabeledCounts(ctx context.Context, pool *pgxpool.Pool, query string, args ...any) ([]labeledCount, error) { rows, err := pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := []labeledCount{} for rows.Next() { var c labeledCount if err := rows.Scan(&c.Label, &c.Count); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // scanFactionStats runs the per-faction (faction, players, solaris, scrip) query // and returns a never-nil slice. NUMERIC balance sums scan cleanly into int64. func scanFactionStats(ctx context.Context, pool *pgxpool.Pool, query string, args ...any) ([]factionStat, error) { rows, err := pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := []factionStat{} for rows.Next() { var fs factionStat if err := rows.Scan(&fs.Faction, &fs.Players, &fs.Solaris, &fs.Scrip); err != nil { return nil, err } out = append(out, fs) } return out, rows.Err() } // cmdFetchCharXPList returns every player character's cumulative character XP, // for the server-wide average character level (#130). func cmdFetchCharXPList(ctx context.Context, pool *pgxpool.Pool) ([]int64, error) { rows, err := pool.Query(ctx, serverCharXPSQL, gmIdentityAccountID) if err != nil { return nil, fmt.Errorf("server char xp: %w", err) } defer rows.Close() var xps []int64 for rows.Next() { var xp int64 if err := rows.Scan(&xp); err != nil { return nil, fmt.Errorf("scan char xp: %w", err) } xps = append(xps, xp) } return xps, rows.Err() } // averageLevel is the mean character level across the given cumulative-XP values // (via xpToLevel). Averaging per-character levels — not raw XP — is the intent, // since the XP→level curve is non-linear. Empty input → 0. Rounding is left to // the client. func averageLevel(xps []int64) float64 { if len(xps) == 0 { return 0 } sum := 0 for _, xp := range xps { sum += xpToLevel(xp) } return float64(sum) / float64(len(xps)) } func cmdFetchInventory(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgInventory{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT i.id, i.template_id, i.stack_size, i.quality_level, COALESCE((i.stats->'FItemStackAndDurabilityStats'->1->>'CurrentDurability'), 'N/A'), COALESCE((i.stats->'FItemStackAndDurabilityStats'->1->>'MaxDurability'), 'N/A') FROM dune.items i JOIN dune.inventories inv ON i.inventory_id = inv.id WHERE inv.actor_id = $1::bigint ORDER BY i.template_id`, playerID) if err != nil { return msgInventory{err: err} } defer rows.Close() var items []itemInfo for rows.Next() { var it itemInfo if err := rows.Scan(&it.ID, &it.TemplateID, &it.StackSize, &it.Quality, &it.Durability, &it.MaxDurability); err != nil { continue } it.Name = itemData.Names[strings.ToLower(it.TemplateID)] items = append(items, it) } if err := rows.Err(); err != nil { return msgInventory{err: err} } return msgInventory{rows: items} } } func cmdFetchCurrency() Msg { if globalDB == nil { return msgCurrency{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT player_controller_id, currency_id, balance FROM dune.player_virtual_currency_balances ORDER BY player_controller_id, currency_id`) if err != nil { return msgCurrency{err: err} } defer rows.Close() var out []currencyRow for rows.Next() { var r currencyRow if err := rows.Scan(&r.PlayerID, &r.CurrencyID, &r.Balance); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgCurrency{err: err} } return msgCurrency{rows: out} } func cmdFetchFactions() Msg { if globalDB == nil { return msgFactions{err: fmt.Errorf("not connected")} } ctx := context.Background() scripID, err := resolveScripCurrencyID(ctx) if err != nil { return msgFactions{err: err} } rows, err := globalDB.Query(ctx, ` SELECT pfr.actor_id, pfr.faction_id, f.name, pfr.reputation_amount, COALESCE(vcb.balance, 0) FROM dune.player_faction_reputation pfr JOIN dune.factions f ON f.id = pfr.faction_id LEFT JOIN dune.player_virtual_currency_balances vcb ON vcb.player_controller_id = pfr.actor_id AND vcb.currency_id = $1::smallint ORDER BY pfr.actor_id, pfr.faction_id`, scripID) if err != nil { return msgFactions{err: err} } defer rows.Close() var out []factionRep for rows.Next() { var r factionRep if err := rows.Scan(&r.ActorID, &r.FactionID, &r.FactionName, &r.Reputation, &r.Scrips); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgFactions{err: err} } return msgFactions{rows: out, scripCurrencyID: scripID} } func cmdFetchSpecs() Msg { if globalDB == nil { return msgSpecs{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT player_id, track_type::text, xp_amount, level FROM dune.specialization_tracks ORDER BY player_id, track_type`) if err != nil { return msgSpecs{err: err} } defer rows.Close() var out []specTrack for rows.Next() { var r specTrack if err := rows.Scan(&r.PlayerID, &r.TrackType, &r.XP, &r.Level); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgSpecs{err: err} } return msgSpecs{rows: out} } func sqlHeaderNames(rows pgx.Rows) []string { descs := rows.FieldDescriptions() headers := make([]string, len(descs)) for i, desc := range descs { headers[i] = string(desc.Name) } return headers } func collectSQLRows(rows pgx.Rows, limit int) ([][]any, bool) { collected := make([][]any, 0, limit) for rows.Next() && len(collected) < limit { values, err := rows.Values() if err != nil { continue } collected = append(collected, values) } return collected, len(collected) == limit } func formatSQLRow(values []any) string { parts := make([]string, len(values)) for i, value := range values { parts[i] = fmt.Sprintf("%v", value) } return strings.Join(parts, " │ ") } func buildSQLResult(headers []string, rows [][]any, truncated bool) string { var sb strings.Builder sb.WriteString(strings.Join(headers, " │ ")) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", 80)) sb.WriteString("\n") for _, row := range rows { sb.WriteString(formatSQLRow(row)) sb.WriteString("\n") } if truncated { sb.WriteString("… (limited to 200 rows)\n") } return sb.String() } func formatSQLStringRows(rows [][]any) [][]string { out := make([][]string, len(rows)) for i, row := range rows { cells := make([]string, len(row)) for j, v := range row { cells[j] = fmt.Sprintf("%v", v) } out[i] = cells } return out } func cmdRunSQL(sql string) Cmd { return func() Msg { if globalDB == nil { return msgSQL{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), sql) if err != nil { return msgSQL{err: err} } defer rows.Close() headers := sqlHeaderNames(rows) resultRows, truncated := collectSQLRows(rows, 200) return msgSQL{ result: buildSQLResult(headers, resultRows, truncated), headers: headers, rows: formatSQLStringRows(resultRows), truncated: truncated, } } } func cmdGiveItem(playerID int64, template string, qty, quality int64) Cmd { return func() Msg { return runGiveItem(playerID, template, qty, quality) } } func runGiveItem(playerID int64, template string, qty, quality int64) Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } trimmedTemplate, err := validateGiveItemInput(playerID, template, qty) if err != nil { return msgMutate{err: err} } template = trimmedTemplate ctx := context.Background() inv, err := findGiveItemInventory(ctx, playerID) if err != nil { return msgMutate{err: err} } state, err := loadGiveItemInventoryState(ctx, inv.id, template, quality, inv.hasVolumeCap) if err != nil { return msgMutate{err: err} } stackMax, known, err := resolveStackMax(ctx, template, quality) if err != nil { return msgMutate{err: err} } stackMax = effectiveStackMax(stackMax, known, qty) if err := ensureGiveItemVolumeCapacity(ctx, inv, state, template, qty); err != nil { return msgMutate{err: err} } updates, newStacks := planGiveItemStacks(qty, stackMax, state.stacks) if err := ensureGiveItemSlotCapacity(inv, state, len(newStacks)); err != nil { return msgMutate{err: err} } if err := applyGiveItemChanges(ctx, inv.id, template, quality, state.maxPos, updates, newStacks); err != nil { return msgMutate{err: err} } return msgMutate{ok: formatGiveItemResult(playerID, template, qty, len(updates), len(newStacks))} } type giveItemInventory struct { id int64 maxSlots int maxVolume float64 hasSlotCap bool hasVolumeCap bool } type giveItemStackSlot struct { id int64 size int64 } type giveItemInventoryState struct { stacks []giveItemStackSlot usedSlots int usedVolume float64 maxPos int64 } type giveItemStackUpdate struct { id int64 add int64 } func validateGiveItemInput(playerID int64, template string, qty int64) (string, error) { if playerID == 0 { return "", fmt.Errorf("player ID required") } template = strings.TrimSpace(template) if template == "" { return "", fmt.Errorf("item template required") } if qty <= 0 { return "", fmt.Errorf("quantity must be > 0") } return template, nil } func findGiveItemInventory(ctx context.Context, playerID int64) (giveItemInventory, error) { var inv giveItemInventory err := globalDB.QueryRow(ctx, ` SELECT id, COALESCE(max_item_count, -1), COALESCE(max_item_volume, -1) FROM dune.inventories WHERE actor_id = $1::bigint AND inventory_type = 0 LIMIT 1`, playerID).Scan(&inv.id, &inv.maxSlots, &inv.maxVolume) if err == nil { inv.hasSlotCap = inv.maxSlots > 0 inv.hasVolumeCap = inv.maxVolume > 0 return inv, nil } err = globalDB.QueryRow(ctx, ` SELECT id, COALESCE(max_item_count, -1), COALESCE(max_item_volume, -1) FROM dune.inventories WHERE actor_id = $1::bigint LIMIT 1`, playerID).Scan(&inv.id, &inv.maxSlots, &inv.maxVolume) if err != nil { return giveItemInventory{}, fmt.Errorf("find inventory: %w", err) } inv.hasSlotCap = inv.maxSlots > 0 inv.hasVolumeCap = inv.maxVolume > 0 return inv, nil } func loadGiveItemInventoryState(ctx context.Context, invID int64, template string, quality int64, includeVolume bool) (giveItemInventoryState, error) { rows, err := globalDB.Query(ctx, ` SELECT id, template_id, stack_size, quality_level, volume_override, position_index FROM dune.items WHERE inventory_id = $1::bigint`, invID) if err != nil { return giveItemInventoryState{}, err } defer rows.Close() state := giveItemInventoryState{maxPos: -1} for rows.Next() { var id int64 var tmpl string var stackSize int64 var qLevel int64 var vol pgtype.Float8 var pos int64 if err := rows.Scan(&id, &tmpl, &stackSize, &qLevel, &vol, &pos); err != nil { continue } state.usedSlots++ if pos > state.maxPos { state.maxPos = pos } if qLevel == quality && tmpl == template { state.stacks = append(state.stacks, giveItemStackSlot{id: id, size: stackSize}) } if includeVolume { state.usedVolume += inventoryItemVolume(tmpl, vol) * float64(stackSize) } } if err := rows.Err(); err != nil { return giveItemInventoryState{}, err } return state, nil } func inventoryItemVolume(template string, vol pgtype.Float8) float64 { if vol.Valid && vol.Float64 > 0 { return vol.Float64 } if itemData.Items != nil { if rule, ok := itemData.Items[strings.ToLower(template)]; ok { return rule.Volume // 0 is valid — item takes no volume } if itemData.DefaultVolume > 0 { return itemData.DefaultVolume } // Unknown volume: treat as 0 (no space consumed). return 0 } if itemData.DefaultVolume > 0 { return itemData.DefaultVolume } return 0 } func ensureGiveItemVolumeCapacity( ctx context.Context, inv giveItemInventory, state giveItemInventoryState, template string, qty int64, ) error { if !inv.hasVolumeCap { return nil } perItemVol, err := resolveItemVolume(ctx, template) if err != nil { return err } if perItemVol <= 0 { return nil } availableVol := inv.maxVolume - state.usedVolume if availableVol < 0 { availableVol = 0 } maxByVolume := int64(math.Floor(availableVol / perItemVol)) if maxByVolume < qty { return fmt.Errorf( "over weight limit: room for %d more %s (%.2f/%.2f volume used)", maxByVolume, template, state.usedVolume, inv.maxVolume) } return nil } func fillExistingStacks(sorted []giveItemStackSlot, remaining, stackMax int64) ([]giveItemStackUpdate, int64) { updates := make([]giveItemStackUpdate, 0, len(sorted)) for _, st := range sorted { if remaining == 0 { break } space := stackMax - st.size if space <= 0 { continue } add := space if add > remaining { add = remaining } updates = append(updates, giveItemStackUpdate{id: st.id, add: add}) remaining -= add } return updates, remaining } func planGiveItemStacks(qty, stackMax int64, stacks []giveItemStackSlot) ([]giveItemStackUpdate, []int64) { sorted := make([]giveItemStackSlot, len(stacks)) copy(sorted, stacks) sort.Slice(sorted, func(i, j int) bool { return sorted[i].size > sorted[j].size }) remaining := qty var updates []giveItemStackUpdate if stackMax > 1 { updates, remaining = fillExistingStacks(sorted, remaining, stackMax) } else { updates = make([]giveItemStackUpdate, 0) } newStackCap := 0 if stackMax > 0 { newStackCap = int((remaining + stackMax - 1) / stackMax) } newStacks := make([]int64, 0, newStackCap) for remaining > 0 { size := stackMax if size > remaining { size = remaining } newStacks = append(newStacks, size) remaining -= size } return updates, newStacks } func ensureGiveItemSlotCapacity(inv giveItemInventory, state giveItemInventoryState, newStackCount int) error { if !inv.hasSlotCap { return nil } freeSlots := inv.maxSlots - state.usedSlots if freeSlots < newStackCount { return fmt.Errorf("inventory full: need %d free slots, have %d", newStackCount, freeSlots) } return nil } func applyGiveItemChanges( ctx context.Context, invID int64, template string, quality int64, maxPos int64, updates []giveItemStackUpdate, newStacks []int64, ) error { tx, err := globalDB.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() for _, u := range updates { if _, err := tx.Exec(ctx, ` UPDATE dune.items SET stack_size = stack_size + $1::bigint WHERE id = $2::bigint`, u.add, u.id); err != nil { return err } } nextPos := maxPos + 1 for _, size := range newStacks { if _, err := tx.Exec(ctx, ` INSERT INTO dune.items (inventory_id, stack_size, position_index, template_id, quality_level, stats) VALUES ($1::bigint, $2::bigint, $3::bigint, $4::text, $5::bigint, '{}'::jsonb)`, invID, size, nextPos, template, quality); err != nil { return err } nextPos++ } return tx.Commit(ctx) } func formatGiveItemResult(playerID int64, template string, qty int64, toppedUp, created int) string { msg := fmt.Sprintf("Added %d × %s to player %d", qty, template, playerID) if toppedUp > 0 || created > 0 { return fmt.Sprintf( "Added %d × %s to player %d (%d stack(s) topped up, %d new stack(s))", qty, template, playerID, toppedUp, created) } return msg } type inventoryCapacityProfile struct { id int64 maxSlots int maxVolume float64 hasSlotCap bool hasVolumeCap bool } type inventoryUsage struct { usedSlots int usedVolume float64 } func loadBackpackCapacity(ctx context.Context, playerID int64) (inventoryCapacityProfile, bool) { var profile inventoryCapacityProfile err := globalDB.QueryRow(ctx, ` SELECT id, COALESCE(max_item_count, -1), COALESCE(max_item_volume, -1) FROM dune.inventories WHERE actor_id = $1::bigint AND inventory_type = 0 LIMIT 1`, playerID).Scan(&profile.id, &profile.maxSlots, &profile.maxVolume) if err != nil { // No inventory found — cannot validate; let the game server decide. return inventoryCapacityProfile{}, false } profile.hasSlotCap = profile.maxSlots > 0 profile.hasVolumeCap = profile.maxVolume > 0 return profile, true } func loadInventoryUsage(ctx context.Context, inventoryID int64, includeVolume bool) (inventoryUsage, error) { rows, err := globalDB.Query(ctx, ` SELECT template_id, stack_size, volume_override FROM dune.items WHERE inventory_id = $1::bigint`, inventoryID) if err != nil { return inventoryUsage{}, err } defer rows.Close() usage := inventoryUsage{} for rows.Next() { var templateID string var stackSize int64 var volumeOverride pgtype.Float8 if err := rows.Scan(&templateID, &stackSize, &volumeOverride); err != nil { continue } usage.usedSlots++ if includeVolume { usage.usedVolume += inventoryItemVolume(templateID, volumeOverride) * float64(stackSize) } } return usage, nil } func maxItemsByVolume(maxVolume, usedVolume, perItemVol float64) int64 { availableVolume := maxVolume - usedVolume if availableVolume < 0 { availableVolume = 0 } return int64(math.Floor(availableVolume / perItemVol)) } func requiredStackCount(qty, stackMax int64) int { return int((qty + stackMax - 1) / stackMax) } func checkInventoryVolumeLimit( ctx context.Context, profile inventoryCapacityProfile, usage inventoryUsage, template string, qty int64, ) error { if !profile.hasVolumeCap { return nil } perItemVol, err := resolveItemVolume(ctx, template) if err != nil || perItemVol <= 0 { return nil } maxByVolume := maxItemsByVolume(profile.maxVolume, usage.usedVolume, perItemVol) if maxByVolume < qty { return fmt.Errorf( "over weight limit: room for %d more %s (%.2f/%.2f volume used)", maxByVolume, template, usage.usedVolume, profile.maxVolume) } return nil } func checkInventorySlotLimit(ctx context.Context, profile inventoryCapacityProfile, usage inventoryUsage, template string, qty int64) error { if !profile.hasSlotCap { return nil } stackMax, known, err := resolveStackMax(ctx, template, 0) if err != nil { known = false } stackMax = effectiveStackMax(stackMax, known, qty) freeSlots := profile.maxSlots - usage.usedSlots newStacks := requiredStackCount(qty, stackMax) if freeSlots < newStacks { return fmt.Errorf( "inventory full: need %d free slots, have %d", newStacks, freeSlots) } return nil } // checkInventoryCapacity verifies that qty items of template fit in the player's // backpack (inventory_type=0). Returns an error if the inventory is over volume // or slot limits. Used to pre-validate RMQ give-item commands since the game // server's cheat function bypasses these checks. // listWelcomeOnlineAccounts returns currently-online characters eligible for the // welcome package: account id, pawn actor id (consumed by the give-items path), // FLS id (accounts."user", the ledger key), and character name. Online-only so // the live RMQ grant path applies — this is the "on first login" trigger. func listWelcomeOnlineAccounts(ctx context.Context) ([]welcomeAccount, error) { if globalDB == nil { return nil, fmt.Errorf("not connected") } rows, err := globalDB.Query(ctx, ` SELECT ps.account_id, ps.player_pawn_id, COALESCE(ac."user", ''), COALESCE(ps.character_name, '') FROM dune.player_state ps JOIN dune.actors a ON a.id = ps.player_pawn_id JOIN dune.accounts ac ON ac.id = a.owner_account_id WHERE ps.online_status = 'Online' AND ps.player_pawn_id IS NOT NULL AND ps.account_id <> $1`, gmIdentityAccountID) if err != nil { return nil, fmt.Errorf("list welcome accounts: %w", err) } defer rows.Close() out := make([]welcomeAccount, 0) for rows.Next() { var acc welcomeAccount if err := rows.Scan(&acc.AccountID, &acc.PawnID, &acc.FlsID, &acc.CharacterName); err != nil { return nil, fmt.Errorf("scan welcome account: %w", err) } out = append(out, acc) } return out, rows.Err() } func checkInventoryCapacity(ctx context.Context, playerID int64, template string, qty int64) error { if globalDB == nil { return fmt.Errorf("not connected") } profile, ok := loadBackpackCapacity(ctx, playerID) if !ok { return nil } if !profile.hasSlotCap && !profile.hasVolumeCap { return nil } usage, err := loadInventoryUsage(ctx, profile.id, profile.hasVolumeCap) if err != nil { return nil } if err := checkInventoryVolumeLimit(ctx, profile, usage, template, qty); err != nil { return err } if err := checkInventorySlotLimit(ctx, profile, usage, template, qty); err != nil { return err } return nil } // cmdGrantLive inserts into landsraad_house_rewards which fires a pg_notify trigger. // The game server receives the notification immediately and shows "Claim Rewards" to the player. func cmdGrantLive(controllerID int64, templateID string, amount int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } _, err := globalDB.Exec(context.Background(), ` DELETE FROM dune.landsraad_house_rewards WHERE player_id = $1 AND house_name = 'AdminGrant'`, controllerID) if err != nil { return msgMutate{err: fmt.Errorf("grant live clear: %w", err)} } _, err = globalDB.Exec(context.Background(), ` INSERT INTO dune.landsraad_house_rewards (player_id, house_name, amount, template_id, last_updated) VALUES ($1, 'AdminGrant', $2, $3, NOW())`, controllerID, amount, templateID) if err != nil { return msgMutate{err: fmt.Errorf("grant live: %w", err)} } return msgMutate{ok: fmt.Sprintf("Queued live grant: %dx %s — player will see Claim Rewards", amount, templateID)} } } func cmdGiveCurrency(playerID int64, amount int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if playerID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } ctx := context.Background() // Route through adjust_player_virtual_currency_balance for audit logging // and negative-balance guards. The casts match the live function signature. _, err := globalDB.Exec(ctx, ` SELECT dune.adjust_player_virtual_currency_balance( $1::bigint, dune.get_solaris_id(), $2::bigint )`, playerID, amount) if err != nil { return msgMutate{err: err} } var balance int64 _ = globalDB.QueryRow(ctx, ` SELECT balance FROM dune.player_virtual_currency_balances WHERE player_controller_id = $1::bigint AND currency_id = dune.get_solaris_id()`, playerID).Scan(&balance) return msgMutate{ok: fmt.Sprintf( "Added %d Solaris to player %d — new balance %d", amount, playerID, balance)} } } func cmdGiveFactionRep(actorID int64, factionID int16, delta int32) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() return applyFactionRepDelta(ctx, actorID, factionID, delta) } } func cmdGiveLandsraadScrip(actorID int64, delta int32) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() if actorID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } currencyID, err := resolveScripCurrencyID(ctx) if err != nil { return msgMutate{err: err} } _, err = globalDB.Exec(ctx, ` SELECT dune.adjust_player_virtual_currency_balance($1::bigint, $2::smallint, $3::bigint)`, actorID, currencyID, int64(delta)) if err != nil { return msgMutate{err: err} } var balance int64 _ = globalDB.QueryRow(ctx, ` SELECT balance FROM dune.player_virtual_currency_balances WHERE player_controller_id = $1::bigint AND currency_id = $2::smallint`, actorID, currencyID).Scan(&balance) return msgMutate{ok: fmt.Sprintf( "Added %d scrips (currency %d) to player %d — new balance %d", delta, currencyID, actorID, balance)} } } func cmdAwardXP(playerID int64, trackType string, delta int32) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } const maxXP int32 = 44182 if delta > maxXP { delta = maxXP } res, err := globalDB.Exec(context.Background(), ` UPDATE dune.specialization_tracks SET xp_amount = GREATEST(LEAST(xp_amount + $1::integer, $4::integer), 0) WHERE player_id = $2::bigint AND track_type::text = $3::text`, delta, playerID, trackType, maxXP) if err != nil { return msgMutate{err: err} } if res.RowsAffected() == 0 { _, err = globalDB.Exec(context.Background(), ` INSERT INTO dune.specialization_tracks (player_id, track_type, xp_amount, level) VALUES ($1::bigint, $2::dune.specializationtracktype, LEAST($3::integer, $4::integer), 0::real)`, playerID, trackType, delta, maxXP) if err != nil { return msgMutate{err: err} } } return msgMutate{ok: fmt.Sprintf("Awarded %d XP (%s) to player %d", delta, trackType, playerID)} } } func cmdRenameCharacter(accountID int64, name string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if accountID == 0 { return msgMutate{err: fmt.Errorf("account ID required")} } name = strings.TrimSpace(name) if name == "" { return msgMutate{err: fmt.Errorf("name required")} } _, err := globalDB.Exec(context.Background(), `SELECT dune.set_character_name($1, $2)`, accountID, name) if err != nil { return msgMutate{err: fmt.Errorf("rename character: %w", err)} } return msgMutate{ok: fmt.Sprintf("Renamed to %s", name)} } } func cmdGetPlayerTags(accountID int64) Cmd { return func() Msg { if globalDB == nil { return msgTags{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), `SELECT tag FROM dune.player_tags WHERE account_id=$1 ORDER BY tag`, accountID) if err != nil { return msgTags{err: err} } defer rows.Close() var tags []string for rows.Next() { var tag string if err := rows.Scan(&tag); err != nil { continue } tags = append(tags, tag) } if err := rows.Err(); err != nil { return msgTags{err: err} } return msgTags{rows: tags} } } func cmdUpdatePlayerTags(accountID int64, add []string, remove []string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } var addArg, removeArg interface{} if len(add) > 0 { addArg = add } else { addArg = []string{} } if len(remove) > 0 { removeArg = remove } else { removeArg = []string{} } _, err := globalDB.Exec(context.Background(), `SELECT dune.update_player_tags($1, $2::text[], $3::text[])`, accountID, addArg, removeArg) if err != nil { return msgMutate{err: fmt.Errorf("update player tags: %w", err)} } return msgMutate{ok: "Tags updated"} } } // rawFuncomID returns the accounts."user" value (hex Funcom ID) for a given // account_id. This is the ID format expected by character_transfer_export, // complete_journey_story_nodes_for_player, update_returning_player_status, // delete_account, and other procs — distinct from encrypted_funcom_id which // stores the human-readable display name (e.g. "Icehunter#55381"). func rawFuncomID(ctx context.Context, accountID int64) (string, error) { var id string err := globalDB.QueryRow(ctx, `SELECT "user" FROM dune.accounts WHERE id = $1`, accountID).Scan(&id) return id, err } // Seeded "GM/Server" chat persona sentinels. The account id is held in a high, // out-of-range slot so it never collides with a real player (Phase 0 recon: real // accounts max out near id 2, actors near 54) and so operator-facing queries can // exclude it. The actor ids derive from the account id. Single source of truth; // the seed routine and the blast-radius exclusions both key off these. const ( gmIdentityAccountID int64 = 9000001 gmIdentityHexID string = "DA5EBA11DA5EBA11" // accounts."user" (AMQP user_id) gmIdentityFuncomID string = "GM#0001" // chat id (m_FuncomIdFrom) gmIdentityCharacterName string = "GM" // in-game display name ) // errGMNotProvisioned signals that the GM/Server chat persona has not been seeded // yet, so admin chat cannot resolve a sender identity. Mapped to 503 by handlers. var errGMNotProvisioned = errors.New("gm identity not provisioned") // cmdGetGMIdentity reads the seeded GM/Server persona used as the sender for admin // chat. Returns its hex FLS id (the AMQP user_id) and funcom id (m_FuncomIdFrom). // Reads the dune.accounts VIEW, which decrypts funcom_id from the encrypted base // table — so this stays correct even if user-data encryption is enabled. Returns // errGMNotProvisioned if the identity row does not exist yet. func cmdGetGMIdentity(ctx context.Context) (gmIdentity, error) { gm := gmIdentity{AccountID: gmIdentityAccountID} err := globalDB.QueryRow(ctx, ` SELECT "user", funcom_id FROM dune.accounts WHERE id = $1`, gmIdentityAccountID).Scan(&gm.HexID, &gm.FuncomID) if errors.Is(err, pgx.ErrNoRows) { return gmIdentity{}, errGMNotProvisioned } if err != nil { return gmIdentity{}, fmt.Errorf("read gm identity: %w", err) } return gm, nil } // cmdResolveRecipientChatIdentity resolves a whisper recipient by account id into // the values the whisper wire body needs: funcom id (m_SubChannelId + AMQP routing // key) and character name (m_UserNameTo). Reads the decrypting accounts/player_state // views so it is correct regardless of the user-data encryption setting. func cmdResolveRecipientChatIdentity(ctx context.Context, accountID int64) (funcomID, charName string, err error) { err = globalDB.QueryRow(ctx, ` SELECT a.funcom_id, COALESCE(ps.character_name, '') FROM dune.accounts a LEFT JOIN dune.player_state ps ON ps.account_id = a.id WHERE a.id = $1`, accountID).Scan(&funcomID, &charName) if err != nil { return "", "", fmt.Errorf("resolve recipient %d: %w", accountID, err) } return funcomID, charName, nil } // gmSeed holds the fixed values written for the GM/Server persona. Centralised so // the sentinel ids, the actor class paths (which MUST match the live schema or the // game's player-info lookup fails), and the blast-radius-safe defaults are testable // and have one source of truth. Class paths + partition are from Phase 0 recon. type gmSeed struct { AccountID int64 HexID string FuncomID string CharacterName string ControllerID int64 StateID int64 PawnID int64 ControllerClass string StateClass string PawnClass string Map string PartitionID int64 DimensionIndex int OnlineStatus string LifeState string } func gmSeedSpec() gmSeed { return gmSeed{ AccountID: gmIdentityAccountID, HexID: gmIdentityHexID, FuncomID: gmIdentityFuncomID, CharacterName: gmIdentityCharacterName, ControllerID: gmIdentityAccountID*100 + 1, // 900000101 StateID: gmIdentityAccountID*100 + 2, // 900000102 PawnID: gmIdentityAccountID*100 + 3, // 900000103 ControllerClass: "/Game/Dune/Characters/Player/BP_DunePlayerController.BP_DunePlayerController_C", StateClass: "/Script/DuneSandbox.DunePlayerState", PawnClass: "/Game/Dune/Characters/Player/BP_DunePlayerCharacter.BP_DunePlayerCharacter_C", Map: "HaggaBasin", PartitionID: 1, DimensionIndex: 0, OnlineStatus: "Offline", // blast-radius safe; flip to Online only if verify needs it LifeState: "Alive", } } // cmdEnsureGMIdentity idempotently seeds the GM/Server persona used as the sender // for admin chat. It writes the BASE tables (dune.accounts / dune.player_state are // VIEWS over them, per Phase 0 recon) plus the three linked actor rows the game's // player-info lookup requires. Names go through dune.encrypt_user_data so the seed // stays correct if user-data encryption is ever enabled. actors.transform is left // NULL so the GM never plots on the live map. Safe to call on every startup // (ON CONFLICT DO NOTHING); the connectAll wiring logs-and-continues on failure. func cmdEnsureGMIdentity(ctx context.Context) error { if globalDB == nil { return fmt.Errorf("not connected") } s := gmSeedSpec() tx, err := globalDB.Begin(ctx) if err != nil { return fmt.Errorf("begin gm seed: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, ` INSERT INTO dune.encrypted_accounts (id, "user", encrypted_funcom_id, takeoverable, platform_id, platform_name) VALUES ($1, $2, dune.encrypt_user_data($3), false, 'dune-admin', 'DuneAdmin') ON CONFLICT DO NOTHING`, s.AccountID, s.HexID, s.FuncomID); err != nil { return fmt.Errorf("seed gm account: %w", err) } actors := []struct { id int64 class string }{ {s.ControllerID, s.ControllerClass}, {s.StateID, s.StateClass}, {s.PawnID, s.PawnClass}, } for _, a := range actors { if _, err := tx.Exec(ctx, ` INSERT INTO dune.actors (id, class, map, partition_id, dimension_index, gas_attributes, properties, owner_account_id, serial) VALUES ($1, $2, $3, $4, $5, '{}'::jsonb, '{}'::jsonb, $6, 1) ON CONFLICT DO NOTHING`, a.id, a.class, s.Map, s.PartitionID, s.DimensionIndex, s.AccountID); err != nil { return fmt.Errorf("seed gm actor %d: %w", a.id, err) } } // server_id reuses a real one if any player has logged in (the game's lookup // expects a valid server). NULL is acceptable on a never-populated DB. if _, err := tx.Exec(ctx, ` INSERT INTO dune.encrypted_player_state (account_id, encrypted_character_name, life_state, online_status, is_coriolis_processed, server_id, player_controller_id, player_pawn_id, player_state_id, last_login_time) VALUES ($1, dune.encrypt_user_data($2), $3::dune.playerlifestate, $4::dune.playerconnectionstatus, false, (SELECT server_id FROM dune.encrypted_player_state WHERE server_id IS NOT NULL LIMIT 1), $5, $6, $7, now()) ON CONFLICT DO NOTHING`, s.AccountID, s.CharacterName, s.LifeState, s.OnlineStatus, s.ControllerID, s.PawnID, s.StateID); err != nil { return fmt.Errorf("seed gm player_state: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit gm seed: %w", err) } return nil } func cmdGrantReturningPlayerAward(accountID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() rawID, err := rawFuncomID(ctx, accountID) if err != nil { return msgMutate{err: fmt.Errorf("look up funcom id: %w", err)} } _, err = globalDB.Exec(ctx, ` UPDATE dune.encrypted_player_state SET last_returning_player_awarded_time = NULL, last_returning_player_event_time = NULL WHERE account_id = $1`, accountID) if err != nil { return msgMutate{err: fmt.Errorf("reset returning player timestamps: %w", err)} } _, err = globalDB.Exec(ctx, `SELECT dune.update_returning_player_status($1, 0)`, rawID) if err != nil { return msgMutate{err: fmt.Errorf("update_returning_player_status: %w", err)} } return msgMutate{ok: "Returning player award reset — will trigger on next login"} } } func cmdDismissReturningPlayerAward(accountID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() _, err := globalDB.Exec(ctx, ` UPDATE dune.encrypted_player_state SET last_returning_player_awarded_time = NOW(), last_returning_player_event_time = NOW() WHERE account_id = $1`, accountID) if err != nil { return msgMutate{err: fmt.Errorf("dismiss returning player award: %w", err)} } return msgMutate{ok: "Returning player popup dismissed"} } } func cmdDeleteAccount(accountID int64, reason string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() rawID, err := rawFuncomID(ctx, accountID) if err != nil { return msgMutate{err: fmt.Errorf("look up funcom id: %w", err)} } var result bool err = globalDB.QueryRow(ctx, `SELECT dune.delete_account($1, $2)`, rawID, reason).Scan(&result) if err != nil { return msgMutate{err: fmt.Errorf("delete account: %w", err)} } return msgMutate{ok: "Account deleted"} } } func cmdDeleteItem(itemID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if itemID == 0 { return msgMutate{err: fmt.Errorf("item ID required")} } _, err := globalDB.Exec(context.Background(), `SELECT dune.delete_item($1::bigint)`, itemID) if err != nil { return msgMutate{err: fmt.Errorf("delete item: %w", err)} } return msgMutate{ok: fmt.Sprintf("Deleted item %d", itemID)} } } func cmdResetSpecializations(playerID int64, trackType string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if playerID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } ctx := context.Background() if trackType == "" || strings.EqualFold(trackType, "all") { if _, err := globalDB.Exec(ctx, `SELECT dune.reset_specialization_tracks($1)`, playerID); err != nil { return msgMutate{err: fmt.Errorf("reset tracks: %w", err)} } if _, err := globalDB.Exec(ctx, `SELECT dune.reset_specialization_keystones($1)`, playerID); err != nil { return msgMutate{err: fmt.Errorf("reset keystones: %w", err)} } return msgMutate{ok: fmt.Sprintf("Reset all spec tracks + keystones for player %d", playerID)} } res, err := globalDB.Exec(ctx, ` DELETE FROM dune.specialization_tracks WHERE player_id = $1::bigint AND track_type::text = $2::text`, playerID, trackType) if err != nil { return msgMutate{err: fmt.Errorf("reset track: %w", err)} } return msgMutate{ok: fmt.Sprintf( "Reset %s track for player %d (%d row(s) cleared)", trackType, playerID, res.RowsAffected())} } } // onlineStateRow holds a single row from the player online state query. type onlineStateRow struct { PlayerID int64 Name string Map string Status string LastSeen string } type msgOnlineState struct { rows []onlineStateRow err error } func cmdFetchOnlineState() Msg { if globalDB == nil { return msgOnlineState{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT ps.player_controller_id, COALESCE(ps.character_name, ''), COALESCE(a.map, ''), ps.online_status::text, COALESCE(to_char(ps.last_avatar_activity AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'), '') FROM dune.player_state ps LEFT JOIN dune.actors a ON a.id = ps.player_controller_id WHERE ps.account_id <> $1 ORDER BY ps.online_status DESC, ps.last_avatar_activity DESC`, gmIdentityAccountID) if err != nil { return msgOnlineState{err: err} } defer rows.Close() var out []onlineStateRow for rows.Next() { var r onlineStateRow if err := rows.Scan(&r.PlayerID, &r.Name, &r.Map, &r.Status, &r.LastSeen); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgOnlineState{err: err} } return msgOnlineState{rows: out} } // ── private helpers ─────────────────────────────────────────────────────────── // resolveStackMax returns the per-slot stack cap for a template and whether // that value is actually known. known=false means we had to fall back to a // guess (no item-data rule, no existing stacks of this template, no configured // default). Callers must treat an unknown cap as "stacks freely" rather than // "one per slot" — otherwise stackables we lack data for (e.g. Ammo) get // counted as one inventory slot per unit. See effectiveStackMax. func resolveStackMax(ctx context.Context, template string, quality int64) (stackMax int64, known bool, err error) { if itemData.Items != nil { if rule, ok := itemData.Items[strings.ToLower(template)]; ok && rule.StackMax > 0 { return rule.StackMax, true, nil } } var maxStack int64 if err := globalDB.QueryRow(ctx, ` SELECT COALESCE(MAX(stack_size), 0) FROM dune.items WHERE template_id = $1::text AND quality_level = $2::bigint`, template, quality).Scan(&maxStack); err != nil { return 0, false, err } if maxStack > 0 { return maxStack, true, nil } if itemData.DefaultStackMax > 0 { return itemData.DefaultStackMax, true, nil } return 1, false, nil } // effectiveStackMax picks the stack size to assume for slot/stack planning. // When the real cap is unknown (or nonsensical), items are assumed to stack // into the requested quantity — i.e. one slot — instead of one slot per unit. // This keeps qty=1 grants unchanged while preventing unknown stackables like // ammo from being rejected as needing thousands of free slots. func effectiveStackMax(stackMax int64, known bool, qty int64) int64 { if !known || stackMax < 1 { return qty } return stackMax } func resolveItemVolume(ctx context.Context, template string) (float64, error) { if itemData.Items != nil { if rule, ok := itemData.Items[strings.ToLower(template)]; ok { // volume=0 is valid (item takes no inventory space). return rule.Volume, nil } } var vol pgtype.Float8 err := globalDB.QueryRow(ctx, ` SELECT MAX(volume_override) FROM dune.items WHERE template_id = $1::text AND volume_override IS NOT NULL`, template).Scan(&vol) if err != nil { return 0, err } if vol.Valid && vol.Float64 > 0 { return vol.Float64, nil } if itemData.DefaultVolume > 0 { return itemData.DefaultVolume, nil } return 0, nil // unknown volume — treat as zero (no space consumed) } func formatCurrencyIDs(ids []int16) string { parts := make([]string, 0, len(ids)) for _, id := range ids { parts = append(parts, fmt.Sprintf("%d", id)) } return strings.Join(parts, ", ") } func resolveScripCurrencyID(ctx context.Context) (int16, error) { if scripCurrencyID >= 0 { return int16(scripCurrencyID), nil } rows, err := globalDB.Query(ctx, ` SELECT currency_id, COALESCE(SUM(balance), 0) AS total FROM dune.player_virtual_currency_balances WHERE currency_id <> dune.get_solaris_id() GROUP BY currency_id ORDER BY total DESC, currency_id`) if err != nil { return 0, err } defer rows.Close() var ids []int16 for rows.Next() { var id int16 var total int64 if err := rows.Scan(&id, &total); err != nil { continue } ids = append(ids, id) } if rows.Err() != nil { return 0, rows.Err() } if len(ids) == 1 { return ids[0], nil } if len(ids) == 0 { return 0, fmt.Errorf("no non-solaris currency rows found; pass -scripcurrency") } return 0, fmt.Errorf("multiple non-solaris currency IDs found (%s); pass -scripcurrency", formatCurrencyIDs(ids)) } // factionDataEntry mirrors one element of // actors.properties.FactionPlayerComponent.m_FactionDataArray — the cache the // in-game faction UI reads for rank display and per-territory vendor gating. // Shape verified live: {"Faction":{"Name":...},"timestamp":,"ReputationAmount":}. type factionDataEntry struct { Faction factionDataName `json:"Faction"` Timestamp float64 `json:"timestamp"` ReputationAmount int32 `json:"ReputationAmount"` } type factionDataName struct { Name string `json:"Name"` } // greatHouseFactions are the two houses the in-game faction rank/vendor system // tracks. m_FactionDataArray always lists both so each territory's vendor reads // its own house's standing (a missing house reads as 0). Verified in-game: // Arrakeen reads the Atreides entry, Harko Village reads the Harkonnen one. var greatHouseFactions = []struct { id int16 name string }{ {1, "Atreides"}, {2, "Harkonnen"}, } // buildFactionDataArray produces the canonical m_FactionDataArray from the // player's per-faction reputation. It always emits both great houses (missing // → 0) and ignores non-great-house factions (None=3, Smuggler=4). func buildFactionDataArray(reps map[int16]int32, ts float64) []factionDataEntry { out := make([]factionDataEntry, 0, len(greatHouseFactions)) for _, h := range greatHouseFactions { out = append(out, factionDataEntry{ Faction: factionDataName{Name: h.name}, Timestamp: ts, ReputationAmount: reps[h.id], }) } return out } // factionComponentExecer is the minimal surface writeFactionComponent needs — // satisfied by *pgxpool.Pool and pgx.Tx (and stubbed in tests). type factionComponentExecer interface { Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) } // factionComponentDB adds row reads for syncFactionComponent. type factionComponentDB interface { factionComponentExecer Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) } // factionComponentRebuildSQL replaces m_FactionDataArray wholesale. The previous // per-element update no-oped silently when the array was empty (the state of // every fresh character) — the bug that made faction unlocks display as rank // "Outsider" in-game despite correct rep/alignment/tags in the DB. const factionComponentRebuildSQL = ` UPDATE dune.actors SET properties = jsonb_set( properties, '{FactionPlayerComponent,m_FactionDataArray}', $1::jsonb, true) WHERE id = $2` // writeFactionComponent rebuilds the controller actor's m_FactionDataArray. // Returns an error if no row was updated — turning the old silent no-op into a // loud failure so a misleading success can never be reported again. func writeFactionComponent(ctx context.Context, exec factionComponentExecer, controllerID int64, arr []factionDataEntry) error { payload, err := json.Marshal(arr) if err != nil { return fmt.Errorf("marshal faction data array: %w", err) } tag, err := exec.Exec(ctx, factionComponentRebuildSQL, payload, controllerID) if err != nil { return fmt.Errorf("rebuild FactionPlayerComponent: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("controller actor %d not found; FactionPlayerComponent not updated", controllerID) } return nil } // syncFactionComponent reads the controller's current great-house reputation and // rebuilds m_FactionDataArray to match. Call after any // set_player_faction_reputation so the in-game rank/vendor UI reflects the DB. // Accepts *pgxpool.Pool or a pgx.Tx. func syncFactionComponent(ctx context.Context, db factionComponentDB, controllerID int64) error { rows, err := db.Query(ctx, ` SELECT faction_id, reputation_amount FROM dune.player_faction_reputation WHERE actor_id = $1 AND faction_id IN (1, 2)`, controllerID) if err != nil { return fmt.Errorf("read faction reputation: %w", err) } defer rows.Close() reps := make(map[int16]int32, 2) for rows.Next() { var fid int16 var rep int32 if err := rows.Scan(&fid, &rep); err != nil { return fmt.Errorf("scan faction reputation: %w", err) } reps[fid] = rep } if err := rows.Err(); err != nil { return fmt.Errorf("iterate faction reputation: %w", err) } arr := buildFactionDataArray(reps, float64(time.Now().UnixNano())/1e9) return writeFactionComponent(ctx, db, controllerID, arr) } func applyFactionRepDelta(ctx context.Context, actorID int64, factionID int16, delta int32) msgMutate { // Route through set_player_faction_reputation which handles tier tags correctly. // First get current rep to compute the new absolute value. var currentRep int32 _ = globalDB.QueryRow(ctx, ` SELECT COALESCE(reputation_amount, 0) FROM dune.player_faction_reputation WHERE actor_id = $1::bigint AND faction_id = $2::smallint`, actorID, factionID).Scan(¤tRep) newRep := currentRep + delta if newRep < 0 { newRep = 0 } if newRep > factionRepCap { newRep = factionRepCap } _, err := globalDB.Exec(ctx, ` SELECT dune.set_player_faction_reputation($1::bigint, $2::smallint, $3::integer)`, actorID, factionID, newRep) if err != nil { return msgMutate{err: fmt.Errorf("set_player_faction_reputation: %w", err)} } if err = syncFactionComponent(ctx, globalDB, actorID); err != nil { return msgMutate{err: fmt.Errorf("update FactionPlayerComponent rep: %w", err)} } tier := repToTier(newRep) fName := factionDisplayName(factionID) return msgMutate{ok: fmt.Sprintf( "Set %s rep to %d → tier %d (%s) for actor %d", fName, newRep, tier, factionTierName(factionID, tier), actorID)} } // factionRepCap is the maximum reputation for any faction (tier 20). const factionRepCap = int32(12474) // factionTierThresholds[i] = cumulative rep required to reach tier i (0–20). // Both Atreides and Harkonnen share identical thresholds. var factionTierThresholds = [21]int32{ 0, 99, 249, 499, 999, 1999, 2224, 2524, 2899, 3349, 3874, 4474, 5149, 5899, 6724, 7624, 8599, 9649, 10774, 11974, 12474, } // repToTier returns the tier (0–20) for a given reputation amount. func repToTier(rep int32) int { tier := 0 for i := 1; i <= 20; i++ { if rep >= factionTierThresholds[i] { tier = i } else { break } } return tier } // factionTierName returns the named tier string for a faction+tier combination. func factionTierName(factionID int16, tier int) string { named := map[int]string{ 0: "Outsider", 1: "Mercenary", 2: "Recruit", 3: "Contractor", 4: "Agent", 5: "House Operator", } if tier20 := map[int16]string{1: "Envoy", 2: "Enforcer"}; tier == 20 { if n, ok := tier20[factionID]; ok { return n } } if n, ok := named[tier]; ok { return n } return fmt.Sprintf("Tier %d", tier) } func factionDisplayName(id int16) string { switch id { case 1: return "Atreides" case 2: return "Harkonnen" case 3: return "None" case 4: return "Smuggler" default: return fmt.Sprintf("Faction%d", id) } } func cmdSetFactionTier(actorID int64, factionID int16, tier int) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if tier < 0 || tier > 20 { return msgMutate{err: fmt.Errorf("tier must be 0–20")} } // Nudge +1 over the threshold — the game UI floors at the threshold // (rep == threshold shows the tier below), except at tier 0 where 0 is // the legitimate minimum. rep := factionTierThresholds[tier] if tier > 0 { rep++ } ctx := context.Background() // Align the player to this house first (Gap 1): set-tier on an unaligned // character previously wrote rep with no player_faction row, so the game // treated them as unaligned. change_player_faction upserts alignment and // fires pg_notify('faction_notify_channel'). neutral_faction_id = 3 ("None"). if _, err := globalDB.Exec(ctx, `SELECT dune.change_player_faction($1::bigint, $2::smallint, 3::smallint, NOW()::timestamp)`, actorID, factionID); err != nil { return msgMutate{err: fmt.Errorf("change_player_faction: %w", err)} } if _, err := globalDB.Exec(ctx, `SELECT dune.set_player_faction_reputation($1, $2, $3)`, actorID, factionID, rep); err != nil { return msgMutate{err: fmt.Errorf("set_player_faction_reputation: %w", err)} } if err := syncFactionComponent(ctx, globalDB, actorID); err != nil { return msgMutate{err: fmt.Errorf("update FactionPlayerComponent rep: %w", err)} } fName := factionDisplayName(factionID) return msgMutate{ok: fmt.Sprintf( "Set %s to tier %d (%s) — rep %d for actor %d", fName, tier, factionTierName(factionID, tier), rep, actorID)} } } func cmdFetchItemTemplates() Msg { if globalDB == nil { return msgItemTemplates{} } rows, err := globalDB.Query(context.Background(), `SELECT DISTINCT template_id FROM dune.items ORDER BY template_id`) if err != nil { return msgItemTemplates{} } defer rows.Close() var templates []string for rows.Next() { var t string if rows.Scan(&t) == nil { templates = append(templates, t) } } return msgItemTemplates{templates: templates} } // ── database tab types and fetch functions ──────────────────────────────────── type tableRow struct { Name string RowCount int64 } type columnInfo struct { Name string DataType string Nullable string } type msgTables struct { rows []tableRow err error } type msgDescribe struct { table string cols []columnInfo err error } type msgSample struct { table string headers []string rows [][]string err error } type msgSearchCols struct { headers []string rows [][]string err error } func cmdFetchTables() Msg { if globalDB == nil { return msgTables{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT relname, COALESCE(n_live_tup, 0) FROM pg_stat_user_tables ORDER BY relname`) if err != nil { return msgTables{err: err} } defer rows.Close() var result []tableRow for rows.Next() { var r tableRow if err := rows.Scan(&r.Name, &r.RowCount); err != nil { return msgTables{err: err} } result = append(result, r) } return msgTables{rows: result} } func cmdDescribeTable(tbl string) Cmd { return func() Msg { if globalDB == nil { return msgDescribe{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT column_name, data_type, CASE is_nullable WHEN 'YES' THEN 'null' ELSE 'not null' END FROM information_schema.columns WHERE table_schema = $1::text AND table_name = $2::text ORDER BY ordinal_position`, dbSchema, tbl) if err != nil { return msgDescribe{table: tbl, err: err} } defer rows.Close() var cols []columnInfo for rows.Next() { var c columnInfo if err := rows.Scan(&c.Name, &c.DataType, &c.Nullable); err != nil { return msgDescribe{table: tbl, err: err} } cols = append(cols, c) } if err := rows.Err(); err != nil { return msgDescribe{table: tbl, err: err} } return msgDescribe{table: tbl, cols: cols} } } func sampleTableQuery(tbl string, limit int) string { // Sanitize table name defensively even though tbl comes from pg_stat_user_tables. // pgx.Identifier handles quoting and escaping to prevent SQL injection. safeTable := pgx.Identifier{dbSchema, tbl}.Sanitize() return fmt.Sprintf("SELECT * FROM %s LIMIT %d", safeTable, limit) } func sampleTableHeaders(rows pgx.Rows) []string { descriptions := rows.FieldDescriptions() headers := make([]string, 0, len(descriptions)) for _, description := range descriptions { headers = append(headers, description.Name) } return headers } func formatSampleRow(values []any) []string { row := make([]string, 0, len(values)) for _, value := range values { row = append(row, fmt.Sprintf("%v", value)) } return row } func sampleTableRows(rows pgx.Rows) ([][]string, error) { result := make([][]string, 0) for rows.Next() { values, err := rows.Values() if err != nil { return nil, err } result = append(result, formatSampleRow(values)) } return result, nil } func cmdSampleTable(tbl string, limit int) Cmd { return func() Msg { if globalDB == nil { return msgSample{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), sampleTableQuery(tbl, limit)) if err != nil { return msgSample{table: tbl, err: err} } defer rows.Close() headers := sampleTableHeaders(rows) result, err := sampleTableRows(rows) if err != nil { return msgSample{table: tbl, err: err} } if err := rows.Err(); err != nil { return msgSample{table: tbl, err: err} } return msgSample{table: tbl, headers: headers, rows: result} } } func cmdSearchColumns(term string) Cmd { return func() Msg { if globalDB == nil { return msgSearchCols{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = $1::text AND (column_name ILIKE $2::text OR table_name ILIKE $2::text) ORDER BY table_name, column_name`, dbSchema, "%"+term+"%") if err != nil { return msgSearchCols{err: err} } defer rows.Close() headers := []string{"table", "column", "type"} var result [][]string for rows.Next() { var table, col, dtype string if err := rows.Scan(&table, &col, &dtype); err != nil { return msgSearchCols{err: err} } result = append(result, []string{table, col, dtype}) } if err := rows.Err(); err != nil { return msgSearchCols{err: err} } return msgSearchCols{headers: headers, rows: result} } } // ── journey / progression commands ─────────────────────────────────────────── func cmdFetchJourneyNodes(accountID int64) Cmd { return func() Msg { if globalDB == nil { return msgJourney{err: fmt.Errorf("not connected")} } // Cache hit? journeyCacheMu.RLock() entry, ok := journeyCache[accountID] journeyCacheMu.RUnlock() if ok && time.Since(entry.cached) < journeyCacheTTL { return msgJourney{rows: entry.nodes} } rows, err := globalDB.Query(context.Background(), ` SELECT story_node_id, (complete_condition_state = 'true'::jsonb) AS is_complete, (reveal_condition_state = 'true'::jsonb) AS is_revealed, has_pending_reward FROM dune.journey_story_node WHERE account_id = $1 ORDER BY story_node_id`, accountID) if err != nil { return msgJourney{err: err} } defer rows.Close() var nodes []journeyNode for rows.Next() { var n journeyNode var isComplete, isRevealed pgtype.Bool if err := rows.Scan(&n.NodeID, &isComplete, &isRevealed, &n.HasPendingReward); err != nil { continue } n.IsComplete = isComplete.Bool n.IsRevealed = isRevealed.Bool nodes = append(nodes, n) } if err := rows.Err(); err != nil { return msgJourney{err: err} } journeyCacheMu.Lock() journeyCache[accountID] = journeyCacheEntry{nodes: nodes, cached: time.Now()} journeyCacheMu.Unlock() return msgJourney{rows: nodes} } } // tagsForJourneyNodeSubtree returns the union of m_TagsToAdd for the named // node and every descendant (matching the SQL completion behavior in // cmdCompleteJourneyNode which flips children too). Order preserved, deduped. func tagsForJourneyNodeSubtree(nodeID string) []string { if tagsData.JourneyNodeTags == nil { return nil } prefix := nodeID + "." seen := map[string]bool{} var out []string add := func(tags []string) { for _, t := range tags { if !seen[t] { seen[t] = true out = append(out, t) } } } add(tagsData.JourneyNodeTags[nodeID]) for id, tags := range tagsData.JourneyNodeTags { if strings.HasPrefix(id, prefix) { add(tags) } } return out } // tierBumpFromTags scans applied tags for Faction..Tier (N ∈ [0,5]) and // returns the highest implied reputation per faction. Used to fire the rep // promotion side effect when admin completion applies a tier tag. func tierBumpFromTags(tags []string) map[string]int32 { out := map[string]int32{} // e.g. "Faction.Atreides.Tier3" for _, t := range tags { const prefix = "Faction." if !strings.HasPrefix(t, prefix) { continue } rest := t[len(prefix):] dot := strings.IndexByte(rest, '.') if dot <= 0 { continue } faction := rest[:dot] tail := rest[dot+1:] if !strings.HasPrefix(tail, "Tier") { continue } n, err := strconv.Atoi(tail[len("Tier"):]) if err != nil || n < 0 || n > 5 { continue } // +1 over the tier threshold so the in-game UI doesn't floor a tier low // (rep == threshold displays the tier below). Tier 0 stays at 0 — it's // the legitimate starting state. rep := factionTierThresholds[n] if n > 0 { rep++ } if rep > out[faction] { out[faction] = rep } } return out } func factionIDByName(name string) int16 { switch name { case "Atreides": return 1 case "Harkonnen": return 2 case "None": return 3 case "Smuggler": return 4 } return 0 } func cmdCompleteJourneyNode(accountID int64, nodeID string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() // Complete the node itself plus all child nodes (nodeID + ".anything"). // The game checks sub-nodes to determine quest completion state. res, err := globalDB.Exec(ctx, ` UPDATE dune.journey_story_node SET complete_condition_state = 'true'::jsonb, reveal_condition_state = 'true'::jsonb WHERE account_id = $1 AND (story_node_id = $2 OR story_node_id LIKE $2 || '.%')`, accountID, nodeID) if err != nil { return msgMutate{err: fmt.Errorf("complete node: %w", err)} } updated := res.RowsAffected() if updated == 0 { // Node doesn't exist yet — insert it. _, err = globalDB.Exec(ctx, ` INSERT INTO dune.journey_story_node (account_id, story_node_id, has_pending_reward, complete_condition_state, reveal_condition_state, fail_condition_state, metadata_state, reset_group) VALUES ($1, $2, false, 'true'::jsonb, 'true'::jsonb, '{}'::jsonb, '{}'::jsonb, 'Default'::dune.JourneyStoryResetGroup)`, accountID, nodeID) if err != nil { return msgMutate{err: fmt.Errorf("insert node: %w", err)} } updated = 1 } // Apply tags that in-game completion of the node + its descendants // would emit (via m_TagsToAdd). Without this the DB row is flipped // but the player is missing the side effects the game would have // written — which is why journey-only completion historically did not // "stick" without login/logout cycles. appliedTags := tagsForJourneyNodeSubtree(nodeID) extra, err := applyTagsWithTierBump(ctx, accountID, appliedTags) if err != nil { return msgMutate{err: err} } svExtra, svErr := maybeGrantSpiceVision(ctx, accountID, nodeID) if svErr != nil { return msgMutate{err: svErr} } extra += svExtra return msgMutate{ok: fmt.Sprintf("Completed %s + %d node(s)%s — takes effect on next login", nodeID, updated, extra)} } } // applyTagsWithTierBump writes `tags` via dune.update_player_tags and, for any // Faction..Tier (N ∈ 0–5) it sees, also raises that faction's rep + the // FactionPlayerComponent ReputationAmount on the controller actor so the // in-game rank UI reflects the promotion. Never lowers existing rep. // Returns a short " , +K tag(s), bumped rep for N faction(s)" fragment for // inclusion in the caller's success message (empty when no tags applied). func applyTagsWithTierBump(ctx context.Context, accountID int64, tags []string) (string, error) { if len(tags) == 0 { return "", nil } if _, err := globalDB.Exec(ctx, `SELECT dune.update_player_tags($1, $2::text[], '{}'::text[])`, accountID, tags); err != nil { return "", fmt.Errorf("apply tags: %w", err) } extra := fmt.Sprintf(", +%d tag(s)", len(tags)) bumps := tierBumpFromTags(tags) if len(bumps) == 0 { return extra, nil } var controllerID int64 _ = globalDB.QueryRow(ctx, ` SELECT player_controller_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&controllerID) if controllerID == 0 { // Fresh character without a player_state row — can't bump rep yet. // Tags landed, the rep side effect will have to wait until the // character first logs in. Surface in the message. return extra + ", rep bump skipped (no controller yet)", nil } bumped := 0 for faction, rep := range bumps { fid := factionIDByName(faction) if fid == 0 { continue } var current int32 _ = globalDB.QueryRow(ctx, ` SELECT COALESCE(reputation_amount, 0) FROM dune.player_faction_reputation WHERE actor_id = $1 AND faction_id = $2`, controllerID, fid).Scan(¤t) if current >= rep { continue } if _, err := globalDB.Exec(ctx, `SELECT dune.set_player_faction_reputation($1::bigint, $2::smallint, $3::integer)`, controllerID, fid, rep); err != nil { return "", fmt.Errorf("bump %s rep: %w", faction, err) } bumped++ } if bumped > 0 { // Rebuild the component once from the now-updated rep table (Gap 2 fix: // the old per-faction update no-oped on an empty m_FactionDataArray). if err := syncFactionComponent(ctx, globalDB, controllerID); err != nil { return "", fmt.Errorf("sync FactionPlayerComponent: %w", err) } extra += fmt.Sprintf(", bumped rep for %d faction(s)", bumped) } return extra, nil } // resolveContractTags resolves a contract id (full DA_CT_ name or short alias) // to its AddedFlagsOnCompletion list. Returns the resolved canonical name and // the tags, or ("", nil, err) if unknown. func resolveContractTags(contractID string) (string, []string, error) { name := contractID if full, ok := tagsData.ContractAliases[contractID]; ok { name = full } tags, ok := tagsData.ContractTags[name] if !ok || len(tags) == 0 { return "", nil, fmt.Errorf("unknown contract %q (check tags-data.json)", contractID) } return name, tags, nil } // cmdCompleteContract applies the AddedFlagsOnCompletion tags for one contract. func cmdCompleteContract(accountID int64, contractID string) Cmd { return cmdCompleteContracts(accountID, []string{contractID}) } // cmdCompleteContracts applies the union of AddedFlagsOnCompletion across // multiple contracts in one go — one update_player_tags call, one tier-bump // pass, plus any SkillsKeyRewards skill-block unlocks. Unknown contracts // cause the whole batch to fail before any write so the operation is // all-or-nothing. func cmdCompleteContracts(accountID int64, contractIDs []string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if err := validateContractMutationInput(accountID, contractIDs); err != nil { return msgMutate{err: err} } set, err := buildContractRemovalSet(contractIDs) if err != nil { return msgMutate{err: err} } ctx := context.Background() extra, err := applyTagsWithTierBump(ctx, accountID, set.removeTags) if err != nil { return msgMutate{err: err} } grantedExtra, err := applyContractSkillGrants(ctx, accountID, set.removeSkills) if err != nil { return msgMutate{err: err} } extra += grantedExtra // Strip any in-progress ContractItem rows so the in-game quest // tracker doesn't keep showing the conditions for a contract we just // force-completed. ContractName.Name uses the short alias form // (no DA_CT_ prefix). shortNames := contractShortNames(set.resolvedNames) dismissedExtra, err := dismissActiveContracts(ctx, accountID, shortNames) if err != nil { return msgMutate{err: err} } extra += dismissedExtra summary := contractBatchSummary(set.resolvedNames) return msgMutate{ok: fmt.Sprintf("Applied %s%s — takes effect on next login", summary, extra)} } } func validateContractMutationInput(accountID int64, contractIDs []string) error { if accountID == 0 { return fmt.Errorf("account ID required") } if len(contractIDs) == 0 { return fmt.Errorf("at least one contract required") } return nil } type contractRemovalSet struct { resolvedNames []string removeTags []string removeSkills []string } func buildContractRemovalSet(contractIDs []string) (contractRemovalSet, error) { seenTag := map[string]bool{} seenSkill := map[string]bool{} set := contractRemovalSet{ resolvedNames: make([]string, 0, len(contractIDs)), removeTags: make([]string, 0, len(contractIDs)), removeSkills: make([]string, 0, len(contractIDs)), } for _, id := range contractIDs { name, tags, err := resolveContractTags(id) if err != nil { return contractRemovalSet{}, err } set.resolvedNames = append(set.resolvedNames, name) for _, tag := range tags { if seenTag[tag] { continue } seenTag[tag] = true set.removeTags = append(set.removeTags, tag) } for _, skill := range tagsData.ContractSkillGrants[name] { if seenSkill[skill] { continue } seenSkill[skill] = true set.removeSkills = append(set.removeSkills, skill) } } return set, nil } func applyContractSkillGrants(ctx context.Context, accountID int64, skills []string) (string, error) { if len(skills) == 0 { return "", nil } return grantSkillBlocks(ctx, accountID, skills) } func contractShortNames(resolvedNames []string) []string { shortNames := make([]string, 0, len(resolvedNames)) for _, full := range resolvedNames { shortNames = append(shortNames, strings.TrimPrefix(full, "DA_CT_")) } return shortNames } func removeContractTags(ctx context.Context, accountID int64, removeTags []string) error { if len(removeTags) == 0 { return nil } if _, err := globalDB.Exec(ctx, `SELECT dune.update_player_tags($1, '{}'::text[], $2::text[])`, accountID, removeTags); err != nil { return fmt.Errorf("remove tags: %w", err) } return nil } func loadContractPawnID(ctx context.Context, accountID int64) int64 { var pawnID int64 _ = globalDB.QueryRow(ctx, `SELECT player_pawn_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&pawnID) return pawnID } func stripContractSkillBlocks(ctx context.Context, pawnID int64, removeSkills []string) (int, error) { if pawnID == 0 || len(removeSkills) == 0 { return 0, nil } stripped := 0 for _, skill := range removeSkills { key := fmt.Sprintf(`(TagName="%s")`, skill) tag, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities fe SET components = jsonb_set( fe.components, ARRAY['FLevelComponent','1','ModuleData'], (fe.components->'FLevelComponent'->1->'ModuleData') - $2::text) WHERE fe.entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE actor_id = $1 AND slot_name = 'DuneCharacter' ) AND COALESCE( (fe.components->'FLevelComponent'->1->'ModuleData'->$2->>'SkillPointsSpent')::int, 0 ) <= 1`, pawnID, key) if err != nil { return 0, fmt.Errorf("strip %s: %w", skill, err) } if tag.RowsAffected() > 0 { stripped++ } } return stripped, nil } func contractBatchSummary(resolvedNames []string) string { summary := resolvedNames[0] if len(resolvedNames) > 1 { summary = fmt.Sprintf("%d contracts", len(resolvedNames)) } return summary } // cmdReverseContracts removes the AddedFlagsOnCompletion tags and strips the // Skills.Key.* ModuleData entries that cmdCompleteContracts wrote. Skill blocks // are only removed when SkillPointsSpent <= 1 — branches the player genuinely // levelled beyond the admin grant are left intact. func cmdReverseContracts(accountID int64, contractIDs []string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if err := validateContractMutationInput(accountID, contractIDs); err != nil { return msgMutate{err: err} } set, err := buildContractRemovalSet(contractIDs) if err != nil { return msgMutate{err: err} } ctx := context.Background() if err := removeContractTags(ctx, accountID, set.removeTags); err != nil { return msgMutate{err: err} } pawnID := loadContractPawnID(ctx, accountID) stripped, err := stripContractSkillBlocks(ctx, pawnID, set.removeSkills) if err != nil { return msgMutate{err: err} } summary := contractBatchSummary(set.resolvedNames) return msgMutate{ok: fmt.Sprintf( "Reversed %s: removed %d tag(s), stripped %d skill block(s) — takes effect on next login", summary, len(set.removeTags), stripped)} } } // cmdResetJobSkills removes every ModuleData entry whose SkillArea matches // the named job — Key blocks, Abilities, Attributes, Perks — fully nuking // that class's skill tree. Key-block removal alone leaves orphaned ability // rows (e.g. SuspensorGrenade_Reduction lingers after Skills.Key.Trooper1 // is gone) which the game still treats as refundable for 1 SP each (the // "phantom SP" bug). Removing every SkillArea-matching module avoids that. func cmdResetJobSkills(accountID int64, job string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if accountID == 0 { return msgMutate{err: fmt.Errorf("account ID required")} } modules := tagsData.JobAllModules[job] if len(modules) == 0 { return msgMutate{err: fmt.Errorf("unknown job %q (check tags-data.json job_all_modules)", job)} } ctx := context.Background() var pawnID int64 _ = globalDB.QueryRow(ctx, ` SELECT player_pawn_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&pawnID) if pawnID == 0 { return msgMutate{err: fmt.Errorf("no pawn for account %d", accountID)} } // Build the (TagName="...") keyed names in one pass and use the // jsonb minus-text[] operator to drop them all in a single UPDATE. keys := make([]string, len(modules)) for i, m := range modules { keys[i] = fmt.Sprintf(`(TagName="%s")`, m) } tag, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities fe SET components = jsonb_set( fe.components, ARRAY['FLevelComponent','1','ModuleData'], (fe.components->'FLevelComponent'->1->'ModuleData') - $2::text[]) WHERE fe.entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE actor_id = $1 AND slot_name = 'DuneCharacter' )`, pawnID, keys) if err != nil { return msgMutate{err: fmt.Errorf("reset %s tree: %w", job, err)} } if tag.RowsAffected() == 0 { return msgMutate{ok: fmt.Sprintf("Reset %s skill tree — no ModuleData on pawn", job)} } return msgMutate{ok: fmt.Sprintf("Reset %s skill tree — scanned %d module slot(s)", job, len(modules))} } } // starterAbilityByJob is the canonical tier-1 starter ability the game // auto-grants on character creation for each class — empirically observed // for BG (VoiceCompel) and Trooper (SuspensorGrenade_Reduction); the others // derived from DT_TrainingModules.json by picking the unique // PrereqModuleTags_And = [Skills.Key.1] ability at GridPosition (3,0), // which is the slot the game uses for the "middle of the first row" starter. var starterAbilityByJob = map[string]string{ "BeneGesserit": "Skills.Ability.VoiceCompel", "Mentat": "Skills.Ability.PoisonCapsuleLauncher", "Planetologist": "Skills.Ability.SuspensorPad", "Swordmaster": "Skills.Ability.DeflectionSlow", "Trooper": "Skills.Ability.SuspensorGrenade_Reduction", } func resolveStarterClassAbility(job string) (string, error) { if _, ok := tagsData.JobSkillBlocks[job]; !ok { return "", fmt.Errorf("unknown job %q", job) } ability, ok := starterAbilityByJob[job] if !ok { return "", fmt.Errorf("no starter ability mapping for %q", job) } return ability, nil } func loadPawnIDForAccount(ctx context.Context, accountID int64) (int64, error) { var pawnID int64 _ = globalDB.QueryRow(ctx, ` SELECT player_pawn_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&pawnID) if pawnID == 0 { return 0, fmt.Errorf("no pawn for account %d", accountID) } return pawnID, nil } func loadStarterTagForPawn(ctx context.Context, pawnID int64) string { var starterTag string _ = globalDB.QueryRow(ctx, ` SELECT fe.components->'FLevelComponent'->1->'StarterSkillTreeTag'->>'TagName' FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id WHERE afe.actor_id = $1 AND afe.slot_name = 'DuneCharacter'`, pawnID).Scan(&starterTag) return starterTag } func starterKeysToRemove(oldStarterTag, newJob string) []string { if !strings.HasPrefix(oldStarterTag, "Skills.Key.") || !strings.HasSuffix(oldStarterTag, "1") { return nil } oldJob := strings.TrimSuffix(strings.TrimPrefix(oldStarterTag, "Skills.Key."), "1") if oldJob == "" || oldJob == newJob { return nil } keys := []string{fmt.Sprintf(`(TagName="%s")`, oldStarterTag)} if oldAbility, ok := starterAbilityByJob[oldJob]; ok { keys = append(keys, fmt.Sprintf(`(TagName="%s")`, oldAbility)) } return keys } func starterClassTagAndKeys(job, ability string) (starterTag, starterKey, abilityKey string) { starterTag = fmt.Sprintf("Skills.Key.%s1", job) starterKey = fmt.Sprintf(`(TagName="%s")`, starterTag) abilityKey = fmt.Sprintf(`(TagName="%s")`, ability) return starterTag, starterKey, abilityKey } func applyStarterClassUpdate( ctx context.Context, pawnID int64, newStarterTag, newStarterKey string, keysToRemove []string, newAbilityKey string, ) error { _, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities fe SET components = jsonb_set( jsonb_set( jsonb_set( jsonb_set( fe.components, ARRAY['FLevelComponent','1','ModuleData'], (fe.components->'FLevelComponent'->1->'ModuleData') - $4::text[]), ARRAY['FLevelComponent','1','StarterSkillTreeTag','TagName'], to_jsonb($2::text)), ARRAY['FLevelComponent','1','ModuleData',$3], '{"SkillPointsSpent": 1}'::jsonb, true), ARRAY['FLevelComponent','1','ModuleData',$5], '{"SkillPointsSpent": 1}'::jsonb, true) WHERE fe.entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE actor_id = $1 AND slot_name = 'DuneCharacter' )`, pawnID, newStarterTag, newStarterKey, keysToRemove, newAbilityKey) if err != nil { return fmt.Errorf("set starter tag: %w", err) } return nil } func formatStarterClassMessage(job, newStarterTag, newAbility string, removedCount int) string { msg := fmt.Sprintf("Starter class set to %s (%s + %s active)", job, newStarterTag, newAbility) if removedCount > 0 { msg += fmt.Sprintf(", cleared previous starter (%d module(s))", removedCount) } return msg } // cmdSetStarterClass swaps the player's starter class: // 1. removes the previous starter's Skills.Key.1 block + its starter // ability from ModuleData (so you don't end up with two starters // stacked after switching), then // 2. writes the new StarterSkillTreeTag pointer, // 3. activates the new Skills.Key.1 block at SpSpent: 1, // 4. grants the new tier-1 starter ability at SpSpent: 1. // // Result on next login: only one class is recognised as starter, with its // canonical first ability already learned. func cmdSetStarterClass(accountID int64, job string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if accountID == 0 { return msgMutate{err: fmt.Errorf("account ID required")} } newAbility, err := resolveStarterClassAbility(job) if err != nil { return msgMutate{err: err} } ctx := context.Background() pawnID, err := loadPawnIDForAccount(ctx, accountID) if err != nil { return msgMutate{err: err} } // Look up the current starter so we can deactivate it. Format is // "Skills.Key.1"; we strip the prefix/suffix to recover the // job name and look up its starter-ability for removal. oldStarterTag := loadStarterTagForPawn(ctx, pawnID) keysToRemove := starterKeysToRemove(oldStarterTag, job) newStarterTag, newStarterKey, newAbilityKey := starterClassTagAndKeys(job, newAbility) // One chained jsonb update: strip old keys, write new tag, activate // new starter block, grant new starter ability. - operator on an // empty text[] is a no-op so it's safe when there's no old starter // to clean up (e.g. fresh character with StarterSkillTreeTag=None). if err := applyStarterClassUpdate(ctx, pawnID, newStarterTag, newStarterKey, keysToRemove, newAbilityKey); err != nil { return msgMutate{err: err} } return msgMutate{ok: formatStarterClassMessage(job, newStarterTag, newAbility, len(keysToRemove))} } } // cmdGrantJobSkills unlocks every bExternal Skills.Key.* module in the named // job's skill tree (e.g. "Trooper" → Trooper1/2/3 + CapstoneGadgets + // CapstoneWeaponry + CapstoneSuspensorTech). Only ~⅓ of these blocks are // contract-granted via SkillsKeyRewards; the rest are normally unlocked by // trainer dialogue or auto on level progression, so the admin Unlock Trainer // action calls this after the contract batch to bypass those gates. func cmdGrantJobSkills(accountID int64, job string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if accountID == 0 { return msgMutate{err: fmt.Errorf("account ID required")} } blocks := tagsData.JobSkillBlocks[job] if len(blocks) == 0 { return msgMutate{err: fmt.Errorf("unknown job %q (check tags-data.json job_skill_blocks)", job)} } ctx := context.Background() extra, err := grantSkillBlocks(ctx, accountID, blocks) if err != nil { return msgMutate{err: err} } return msgMutate{ok: fmt.Sprintf("Unlocked %s skill tree%s — takes effect on next login", job, extra)} } } // dismissActiveContracts deletes any ContractItem inventory entries whose // stats.FContractItemStats.ContractName.Name matches one of shortNames. // Active contract items drive the in-game quest tracker, so after force- // completing a contract via tags we need to remove the live instance // otherwise the player keeps seeing "Deploy Assault Seekers" / etc as // outstanding. No-op if the player never had the contract active. func dismissActiveContracts(ctx context.Context, accountID int64, shortNames []string) (string, error) { if len(shortNames) == 0 { return "", nil } var pawnID int64 _ = globalDB.QueryRow(ctx, ` SELECT player_pawn_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&pawnID) if pawnID == 0 { return "", nil } tag, err := globalDB.Exec(ctx, ` DELETE FROM dune.items WHERE template_id = 'ContractItem' AND inventory_id IN ( SELECT id FROM dune.inventories WHERE actor_id = $1 AND inventory_type = 29 ) AND stats->'FContractItemStats'->1->'ContractName'->>'Name' = ANY($2::text[])`, pawnID, shortNames) if err != nil { return "", fmt.Errorf("dismiss active contracts: %w", err) } n := tag.RowsAffected() if n == 0 { return "", nil } return fmt.Sprintf(", dismissed %d active contract(s)", n), nil } // grantSkillBlocks ensures each Skills.Key. entry exists in the player's // FLevelComponent.ModuleData with SkillPointsSpent: 1 (the format the game // itself writes when a trainer's SkillsKeyRewards fires). If an entry already // exists it's left alone — preserves any further SP the player may have // already spent on that branch's child nodes. Returns a short fragment to // append to the caller's success message. func grantSkillBlocks(ctx context.Context, accountID int64, skillKeys []string) (string, error) { var pawnID int64 _ = globalDB.QueryRow(ctx, ` SELECT player_pawn_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&pawnID) if pawnID == 0 { return ", skill grants skipped (no pawn yet)", nil } granted := 0 for _, sk := range skillKeys { key := fmt.Sprintf(`(TagName="%s")`, sk) // Set ModuleData[key] = {"SkillPointsSpent": 1} when: // - key doesn't exist yet (game never created a placeholder), OR // - key exists with SpSpent <= 0 (game-created placeholder that // means "available but not yet purchased"). // SpSpent >= 1 is left alone so any further SP the player has // already spent on child nodes survives. tag, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities fe SET components = jsonb_set( fe.components, ARRAY['FLevelComponent','1','ModuleData',$2], '{"SkillPointsSpent": 1}'::jsonb, true) WHERE fe.entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE actor_id = $1 AND slot_name = 'DuneCharacter' ) AND COALESCE( (fe.components->'FLevelComponent'->1->'ModuleData'->$2->>'SkillPointsSpent')::int, 0 ) < 1`, pawnID, key) if err != nil { return "", fmt.Errorf("grant %s: %w", sk, err) } if tag.RowsAffected() > 0 { granted++ } } if granted == 0 { return ", no skill blocks needed (all already unlocked)", nil } return fmt.Sprintf(", unlocked %d skill block(s)", granted), nil } // spiceVisionEnableSQL sets FSpiceAddictionComponent.SpiceVisionEnabledStatus // to "FullyEnabled" on the player's DuneCharacter FGL entity. This is the // persistent flag the game reads to determine whether the player has unlocked // the Prescience state (3rd ability slot + spice-vision buff). In-game it is // written by the 4th Trial of Aql quest script, not by a journey tag — hence // it must be applied explicitly when admin-completing FindTheFremen. const spiceVisionEnableSQL = ` UPDATE dune.fgl_entities fe SET components = jsonb_set( fe.components, ARRAY['FSpiceAddictionComponent','1','SpiceVisionEnabledStatus'], '"FullyEnabled"'::jsonb, true) WHERE fe.entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE actor_id = $1 AND slot_name = 'DuneCharacter' ) AND COALESCE( fe.components->'FSpiceAddictionComponent'->1->>'SpiceVisionEnabledStatus', '' ) <> 'FullyEnabled'` // maybeGrantSpiceVision conditionally enables SpiceVision for the account // when nodeID is within the FindTheFremen quest. It is a thin wrapper so // cmdCompleteJourneyNode stays under the complexity gate. func maybeGrantSpiceVision(ctx context.Context, accountID int64, nodeID string) (string, error) { if !nodeIDTriggersSpiceVision(nodeID) { return "", nil } var pawnID int64 _ = globalDB.QueryRow(ctx, `SELECT player_pawn_id FROM dune.player_state WHERE account_id = $1 LIMIT 1`, accountID).Scan(&pawnID) return grantSpiceVision(ctx, pawnID) } // nodeIDTriggersSpiceVision reports whether completing nodeID should also // enable SpiceVision (Prescience). Only DA_MQ_FindTheFremen and its subtree // warrant this — it is the quest that contains the 4th Trial of Aql. func nodeIDTriggersSpiceVision(nodeID string) bool { const root = "DA_MQ_FindTheFremen" return nodeID == root || len(nodeID) > len(root) && nodeID[:len(root)+1] == root+"." } // grantSpiceVision enables the Prescience / SpiceVision state on the player's // DuneCharacter FGL entity. It is idempotent — a no-op if already enabled. // Returns a short extra fragment for the caller's success message, or "" if // the pawn was not found or the flag was already set. func grantSpiceVision(ctx context.Context, pawnID int64) (string, error) { if pawnID == 0 { return ", spice vision skipped (no pawn yet)", nil } res, err := globalDB.Exec(ctx, spiceVisionEnableSQL, pawnID) if err != nil { return "", fmt.Errorf("grant spice vision: %w", err) } if res.RowsAffected() == 0 { return "", nil } return ", enabled Prescience (SpiceVision)", nil } // allJourneyTags returns the union of every tag any journey node would emit // on completion. Used by Wipe All to also strip tags that prior completions // may have applied. Rep is intentionally not touched — natural progression // is monotonic and we don't try to roll it back. func allJourneyTags() []string { if tagsData.JourneyNodeTags == nil { return nil } seen := map[string]bool{} var out []string for _, tags := range tagsData.JourneyNodeTags { for _, t := range tags { if !seen[t] { seen[t] = true out = append(out, t) } } } return out } func cmdResetJourneyNode(accountID int64, nodeID string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() _, err := globalDB.Exec(ctx, ` UPDATE dune.journey_story_node SET complete_condition_state = 'false'::jsonb, has_pending_reward = false WHERE account_id = $1 AND (story_node_id = $2 OR story_node_id LIKE $2 || '.%')`, accountID, nodeID) if err != nil { return msgMutate{err: fmt.Errorf("reset node: %w", err)} } // Also strip any tags this node + its descendants would have emitted // on completion. The proc accepts (add, remove) text[] pairs. removeTags := tagsForJourneyNodeSubtree(nodeID) extra := "" if len(removeTags) > 0 { if _, err = globalDB.Exec(ctx, `SELECT dune.update_player_tags($1, '{}'::text[], $2::text[])`, accountID, removeTags); err != nil { return msgMutate{err: fmt.Errorf("remove node tags: %w", err)} } extra = fmt.Sprintf(", removed %d tag(s)", len(removeTags)) } return msgMutate{ok: fmt.Sprintf("Reset %s%s", nodeID, extra)} } } func cmdWipeJourneyNodes(accountID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() _, err := globalDB.Exec(ctx, `SELECT dune.delete_all_journey_story_nodes($1)`, accountID) if err != nil { return msgMutate{err: fmt.Errorf("wipe journey: %w", err)} } // Strip every tag any journey node could have emitted, so the // player's tag state matches the post-wipe journey state. removeTags := allJourneyTags() extra := "" if len(removeTags) > 0 { if _, err = globalDB.Exec(ctx, `SELECT dune.update_player_tags($1, '{}'::text[], $2::text[])`, accountID, removeTags); err != nil { return msgMutate{err: fmt.Errorf("remove journey tags: %w", err)} } extra = fmt.Sprintf(", removed %d journey tag(s)", len(removeTags)) } return msgMutate{ok: fmt.Sprintf("Wiped all journey nodes for account %d%s", accountID, extra)} } } // climbTheRanksNodes are the journey nodes that gate access to Landsraad // rank 5–20 progression (DA_FQ = Dune Awakening Faction Quest). // Both parent and child nodes must be completed — confirmed by in-game observation. // These are faction-independent. var climbTheRanksNodes = []string{ "DA_FQ_ClimbTheRanks.Rank5To20.MeetSponsor", "DA_FQ_ClimbTheRanks.Rank5To20.MeetSponsor.TalkToSponsor", "DA_FQ_ClimbTheRanks.Rank5To20.StartLandsraadOnboarding", "DA_FQ_ClimbTheRanks.Rank5To20.StartLandsraadOnboarding.ReportToMasterOfAssassins", "DA_FQ_ClimbTheRanks.Rank5To20.CompleteLandsraadMission", "DA_FQ_ClimbTheRanks.Rank5To20.CompleteLandsraadMission.CompleteOnboardingJourney1", "DA_FQ_ClimbTheRanks.Rank5To20.CraftAugmentation", "DA_FQ_ClimbTheRanks.Rank5To20.CraftAugmentation.CompleteOnboardingJourney2", } // climbTheRanksStoryNodes are the faction-neutral storyline beats observed // completed on both rank-up reference characters (rank 19 Atreides and rank 8 // Harkonnen). These cover the chapter-2 → rank-5-onboarding journey beats. var climbTheRanksStoryNodes = []string{ "DA_FQ_ClimbTheRanks.HuntingSkorda", "DA_FQ_ClimbTheRanks.HuntingSkorda.FindSkorda", "DA_FQ_ClimbTheRanks.HuntingSkorda.FindSkorda.SkordaInArrakeen", "DA_FQ_ClimbTheRanks.HuntingSkorda.FindSkorda.SkordaInMysaTarrill", "DA_FQ_ClimbTheRanks.HuntingSkorda.FindSkorda.SkordaInOodham", "DA_FQ_ClimbTheRanks.GatheringIntelligence", "DA_FQ_ClimbTheRanks.GatheringIntelligence.TrackDownContainer", "DA_FQ_ClimbTheRanks.GatheringIntelligence.TrackDownContainer.FindCanister", "DA_FQ_ClimbTheRanks.GatheringIntelligence.TrackDownContainer.InvestigateSandflies", "DA_FQ_ClimbTheRanks.GatheringIntelligence.TrackDownContainer.TrackDownPilot", "DA_FQ_ClimbTheRanks.GatheringIntelligence.TrackDownContainer.TrackDownRedScorpion", "DA_FQ_ClimbTheRanks.JoinAHouse", "DA_FQ_ClimbTheRanks.JoinAHouse.ProveYourself", "DA_FQ_ClimbTheRanks.JoinAHouse.ProveYourself.ChooseASide", "DA_FQ_ClimbTheRanks.JoinAHouse.ProveYourself.Rank1Contracts", "DA_FQ_ClimbTheRanks.JoinAHouse.StrikeADeal", "DA_FQ_ClimbTheRanks.JoinAHouse.StrikeADeal.FindTheSpy", "DA_FQ_ClimbTheRanks.JoinAHouse.StrikeADeal.GetSpyMission", "DA_FQ_ClimbTheRanks.JoinAHouse.StrikeADeal.TalkToARecruiter", "DA_FQ_ClimbTheRanks.ClimbTheRanksR2", "DA_FQ_ClimbTheRanks.ClimbTheRanksR2.ContributeToWarEffort_Atreides", "DA_FQ_ClimbTheRanks.ClimbTheRanksR2.ContributeToWarEffort_Atreides.CompleteContractsR2", } // climbTheRanksStoryNodesAtreides are the Atreides-side storyline beats // (Ch2→Ch3 transition + Test of Loyalty + Atreides investigations). var climbTheRanksStoryNodesAtreides = []string{ "DA_FQ_ClimbTheRanks.TransitionToCh3_Atre", "DA_FQ_ClimbTheRanks.TransitionToCh3_Atre.TheCall", "DA_FQ_ClimbTheRanks.TransitionToCh3_Atre.TheCall.AnswerTheCall", "DA_FQ_ClimbTheRanks.ATestOfLoyalty", "DA_FQ_ClimbTheRanks.ATestOfLoyalty.GetMaximToBackOff", "DA_FQ_ClimbTheRanks.ATestOfLoyalty.GetMaximToBackOff.FindSemuta", "DA_FQ_ClimbTheRanks.InvestigateKytheria_Atreides", "DA_FQ_ClimbTheRanks.InvestigateKytheria_Atreides.InvestigateWreck_Atreides", `DA_FQ_ClimbTheRanks.InvestigateKytheria_Atreides.InvestigateWreck_Atreides.Complete "Track Down Skorda" Contract`, "DA_FQ_ClimbTheRanks.InvestigateKytheria_Atreides.InvestigateWreck_Atreides.MeetAndreaGanan", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides.DeviseAPlan_Atreides", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides.DeviseAPlan_Atreides.TellThufirAboutDelphis", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides.PledgeAllegiance_Atreides", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides.PledgeAllegiance_Atreides.PledgeAllegiance_Atreides_Sub", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides.SecureLastContainer_Atreides", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Atreides.SecureLastContainer_Atreides.RecoverSheolContainer_Atreides", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PunishTraitor", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PunishTraitor.ChoosePoisonOrSpare", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PunishTraitor.CompleteWarProfiteerContract", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PunishTraitor.FindBusinessman", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PunishTraitor.TalkToThufirAgain", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PutFindingsToTest", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PutFindingsToTest.MeetThufir", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PutFindingsToTest.ReturnToGanan", "DA_FQ_ClimbTheRanks.PoisonedSpice_Atreides.PutFindingsToTest.SpeakWithGanan", } // climbTheRanksStoryNodesHarkonnen are the Harkonnen-side storyline beats // (Ch2→Ch3 transition + Test of Treachery + Harkonnen investigations). var climbTheRanksStoryNodesHarkonnen = []string{ "DA_FQ_ClimbTheRanks.TransitionToCh3_Hark", "DA_FQ_ClimbTheRanks.TransitionToCh3_Hark.TheCall", "DA_FQ_ClimbTheRanks.TransitionToCh3_Hark.TheCall.AnswerTheCall", "DA_FQ_ClimbTheRanks.ATestOfTreachery", "DA_FQ_ClimbTheRanks.ATestOfTreachery.GetAntonToBackOff", "DA_FQ_ClimbTheRanks.ATestOfTreachery.GetAntonToBackOff.FindCounterfeitEvidence", "DA_FQ_ClimbTheRanks.InvestigateKytheria_Harkonnen", "DA_FQ_ClimbTheRanks.InvestigateKytheria_Harkonnen.InvestigateWreck_Harkonnen", `DA_FQ_ClimbTheRanks.InvestigateKytheria_Harkonnen.InvestigateWreck_Harkonnen.Complete "Track Down Skorda" Contract`, "DA_FQ_ClimbTheRanks.InvestigateKytheria_Harkonnen.InvestigateWreck_Harkonnen.MeetSimoneVonKonig", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen.DeviseAPlan_Harkonnen", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen.DeviseAPlan_Harkonnen.TellPiterAboutEuporia", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen.PledgeAllegiance_Harkonnen", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen.PledgeAllegiance_Harkonnen.PledgeAllegiance_Harkonnen_Sub", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen.SecureLastContainer_Harkonnen", "DA_FQ_ClimbTheRanks.InvestigateDelphis_Harkonnen.SecureLastContainer_Harkonnen.RecoverSheolContainer_Harkonnen", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.LeverageYourFindings", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.LeverageYourFindings.DeliverResults", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.LeverageYourFindings.MeetPiter", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.LeverageYourFindings.ReturnToVonKonig", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.LeverageYourFindings.SpeakWithVonKonig", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.TakeALeap", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.TakeALeap.PoisonOrWarnPiter", "DA_FQ_ClimbTheRanks.PoisonedSpice_Harkonnen.TakeALeap.TalkToPiterAgain", } // landsraadMissionNodes* are the weekly Landsraad mission journey nodes (DA_SQ = // Dune Awakening Side Quest). Completed naturally by doing one Landsraad mission // in-game; required alongside climbTheRanksNodes for rank 5→20 progression. var landsraadMissionNodesAtreides = []string{ "DA_SQ_OverlandMap.AtreLandsraadMission", "DA_SQ_OverlandMap.AtreLandsraadMission.AtreMission", "DA_SQ_OverlandMap.AtreLandsraadMission.AtreMission.AtreAccept", "DA_SQ_OverlandMap.AtreLandsraadMission.AtreMission.AtreKeyStone", "DA_SQ_OverlandMap.AtreLandsraadMission.AtreMission.AtreComplete", "DA_SQ_OverlandMap.AtreLandsraadMission.AtreMission.AtreReturn", "DA_SQ_OverlandMap.AtreLandsraadMission.AtreMission.AtreClaimReward", } var landsraadMissionNodesHarkonnen = []string{ "DA_SQ_OverlandMap.HarkLandsraadMission", "DA_SQ_OverlandMap.HarkLandsraadMission.HarkMission", "DA_SQ_OverlandMap.HarkLandsraadMission.HarkMission.HarkAccept", "DA_SQ_OverlandMap.HarkLandsraadMission.HarkMission.HarkKeyStone", "DA_SQ_OverlandMap.HarkLandsraadMission.HarkMission.HarkComplete", "DA_SQ_OverlandMap.HarkLandsraadMission.HarkMission.HarkReturn", "DA_SQ_OverlandMap.HarkLandsraadMission.HarkMission.HarkClaimReward", } // nodesForPreset returns the journey node IDs to complete for a faction+preset. // ch3_start: Rank5To20 onboarding + faction-neutral chapter-2 storyline + chosen // faction's Ch2→Ch3 transition / Test of Loyalty(Treachery) / investigations / // poisoned spice arc — i.e. everything required for a fresh character to land // at rank 5 (House Operator), so rank 6-19 can be earned organically. // rank19_eligible: same set + the weekly Landsraad mission tree, fast-forwarded // to tier 19. func nodesForPreset(faction, preset string) []string { nodes := append([]string{}, climbTheRanksNodes...) nodes = append(nodes, climbTheRanksStoryNodes...) switch faction { case "atreides": nodes = append(nodes, climbTheRanksStoryNodesAtreides...) case "harkonnen": nodes = append(nodes, climbTheRanksStoryNodesHarkonnen...) } if preset == "rank19_eligible" { switch faction { case "atreides": nodes = append(nodes, landsraadMissionNodesAtreides...) case "harkonnen": nodes = append(nodes, landsraadMissionNodesHarkonnen...) } } return nodes } const progressionUnlockMaxTier = 5 type progressionFactionConfig struct { factionID int16 dialogueFlag string alignedFlag string metRecruiterFlag string factionUnlocked string recruitmentDone string } func progressionFactionConfigFor(faction string) (progressionFactionConfig, error) { switch faction { case "atreides": return progressionFactionConfig{ factionID: 1, dialogueFlag: "DialogueFlags.Factions.SentToMeetHawat", alignedFlag: "DialogueFlags.Factions.AlignedAtreides", metRecruiterFlag: "DialogueFlags.Factions.MetHawat", factionUnlocked: "Contract.Tracking.AtreidesFactionUnlocked", recruitmentDone: "Contract.Tracking.AtreidesRecruitmentCompleted", }, nil case "harkonnen": return progressionFactionConfig{ factionID: 2, dialogueFlag: "DialogueFlags.Factions.SentToPiterDeVries", alignedFlag: "DialogueFlags.Factions.AlignedHarkonnen", metRecruiterFlag: "DialogueFlags.Factions.MetPiterDeVries", factionUnlocked: "Contract.Tracking.HarkonnenFactionUnlocked", recruitmentDone: "Contract.Tracking.HarkonnenRecruitmentCompleted", }, nil default: return progressionFactionConfig{}, fmt.Errorf("faction must be atreides or harkonnen") } } func progressionTargetTierForPreset(preset string) (int, error) { switch preset { case "ch3_start": return 5, nil case "rank19_eligible": return 19, nil default: return 0, fmt.Errorf("preset must be ch3_start or rank19_eligible") } } func progressionUnlockTags(cfg progressionFactionConfig, targetTier int) []string { factionName := factionDisplayName(cfg.factionID) // Faction..TierN is only a real gameplay tag for N ∈ [0,5] — see // DA_Atreides.json / DA_Harkonnen.json m_FactionTiers, where Tier 6+ // all have m_FactionTierTag.TagName == "None". Tier 5 flips // m_bAllowPromotionThroughReputation to true, after which rep alone // advances the displayed rank. So Tier0–5 + a rep >= threshold[19] is // enough to display rank 19 — no need to write phantom Tier6..19 tags. allTags := []string{ cfg.dialogueFlag, cfg.alignedFlag, cfg.metRecruiterFlag, cfg.factionUnlocked, cfg.recruitmentDone, "DialogueFlags.Factions.FactionIntro", "DialogueFlags.Factions.FactionRank1", "DialogueFlags.Factions.FactionRank3", "DialogueFlags.Factions.MetARecruiter", "DialogueFlags.Factions.PlayedAllegianceCinematic", "DialogueFlags.Factions.SeenAnvilCinematic", } if targetTier >= 19 { allTags = append(allTags, "Journey.LandsraadContractsUnlocked") } for tier := 0; tier <= progressionUnlockMaxTier; tier++ { allTags = append(allTags, fmt.Sprintf("Faction.%s.Tier%d", factionName, tier)) } return allTags } func resolveProgressionUnlockPlayer(ctx context.Context, actorID int64) (accountID, controllerID int64, flsID string, err error) { if err = globalDB.QueryRow(ctx, ` SELECT COALESCE(a.owner_account_id, 0), COALESCE(ps.player_controller_id, 0) FROM dune.actors a LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id WHERE a.id = $1`, actorID, ).Scan(&accountID, &controllerID); err != nil || accountID == 0 { return 0, 0, "", fmt.Errorf("player %d not found or has no account", actorID) } if controllerID == 0 { return 0, 0, "", fmt.Errorf("player %d has no controller actor", actorID) } flsID, err = rawFuncomID(ctx, accountID) if err != nil || flsID == "" { return 0, 0, "", fmt.Errorf("player %d has no FLS ID", actorID) } return accountID, controllerID, flsID, nil } func applyProgressionUnlock( ctx context.Context, accountID, controllerID int64, flsID string, factionID int16, targetTier int, journeyNodes, allTags []string, ) error { tx, err := globalDB.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if _, err = tx.Exec(ctx, `SELECT dune.complete_journey_story_nodes_for_player($1, $2::text[])`, flsID, journeyNodes); err != nil { return fmt.Errorf("complete journey nodes: %w", err) } // Align the player with the chosen faction. Required for fresh / unaligned // characters (no player_faction row) — without this the rank UI doesn't // reflect tier changes because the game treats the player as unaligned. // neutral_faction_id = 3 ("None") so this proc takes the upsert branch. if _, err = tx.Exec(ctx, `SELECT dune.change_player_faction($1::bigint, $2::smallint, 3::smallint, NOW()::timestamp)`, controllerID, factionID); err != nil { return fmt.Errorf("change_player_faction: %w", err) } if _, err = tx.Exec(ctx, `SELECT dune.update_player_tags($1, $2::text[], '{}'::text[])`, accountID, allTags); err != nil { return fmt.Errorf("update player tags: %w", err) } // +1 over the tier threshold: the game UI floors at the threshold // (rep == threshold shows the tier below), so we nudge just over. targetRep := factionTierThresholds[targetTier] + 1 if _, err = tx.Exec(ctx, `SELECT dune.set_player_faction_reputation($1, $2, $3)`, controllerID, factionID, targetRep); err != nil { return fmt.Errorf("set faction rep: %w", err) } if err = syncFactionComponent(ctx, tx, controllerID); err != nil { return fmt.Errorf("update FactionPlayerComponent rep: %w", err) } if err := tx.Commit(ctx); err != nil { return err } return nil } func formatProgressionUnlockSuccess( preset, faction string, journeyNodeCount int, factionName string, targetTier int, controllerID int64, ) string { return fmt.Sprintf( "Progression unlock (%s/%s): %d journey nodes completed + %s tier tags 0–%d + rep tier %d on controller %d — takes effect on next login", preset, faction, journeyNodeCount, factionName, progressionUnlockMaxTier, targetTier, controllerID) } func resolveProgressionAccountID(ctx context.Context, actorID int64) (int64, error) { var accountID int64 if err := globalDB.QueryRow(ctx, `SELECT COALESCE(owner_account_id, 0) FROM dune.actors WHERE id = $1`, actorID).Scan(&accountID); err != nil || accountID == 0 { return 0, fmt.Errorf("player %d not found or has no account", actorID) } return accountID, nil } func progressionReverseTags(baseTags, nodes []string) []string { allTags := append([]string{}, baseTags...) seen := make(map[string]bool, len(allTags)) for _, tag := range allTags { seen[tag] = true } for _, node := range nodes { for _, tag := range tagsForJourneyNodeSubtree(node) { if seen[tag] { continue } seen[tag] = true allTags = append(allTags, tag) } } return allTags } func applyProgressionReverse(ctx context.Context, accountID int64, allTags, nodes []string) (int64, error) { tx, err := globalDB.Begin(ctx) if err != nil { return 0, err } defer func() { _ = tx.Rollback(ctx) }() if _, err = tx.Exec(ctx, `SELECT dune.update_player_tags($1, '{}'::text[], $2::text[])`, accountID, allTags); err != nil { return 0, fmt.Errorf("remove tags: %w", err) } result, err := tx.Exec(ctx, ` UPDATE dune.journey_story_node SET complete_condition_state = 'false'::jsonb, has_pending_reward = false WHERE account_id = $1 AND story_node_id = ANY($2::text[])`, accountID, nodes) if err != nil { return 0, fmt.Errorf("reset journey nodes: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, err } return result.RowsAffected(), nil } func formatProgressionReverseSuccess(preset, faction string, resetNodes int64, removedTags int) string { return fmt.Sprintf( "Reversed progression unlock (%s/%s): reset %d node(s), removed %d tag(s) — takes effect on next login", preset, faction, resetNodes, removedTags) } // cmdProgressionUnlock completes all prerequisite faction story journey nodes, // writes the corresponding gameplay tags, and sets reputation to the preset's // target tier. // // faction: "atreides" | "harkonnen" // preset: "ch3_start" (rank 5 — House Operator) | "rank19_eligible" (rank 19) func cmdProgressionUnlock(actorID int64, faction, preset string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } cfg, err := progressionFactionConfigFor(faction) if err != nil { return msgMutate{err: err} } targetTier, err := progressionTargetTierForPreset(preset) if err != nil { return msgMutate{err: err} } journeyNodes := nodesForPreset(faction, preset) factionName := factionDisplayName(cfg.factionID) allTags := progressionUnlockTags(cfg, targetTier) ctx := context.Background() accountID, controllerID, flsID, err := resolveProgressionUnlockPlayer(ctx, actorID) if err != nil { return msgMutate{err: err} } if err := applyProgressionUnlock( ctx, accountID, controllerID, flsID, cfg.factionID, targetTier, journeyNodes, allTags, ); err != nil { return msgMutate{err: err} } return msgMutate{ok: formatProgressionUnlockSuccess( preset, faction, len(journeyNodes), factionName, targetTier, controllerID, )} } } // cmdReverseProgressionUnlock undoes cmdProgressionUnlock: resets the journey // nodes from nodesForPreset back to not-complete and removes all tags the // forward function wrote. Reputation and faction alignment are not touched — // matching the existing per-node reset behaviour. func cmdReverseProgressionUnlock(actorID int64, faction, preset string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } cfg, err := progressionFactionConfigFor(faction) if err != nil { return msgMutate{err: err} } targetTier, err := progressionTargetTierForPreset(preset) if err != nil { return msgMutate{err: err} } ctx := context.Background() accountID, err := resolveProgressionAccountID(ctx, actorID) if err != nil { return msgMutate{err: err} } nodes := nodesForPreset(faction, preset) baseTags := progressionUnlockTags(cfg, targetTier) allTags := progressionReverseTags(baseTags, nodes) resetNodes, err := applyProgressionReverse(ctx, accountID, allTags, nodes) if err != nil { return msgMutate{err: err} } return msgMutate{ok: formatProgressionReverseSuccess( preset, faction, resetNodes, len(allTags), )} } } func cmdDeleteAllTutorials(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if playerID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } _, err := globalDB.Exec(context.Background(), `SELECT dune.delete_all_tutorial_entries($1)`, playerID) if err != nil { return msgMutate{err: fmt.Errorf("delete tutorials: %w", err)} } return msgMutate{ok: fmt.Sprintf("Deleted all tutorial entries for player %d", playerID)} } } func cmdWipeCodex(accountID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if accountID == 0 { return msgMutate{err: fmt.Errorf("account ID required")} } _, err := globalDB.Exec(context.Background(), `SELECT dune.delete_mnemonic_recall_lesson_all($1)`, accountID) if err != nil { return msgMutate{err: fmt.Errorf("wipe codex: %w", err)} } return msgMutate{ok: fmt.Sprintf("Wiped all codex entries for account %d", accountID)} } } const maxCharXP = int64(344440) // XP required for level 200 (hard cap) // cumulativeXPByLevel[i] = total XP needed to reach level i (from SkillXPPerLevel.json). var cumulativeXPByLevel = [201]int64{ 0, 40, 215, 440, 740, 1240, 1790, 2390, 2990, 3590, 4190, // 0-10 4790, 5390, 5990, 6590, 7190, 7790, 8390, 8990, 9590, 10190, // 11-20 10790, 11390, 11990, 12590, 13190, 13790, 14390, 14990, 15590, 16190, // 21-30 16790, 17390, 17990, 18590, 19190, 19790, 20390, 20990, 21590, 22190, // 31-40 22790, 23390, 23990, 24590, 25190, 25790, 26390, 26990, 27590, 28190, // 41-50 28790, 29390, 29990, 30590, 31190, 31790, 32390, 32990, 33590, 34190, // 51-60 34790, 35390, 35990, 36590, 37190, 37790, 38390, 38990, 39590, 40190, // 61-70 40790, 41390, 41990, 42590, 43190, 43790, 44390, 44990, 45590, 46190, // 71-80 46790, 47390, 47990, 48590, 49190, 49790, 50390, 50990, 51590, 52190, // 81-90 52790, 53390, 53990, 54590, 55190, 55790, 56390, 56990, 57590, 58190, // 91-100 58840, 59490, 60140, 60790, 61440, 62090, 62740, 63390, 64040, 64690, // 101-110 65340, 65990, 66640, 67290, 67940, 68590, 69240, 69890, 70540, 71190, // 111-120 71840, 72490, 73140, 73790, 74440, 75090, 75740, 76391, 77044, 77699, // 121-130 78357, 79018, 79683, 80353, 81030, 81714, 82407, 83110, 83825, 84554, // 131-140 85298, 86060, 86842, 87646, 88475, 89332, 90220, 91141, 92100, 93099, // 141-150 94143, 95235, 96380, 97582, 98845, 100175, 101576, 103054, 104614, 106263, // 151-160 108006, 109849, 111799, 113862, 116046, 118358, 120806, 123397, 126139, 129041, // 161-170 132112, 135360, 138795, 142426, 146263, 150316, 154596, 159114, 163880, 168906, // 171-180 174203, 179784, 185661, 191846, 198353, 205195, 212385, 219938, 227868, 236190, // 181-190 244918, 254069, 263657, 273700, 284213, 295214, 306719, 318746, 331314, 344440, // 191-200 } // xpToLevel returns the character level for the given cumulative XP (1–200). func xpToLevel(xp int64) int { if xp <= 0 { return 0 } lo, hi := 1, 200 for lo < hi { mid := (lo + hi + 1) / 2 if cumulativeXPByLevel[mid] <= xp { lo = mid } else { hi = mid - 1 } } return lo } // intelAtLevel returns cumulative intel points earned through a given level. // Based on IntelPointsRewarded curve in SkillXPPerLevel.json: // // L1=4, L2-3=+2, L4-15=+3, L16-30=+5, L31-50=+10, // L51-69=+20, L70-85=+30, L86-125=+40, L126+=0 (cap 2779) func intelAtLevel(level int) int64 { switch { case level <= 0: return 0 case level == 1: return 4 case level <= 3: return 4 + int64(level-1)*2 case level <= 15: return 8 + int64(level-3)*3 case level <= 30: return 44 + int64(level-15)*5 case level <= 50: return 119 + int64(level-30)*10 case level <= 69: return 319 + int64(level-50)*20 case level <= 85: return 699 + int64(level-69)*30 case level <= 125: return 1179 + int64(level-85)*40 default: return 2779 } } // checkPlayerOffline returns an error if the player is currently online. // playerID is the pawn actor ID (PlayerCharacter). func checkPlayerOffline(ctx context.Context, playerID int64) error { var status string err := globalDB.QueryRow(ctx, ` SELECT online_status::text FROM dune.player_state WHERE player_pawn_id = $1`, playerID).Scan(&status) if errors.Is(err, pgx.ErrNoRows) { // No player_state row means the player has never connected or their // session record was cleaned up — treat as offline. return nil } if err != nil { return fmt.Errorf("could not check online status: %w", err) } if status != "Offline" { return fmt.Errorf("player is currently %s — log out first, then apply the edit", status) } return nil } type msgCharXP struct { xp int64 level int err error } type charXPOutcome struct { newXP int64 newLevel int64 newTotalSP int64 newUnspentSP int64 newIntel int64 capped bool } func cmdFetchCharXP(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgCharXP{err: fmt.Errorf("not connected")} } var xp int64 err := globalDB.QueryRow(context.Background(), ` SELECT COALESCE((fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, 0) FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id WHERE afe.actor_id = $1 AND afe.slot_name = 'DuneCharacter'`, playerID).Scan(&xp) if err != nil { return msgCharXP{err: fmt.Errorf("read char xp: %w", err)} } return msgCharXP{xp: xp, level: xpToLevel(xp)} } } func readCharXPState(ctx context.Context, playerID int64) (currentXP, spentSP int64, err error) { err = globalDB.QueryRow(ctx, ` SELECT (fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, COALESCE(( SELECT SUM((v->>'SkillPointsSpent')::int) FROM jsonb_each(fe.components->'FLevelComponent'->1->'ModuleData') AS kv(k, v) WHERE k != format('(TagName="%s")', fe.components->'FLevelComponent'->1->'StarterSkillTreeTag'->>'TagName') ), 0) FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id WHERE afe.actor_id = $1 AND afe.slot_name = 'DuneCharacter'`, playerID).Scan(¤tXP, &spentSP) if err != nil { return 0, 0, fmt.Errorf("read current state: %w", err) } return currentXP, spentSP, nil } func resolveControllerIDForPawn(ctx context.Context, playerID int64) (int64, error) { var controllerID int64 err := globalDB.QueryRow(ctx, ` SELECT player_controller_id FROM dune.player_state WHERE player_pawn_id = $1 LIMIT 1`, playerID).Scan(&controllerID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, nil } return 0, fmt.Errorf("resolve controller id: %w", err) } return controllerID, nil } func loadControllerKeystoneIDs(ctx context.Context, controllerID int64) ([]int16, error) { if controllerID == 0 { return nil, nil } rows, err := globalDB.Query(ctx, ` SELECT keystone_id FROM dune.purchased_specialization_keystones WHERE player_id = $1::bigint`, controllerID) if err != nil { return nil, fmt.Errorf("read keystones: %w", err) } defer rows.Close() ids := make([]int16, 0, 8) for rows.Next() { var id int16 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan keystone: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("scan keystone: %w", err) } return ids, nil } func fetchKeystoneBonusForPawn(ctx context.Context, playerID int64) (int64, error) { controllerID, err := resolveControllerIDForPawn(ctx, playerID) if err != nil { return 0, err } ids, err := loadControllerKeystoneIDs(ctx, controllerID) if err != nil { return 0, err } return keystoneSPBonus(ids), nil } func computeAwardCharXPOutcome(currentXP, spentSP, keystoneBonus, amount int64) charXPOutcome { newXP := currentXP + amount if newXP > maxCharXP { newXP = maxCharXP } newLevel := int64(xpToLevel(newXP)) newTotalSP := newLevel + keystoneBonus // Starter job always occupies 1 SP that is excluded from spentSP. newUnspentSP := newTotalSP - spentSP - 1 if newUnspentSP < 0 { newUnspentSP = 0 } newIntel := intelAtLevel(int(newLevel)) return charXPOutcome{ newXP: newXP, newLevel: newLevel, newTotalSP: newTotalSP, newUnspentSP: newUnspentSP, newIntel: newIntel, capped: newXP == maxCharXP, } } func applyAwardCharXPFLevelUpdate(ctx context.Context, playerID int64, outcome charXPOutcome) error { _, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities SET components = jsonb_set(jsonb_set(jsonb_set( components, '{FLevelComponent,1,TotalXPEarned}', to_jsonb($2::bigint)), '{FLevelComponent,1,TotalSkillPoints}', to_jsonb($3::bigint)), '{FLevelComponent,1,UnspentSkillPoints}', to_jsonb($4::bigint)) WHERE entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE actor_id = $1 AND slot_name = 'DuneCharacter' )`, playerID, outcome.newXP, outcome.newTotalSP, outcome.newUnspentSP) if err != nil { return fmt.Errorf("update fgl xp/sp: %w", err) } return nil } func applyAwardCharXPIntelUpdate(ctx context.Context, playerID int64, newIntel int64) error { _, err := globalDB.Exec(ctx, ` UPDATE dune.actors SET properties = jsonb_set( properties, '{TechKnowledgePlayerComponent,m_TechKnowledgePoints}', to_jsonb($2::bigint)) WHERE id = $1 AND properties ? 'TechKnowledgePlayerComponent'`, playerID, newIntel) if err != nil { return fmt.Errorf("update intel: %w", err) } return nil } func formatAwardCharXPSuccess(playerID int64, outcome charXPOutcome, spentSP int64) string { capped := "" if outcome.capped { capped = " (capped at level 200)" } return fmt.Sprintf( "Player %d → level %d%s | XP %d | SP %d unspent (%d spent) | Intel %d", playerID, outcome.newLevel, capped, outcome.newXP, outcome.newUnspentSP, spentSP, outcome.newIntel) } func cmdAwardCharXP(playerID int64, amount int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if playerID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgMutate{err: err} } currentXP, spentSP, err := readCharXPState(ctx, playerID) if err != nil { return msgMutate{err: err} } keystoneBonus, err := fetchKeystoneBonusForPawn(ctx, playerID) if err != nil { return msgMutate{err: err} } outcome := computeAwardCharXPOutcome(currentXP, spentSP, keystoneBonus, amount) // Update FLevelComponent: XP + both skill point fields. if err := applyAwardCharXPFLevelUpdate(ctx, playerID, outcome); err != nil { return msgMutate{err: err} } // Update intel points on the PlayerCharacter actor. if err := applyAwardCharXPIntelUpdate(ctx, playerID, outcome.newIntel); err != nil { return msgMutate{err: err} } return msgMutate{ok: formatAwardCharXPSuccess(playerID, outcome, spentSP)} } } func cmdAwardIntel(playerID int64, amount int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if playerID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgMutate{err: err} } res, err := globalDB.Exec(ctx, ` UPDATE dune.actors SET properties = jsonb_set( properties, '{TechKnowledgePlayerComponent,m_TechKnowledgePoints}', to_jsonb((properties->'TechKnowledgePlayerComponent'->>'m_TechKnowledgePoints')::bigint + $2) ) WHERE id = $1 AND properties ? 'TechKnowledgePlayerComponent'`, playerID, amount) if err != nil { return msgMutate{err: fmt.Errorf("award intel: %w", err)} } if res.RowsAffected() == 0 { return msgMutate{err: fmt.Errorf("TechKnowledgePlayerComponent not found for player %d — ensure player is a PlayerCharacter actor", playerID)} } return msgMutate{ok: fmt.Sprintf("Awarded %d intel points to player %d", amount, playerID)} } } // ── blueprint JSON types ────────────────────────────────────────────────────── type blueprintInstance struct { InstanceID *int `json:"instance_id,omitempty"` BuildingType string `json:"building_type"` X float64 `json:"x"` Y float64 `json:"y"` Z float64 `json:"z"` Rotation float64 `json:"rotation"` ProvidesStability *bool `json:"provides_stability,omitempty"` } type blueprintPlaceable struct { PlaceableID *int `json:"placeable_id,omitempty"` BuildingType string `json:"building_type"` X float64 `json:"x"` Y float64 `json:"y"` Z float64 `json:"z"` RX float64 `json:"rx,omitempty"` RY float64 `json:"ry"` RZ float64 `json:"rz,omitempty"` } type blueprintPentashield struct { PlaceableID int `json:"placeable_id"` Scale [3]int `json:"scale"` // [width, height, depth] stored as SMALLINT[3] } type blueprintFile struct { Name string `json:"name,omitempty"` Instances []blueprintInstance `json:"instances"` Placeables []blueprintPlaceable `json:"placeables"` Pentashields []blueprintPentashield `json:"pentashields,omitempty"` } // ── blueprint commands ──────────────────────────────────────────────────────── func cmdListBlueprints() Msg { if globalDB == nil { return msgBlueprintList{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT bb.id, COALESCE(ps.character_name, '') AS owner, COALESCE(bb.item_id, 0), COALESCE(inst.cnt, 0) AS pieces, COALESCE(plac.cnt, 0) AS placeables, COALESCE(i.stats->'FBuildingBlueprintItemStats'->1->>'BuildingBlueprintName', '') AS name FROM dune.building_blueprints bb LEFT JOIN dune.items i ON i.id = bb.item_id LEFT JOIN dune.inventories inv ON inv.id = i.inventory_id LEFT JOIN dune.actors a ON a.id = inv.actor_id LEFT JOIN dune.player_state ps ON ps.player_pawn_id = a.id LEFT JOIN ( SELECT building_blueprint_id, COUNT(*) AS cnt FROM dune.building_blueprint_instances GROUP BY building_blueprint_id ) inst ON inst.building_blueprint_id = bb.id LEFT JOIN ( SELECT building_blueprint_id, COUNT(*) AS cnt FROM dune.building_blueprint_placeables GROUP BY building_blueprint_id ) plac ON plac.building_blueprint_id = bb.id ORDER BY bb.id`) if err != nil { return msgBlueprintList{err: err} } defer rows.Close() var out []blueprintRow for rows.Next() { var r blueprintRow if err := rows.Scan(&r.ID, &r.OwnerName, &r.ItemID, &r.Pieces, &r.Placeables, &r.Name); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgBlueprintList{err: err} } return msgBlueprintList{rows: out} } func cmdGrantMaxSpec(playerID int64, trackType string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } _, err := globalDB.Exec(context.Background(), `SELECT dune.set_specialization_xp_and_level($1, $2::dune.specializationtracktype, $3, $4)`, playerID, trackType, 44182, 100.0) if err != nil { return msgMutate{err: err} } return msgMutate{ok: fmt.Sprintf("Granted max %s spec to player %d", trackType, playerID)} } } func cmdFetchPlayerSpecs(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgSpecs{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT player_id, track_type::text, xp_amount, level FROM dune.specialization_tracks WHERE player_id = $1::bigint ORDER BY track_type`, playerID) if err != nil { return msgSpecs{err: err} } defer rows.Close() var out []specTrack for rows.Next() { var r specTrack if err := rows.Scan(&r.PlayerID, &r.TrackType, &r.XP, &r.Level); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgSpecs{err: err} } return msgSpecs{rows: out} } } // keystoneSPBonus returns the total extra skill points granted by a set of keystone IDs. // SkillPoint = +1, SkillPoint_Major = +3, SkillPoint_Super = +5 (Combat track only). func keystoneSPBonus(ids []int16) int64 { var total int64 for _, id := range ids { info, ok := keystoneMap[id] if !ok { continue } switch { case strings.HasSuffix(info.Name, "_SkillPoint_Super"): total += 5 case strings.HasSuffix(info.Name, "_SkillPoint_Major"): total += 3 case strings.HasSuffix(info.Name, "_SkillPoint"): total += 1 } } return total } func insertAllPurchasedKeystones(ctx context.Context, playerID int64) error { _, err := globalDB.Exec(ctx, ` INSERT INTO dune.purchased_specialization_keystones (player_id, keystone_id) SELECT $1::bigint, generate_series(1, 205) ON CONFLICT DO NOTHING`, playerID) return err } func allKeystoneIDs() []int16 { ids := make([]int16, 205) for i := range ids { ids[i] = int16(i + 1) } return ids } func readLevelComponentSkillState(ctx context.Context, playerID int64) (int64, int64, int64, error) { var xp, currentTotal, spentSP int64 err := globalDB.QueryRow(ctx, ` SELECT (fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, (fe.components->'FLevelComponent'->1->>'TotalSkillPoints')::bigint, COALESCE(( SELECT SUM((v->>'SkillPointsSpent')::int) FROM jsonb_each(fe.components->'FLevelComponent'->1->'ModuleData') AS kv(k, v) WHERE k != format('(TagName="%s")', fe.components->'FLevelComponent'->1->'StarterSkillTreeTag'->>'TagName') ), 0) FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id WHERE afe.slot_name = 'DuneCharacter' AND afe.actor_id = ( SELECT player_pawn_id FROM dune.player_state WHERE player_controller_id = $1 LIMIT 1 )`, playerID).Scan(&xp, ¤tTotal, &spentSP) if err != nil { return 0, 0, 0, fmt.Errorf("read FLevelComponent: %w", err) } return xp, currentTotal, spentSP, nil } func grantAllKeystoneTargets(xp, spentSP int64) (int64, int64, int64) { keystoneBonus := keystoneSPBonus(allKeystoneIDs()) level := int64(xpToLevel(xp)) expectedTotal := level + keystoneBonus // UnspentSkillPoints = total - non-starter spent - 1 (starter job always occupies 1 SP). expectedUnspent := expectedTotal - spentSP - 1 if expectedUnspent < 0 { expectedUnspent = 0 } return expectedTotal, expectedUnspent, keystoneBonus } func updateLevelComponentSkillPoints(ctx context.Context, playerID, expectedTotal, expectedUnspent int64) error { _, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities SET components = jsonb_set(jsonb_set( components, '{FLevelComponent,1,TotalSkillPoints}', to_jsonb($2::bigint)), '{FLevelComponent,1,UnspentSkillPoints}', to_jsonb($3::bigint)) WHERE entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE slot_name = 'DuneCharacter' AND actor_id = ( SELECT player_pawn_id FROM dune.player_state WHERE player_controller_id = $1 LIMIT 1 ) )`, playerID, expectedTotal, expectedUnspent) if err != nil { return fmt.Errorf("update skill points: %w", err) } return nil } func cmdGrantAllKeystones(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgMutate{err: err} } if err := insertAllPurchasedKeystones(ctx, playerID); err != nil { return msgMutate{err: err} } // Read XP, current TotalSkillPoints, and SP spent in non-starter modules. // Uses pawn actor id (purchased_specialization_keystones uses controller id). xp, currentTotal, spentSP, err := readLevelComponentSkillState(ctx, playerID) if err != nil { return msgMutate{err: err} } expectedTotal, expectedUnspent, keystoneBonus := grantAllKeystoneTargets(xp, spentSP) if currentTotal >= expectedTotal { return msgMutate{ok: fmt.Sprintf( "Granted all keystones to player %d — SP already correct (%d total, %d unspent)", playerID, currentTotal, expectedUnspent)} } if err := updateLevelComponentSkillPoints(ctx, playerID, expectedTotal, expectedUnspent); err != nil { return msgMutate{err: err} } return msgMutate{ok: fmt.Sprintf( "Granted all keystones to player %d — SP %d → %d total, %d unspent (+%d keystone bonus)", playerID, currentTotal, expectedTotal, expectedUnspent, keystoneBonus)} } } // cmdResetAllKeystones is the inverse of cmdGrantAllKeystones: it deletes all // purchased keystones and rolls TotalSkillPoints/UnspentSkillPoints back to // the XP-derived baseline (no keystone bonus). Requires the player to be offline. func cmdResetAllKeystones(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgMutate{err: err} } // Read XP, current total SP, and non-starter spent SP — same query as // cmdGrantAllKeystones so the arithmetic is symmetric. var xp, currentTotal, spentSP int64 err := globalDB.QueryRow(ctx, ` SELECT (fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, (fe.components->'FLevelComponent'->1->>'TotalSkillPoints')::bigint, COALESCE(( SELECT SUM((v->>'SkillPointsSpent')::int) FROM jsonb_each(fe.components->'FLevelComponent'->1->'ModuleData') AS kv(k, v) WHERE k != format('(TagName="%s")', fe.components->'FLevelComponent'->1->'StarterSkillTreeTag'->>'TagName') ), 0) FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id WHERE afe.slot_name = 'DuneCharacter' AND afe.actor_id = ( SELECT player_pawn_id FROM dune.player_state WHERE player_controller_id = $1 LIMIT 1 )`, playerID).Scan(&xp, ¤tTotal, &spentSP) if err != nil { return msgMutate{err: fmt.Errorf("read FLevelComponent: %w", err)} } if _, err := globalDB.Exec(ctx, `DELETE FROM dune.purchased_specialization_keystones WHERE player_id = $1`, playerID); err != nil { return msgMutate{err: fmt.Errorf("delete keystones: %w", err)} } level := int64(xpToLevel(xp)) newTotal := level newUnspent := newTotal - spentSP - 1 if newUnspent < 0 { newUnspent = 0 } if _, err := globalDB.Exec(ctx, ` UPDATE dune.fgl_entities SET components = jsonb_set(jsonb_set( components, '{FLevelComponent,1,TotalSkillPoints}', to_jsonb($2::bigint)), '{FLevelComponent,1,UnspentSkillPoints}', to_jsonb($3::bigint)) WHERE entity_id = ( SELECT entity_id FROM dune.actor_fgl_entities WHERE slot_name = 'DuneCharacter' AND actor_id = ( SELECT player_pawn_id FROM dune.player_state WHERE player_controller_id = $1 LIMIT 1 ) )`, playerID, newTotal, newUnspent); err != nil { return msgMutate{err: fmt.Errorf("update skill points: %w", err)} } return msgMutate{ok: fmt.Sprintf( "Reset all keystones for player %d — SP %d → %d total, %d unspent", playerID, currentTotal, newTotal, newUnspent)} } } func cmdFetchPlayerKeystones(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgKeystones{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT keystone_id FROM dune.purchased_specialization_keystones WHERE player_id = $1::bigint ORDER BY keystone_id`, playerID) if err != nil { return msgKeystones{err: err} } defer rows.Close() var ids []int16 for rows.Next() { var id int16 if err := rows.Scan(&id); err != nil { continue } ids = append(ids, id) } return msgKeystones{ids: ids} } } func cmdGetPlayerVehicles(controllerID int64) Cmd { return func() Msg { if globalDB == nil { return msgVehicles{err: fmt.Errorf("not connected")} } // Look up account_id from controller_id — vehicle actors don't use owner_account_id. var accountID int64 err := globalDB.QueryRow(context.Background(), `SELECT ps.account_id FROM dune.player_state ps WHERE ps.player_controller_id = $1 LIMIT 1`, controllerID).Scan(&accountID) if err != nil { return msgVehicles{err: fmt.Errorf("look up account: %w", err)} } rows, err := globalDB.Query(context.Background(), ` SELECT pa.actor_id, a.class, COALESCE(a.map, ''), COALESCE(rv.chassis_durability::float8, 1.0), COALESCE(pa.actor_name, rv.vehicle_name, ''), (rv.vehicle_id IS NOT NULL) AS is_recovered, false AS is_backup FROM dune.permission_actor pa JOIN dune.permission_actor_rank par ON par.permission_actor_id = pa.actor_id JOIN dune.actors a ON a.id = pa.actor_id LEFT JOIN dune.recovered_vehicles rv ON rv.vehicle_id = pa.actor_id AND rv.account_id = $2 WHERE par.player_id = $1 AND pa.actor_type = 2 UNION ALL SELECT a.id, a.class, '' AS map, 1.0 AS chassis_durability, '' AS vehicle_name, false AS is_recovered, true AS is_backup FROM dune.backup_vehicles bv JOIN dune.actors a ON a.id = bv.vehicle_id WHERE bv.account_id = $2 ORDER BY class`, controllerID, accountID) if err != nil { return msgVehicles{err: err} } defer rows.Close() var out []vehicleRow for rows.Next() { var r vehicleRow if err := rows.Scan(&r.ID, &r.Class, &r.Map, &r.ChassisDurability, &r.VehicleName, &r.IsRecovered, &r.IsBackup); err != nil { continue } r.Class = shortClass(r.Class) out = append(out, r) } if err := rows.Err(); err != nil { return msgVehicles{err: err} } return msgVehicles{rows: out} } } func lookupRepairItemOwner(ctx context.Context, itemID int64) (int64, error) { var pawnID int64 err := globalDB.QueryRow(ctx, ` SELECT inv.actor_id FROM dune.items i JOIN dune.inventories inv ON inv.id = i.inventory_id WHERE i.id = $1::bigint`, itemID).Scan(&pawnID) if errors.Is(err, pgx.ErrNoRows) { return 0, fmt.Errorf("item %d not found", itemID) } if err != nil { return 0, fmt.Errorf("look up item owner: %w", err) } return pawnID, nil } func repairItemDurability(ctx context.Context, itemID int64) (int64, error) { res, err := globalDB.Exec(ctx, ` UPDATE dune.items i SET stats = jsonb_set( jsonb_set(i.stats, '{FItemStackAndDurabilityStats,1,CurrentDurability}', to_jsonb(t.val), true), '{FItemStackAndDurabilityStats,1,DecayedMaxDurability}', to_jsonb(t.val), true) FROM ( SELECT COALESCE( (stats->'FItemStackAndDurabilityStats'->1->>'MaxDurability')::float8, 100.0 ) AS val FROM dune.items WHERE id = $1::bigint AND stats ? 'FItemStackAndDurabilityStats' ) AS t WHERE i.id = $1::bigint AND ( abs(COALESCE((i.stats->'FItemStackAndDurabilityStats'->1->>'CurrentDurability')::float8, 0) - t.val) > 0.01 OR abs(COALESCE((i.stats->'FItemStackAndDurabilityStats'->1->>'DecayedMaxDurability')::float8, 0) - t.val) > 0.01 )`, itemID) if err != nil { return 0, err } return res.RowsAffected(), nil } func itemHasDurabilityStats(ctx context.Context, itemID int64) (bool, error) { var hasDurability bool if err := globalDB.QueryRow(ctx, ` SELECT stats ? 'FItemStackAndDurabilityStats' FROM dune.items WHERE id = $1::bigint`, itemID).Scan(&hasDurability); err != nil { return false, fmt.Errorf("check item: %w", err) } return hasDurability, nil } func repairItemNoChangeMessage(itemID int64, hasDurability bool) msgMutate { if !hasDurability { return msgMutate{err: fmt.Errorf("item %d has no durability field", itemID)} } return msgMutate{ok: fmt.Sprintf("Item %d already at full durability", itemID)} } func repairItemSuccessMessage(itemID int64) msgMutate { return msgMutate{ok: fmt.Sprintf("Repaired item %d — relog to see in-game", itemID)} } func cmdRepairItem(itemID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() // Derive owning player from item → inventory → actor (pawn) so we can gate Offline. pawnID, err := lookupRepairItemOwner(ctx, itemID) if err != nil { return msgMutate{err: err} } if err := checkPlayerOffline(ctx, pawnID); err != nil { return msgMutate{err: err} } // Write both fields: Current-only gets clamped to surviving Decayed on reload. // Fallback target = 100.0 covers the 0-100 gear scale when MaxDurability is absent. rowsAffected, err := repairItemDurability(ctx, itemID) if err != nil { return msgMutate{err: fmt.Errorf("repair item: %w", err)} } if rowsAffected == 0 { // Item exists (owner lookup succeeded). Either no durability field, or already at ceiling. hasDurability, err := itemHasDurabilityStats(ctx, itemID) if err != nil { return msgMutate{err: err} } return repairItemNoChangeMessage(itemID, hasDurability) } return repairItemSuccessMessage(itemID) } } // Carried inventories: backpack, equipment, emote wheel, equipped weapons, action wheel, bank. var repairGearInventoryTypes = []int32{0, 1, 14, 15, 27, 30} type repairCandidate struct { id int64 target float64 } func parseDurabilityText(value pgtype.Text) float64 { if !value.Valid { return 0 } parsed, _ := strconv.ParseFloat(value.String, 64) return parsed } func repairTargetForItem(maxDurability pgtype.Text) float64 { // The in-row MaxDurability is the source of truth. Default to 100 only for // plain 0–100 gear that carries no MaxDurability. The PAK catalog is NOT // consulted — it under-reports some scales (e.g. transport modules). if maxDurability.Valid { if value, err := strconv.ParseFloat(maxDurability.String, 64); err == nil && value > 0 { return value } } return 100.0 } func buildRepairCandidate( id int64, maxDurability, currentDurability, decayedDurability pgtype.Text, ) (repairCandidate, bool) { current := parseDurabilityText(currentDurability) decayed := parseDurabilityText(decayedDurability) // Never lower an existing value: a default-100 must not cap a higher-scale // item whose MaxDurability is absent but whose stored values exceed 100. target := repairTargetForItem(maxDurability) if current > target { target = current } if decayed > target { target = decayed } if math.Abs(current-target) < 0.01 && math.Abs(decayed-target) < 0.01 { return repairCandidate{}, false } return repairCandidate{id: id, target: target}, true } func loadPlayerGearRepairCandidates(ctx context.Context, playerID int64) ([]repairCandidate, int, error) { rows, err := globalDB.Query(ctx, ` SELECT i.id, (i.stats->'FItemStackAndDurabilityStats'->1->>'MaxDurability'), (i.stats->'FItemStackAndDurabilityStats'->1->>'CurrentDurability'), (i.stats->'FItemStackAndDurabilityStats'->1->>'DecayedMaxDurability') FROM dune.items i JOIN dune.inventories inv ON inv.id = i.inventory_id WHERE inv.actor_id = $1::bigint AND inv.inventory_type = ANY($2::int[]) AND i.stats ? 'FItemStackAndDurabilityStats'`, playerID, repairGearInventoryTypes) if err != nil { return nil, 0, fmt.Errorf("scan items: %w", err) } defer rows.Close() toRepair := make([]repairCandidate, 0, 64) scanned := 0 for rows.Next() { scanned++ var id int64 var maxDurability, currentDurability, decayedDurability pgtype.Text if err := rows.Scan(&id, &maxDurability, ¤tDurability, &decayedDurability); err != nil { return nil, scanned, fmt.Errorf("scan item: %w", err) } candidate, needsRepair := buildRepairCandidate(id, maxDurability, currentDurability, decayedDurability) if needsRepair { toRepair = append(toRepair, candidate) } } if err := rows.Err(); err != nil { return nil, 0, fmt.Errorf("scan rows: %w", err) } return toRepair, scanned, nil } func validateRepairPlayerGearInput(playerID int64) error { if globalDB == nil { return fmt.Errorf("not connected") } if playerID == 0 { return fmt.Errorf("player ID required") } return nil } type gearRepairRunResult struct { repaired int err error } func runPlayerGearRepairs(ctx context.Context, toRepair []repairCandidate) gearRepairRunResult { tx, err := globalDB.Begin(ctx) if err != nil { return gearRepairRunResult{err: fmt.Errorf("begin tx: %w", err)} } defer func() { _ = tx.Rollback(ctx) }() repaired := 0 for _, rc := range toRepair { _, err := tx.Exec(ctx, ` UPDATE dune.items SET stats = jsonb_set( jsonb_set(stats, '{FItemStackAndDurabilityStats,1,CurrentDurability}', to_jsonb($2::float8), true), '{FItemStackAndDurabilityStats,1,DecayedMaxDurability}', to_jsonb($2::float8), true) WHERE id = $1::bigint`, rc.id, rc.target) if err != nil { return gearRepairRunResult{ repaired: repaired, err: fmt.Errorf("repair item %d: %w", rc.id, err), } } repaired++ } if err := tx.Commit(ctx); err != nil { // Keep the legacy response shape for commit failures: no repaired count. return gearRepairRunResult{err: fmt.Errorf("commit: %w", err)} } return gearRepairRunResult{repaired: repaired} } func cmdRepairPlayerGear(playerID int64) Cmd { return func() Msg { if err := validateRepairPlayerGearInput(playerID); err != nil { return msgRepairGear{err: err} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgRepairGear{err: err} } toRepair, scanned, err := loadPlayerGearRepairCandidates(ctx, playerID) if err != nil { return msgRepairGear{scanned: scanned, err: err} } run := runPlayerGearRepairs(ctx, toRepair) if run.err != nil { return msgRepairGear{repaired: run.repaired, scanned: scanned, err: run.err} } return msgRepairGear{repaired: run.repaired, scanned: scanned} } } func validateRepairVehicleInput(playerID int64) error { if globalDB == nil { return fmt.Errorf("not connected") } if playerID == 0 { return fmt.Errorf("player ID required") } return nil } type vehicleModule struct { id int64 maxDurability pgtype.Text currentDurability pgtype.Text decayedDurability pgtype.Text } func loadVehicleModules(ctx context.Context, vehicleID int64) ([]vehicleModule, error) { rows, err := globalDB.Query(ctx, ` SELECT id, (stats->'FVehicleModuleDurabilityStats'->1->>'MaxDurability'), (stats->'FVehicleModuleDurabilityStats'->1->>'CurrentDurability'), (stats->'FVehicleModuleDurabilityStats'->1->>'DecayedMaxDurability') FROM dune.vehicle_modules WHERE vehicle_id = $1::bigint`, vehicleID) if err != nil { return nil, fmt.Errorf("scan modules: %w", err) } defer rows.Close() var modules []vehicleModule for rows.Next() { var module vehicleModule if err := rows.Scan(&module.id, &module.maxDurability, &module.currentDurability, &module.decayedDurability); err != nil { return nil, fmt.Errorf("scan module: %w", err) } modules = append(modules, module) } if err := rows.Err(); err != nil { return nil, err } return modules, nil } type vehicleRepairSummary struct { repaired int skipped int total int err error } // vehicleModuleRepairTarget derives the repair target from the module's own // in-row MaxDurability — the authoritative 100% ceiling. ok=false means the // module has no usable in-row MaxDurability and must be skipped: the PAK catalog // is NOT a fallback (it under-reports transport-ornithopter modules by ~half). // The target never lowers an existing higher value. func vehicleModuleRepairTarget(module vehicleModule) (float64, bool) { maxDur := parseDurabilityText(module.maxDurability) if !module.maxDurability.Valid || maxDur <= 0 { return 0, false } target := maxDur if current := parseDurabilityText(module.currentDurability); current > target { target = current } if decayed := parseDurabilityText(module.decayedDurability); decayed > target { target = decayed } return target, true } // vehicleModuleAtTarget reports whether the module already sits at the target // on both the current and decayed ceilings, so no write is needed. func vehicleModuleAtTarget(module vehicleModule, target float64) bool { current := parseDurabilityText(module.currentDurability) decayed := parseDurabilityText(module.decayedDurability) return math.Abs(current-target) < 0.01 && math.Abs(decayed-target) < 0.01 } func runVehicleModuleRepairs( modules []vehicleModule, update func(module vehicleModule, target float64) error, ) vehicleRepairSummary { summary := vehicleRepairSummary{total: len(modules)} for _, module := range modules { target, ok := vehicleModuleRepairTarget(module) if !ok { summary.skipped++ continue } if vehicleModuleAtTarget(module, target) { continue // already at full — no write needed } if err := update(module, target); err != nil { summary.err = fmt.Errorf("repair module %d: %w", module.id, err) return summary } summary.repaired++ } return summary } func updateVehicleModuleDurability(ctx context.Context, moduleID int64, target float64) error { _, err := globalDB.Exec(ctx, ` UPDATE dune.vehicle_modules SET stats = jsonb_set( jsonb_set(stats, '{FVehicleModuleDurabilityStats,1,CurrentDurability}', to_jsonb($2::float8), true), '{FVehicleModuleDurabilityStats,1,DecayedMaxDurability}', to_jsonb($2::float8), true) WHERE id = $1::bigint`, moduleID, target) return err } func cmdRepairVehicle(playerID, vehicleID int64) Cmd { return func() Msg { if err := validateRepairVehicleInput(playerID); err != nil { return msgRepairVehicle{err: err} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgRepairVehicle{err: err} } modules, err := loadVehicleModules(ctx, vehicleID) if err != nil { return msgRepairVehicle{err: err} } summary := runVehicleModuleRepairs(modules, func(module vehicleModule, target float64) error { // Both fields must be written — Current-only is clamped to surviving Decayed on reload. return updateVehicleModuleDurability(ctx, module.id, target) }) return msgRepairVehicle(summary) } } func cmdRefuelVehicle(playerID, vehicleID int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } if playerID == 0 { return msgMutate{err: fmt.Errorf("player ID required")} } ctx := context.Background() if err := checkPlayerOffline(ctx, playerID); err != nil { return msgMutate{err: err} } // Each vehicle BP has its own properties bag keyed by the class basename // (e.g. "BP_Sandbike_CHOAM_C"), so we derive it before writing m_InitialFuel. var class string err := globalDB.QueryRow(ctx, `SELECT class FROM dune.actors WHERE id = $1::bigint`, vehicleID).Scan(&class) if errors.Is(err, pgx.ErrNoRows) { return msgMutate{err: fmt.Errorf("vehicle %d not found", vehicleID)} } if err != nil { return msgMutate{err: fmt.Errorf("look up vehicle class: %w", err)} } bpClass := class if idx := strings.LastIndex(class, "."); idx >= 0 { bpClass = class[idx+1:] } _, err = globalDB.Exec(ctx, ` UPDATE dune.actors SET properties = jsonb_set( COALESCE(properties, '{}'::jsonb), ARRAY[$2::text, 'm_InitialFuel'], to_jsonb(1.0::float8), true) WHERE id = $1::bigint`, vehicleID, bpClass) if err != nil { return msgMutate{err: fmt.Errorf("refuel vehicle: %w", err)} } return msgMutate{ok: fmt.Sprintf("Refueled vehicle %d — relog to see in-game", vehicleID)} } } func cmdFetchCheatLog() Cmd { return func() Msg { if globalDB == nil { return msgCheatLog{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT ct.fls_id, ct.cheat_type::text, to_char(ct.event_time AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'), COALESCE(ps.character_name, ct.fls_id) FROM dune.cheater_tracking ct LEFT JOIN dune.encrypted_accounts e ON convert_from(e.encrypted_funcom_id, 'UTF8') = ct.fls_id LEFT JOIN dune.player_state ps ON ps.account_id = e.id WHERE ct.event_time > NOW() - INTERVAL '7 days' ORDER BY ct.event_time DESC LIMIT 500`) if err != nil { return msgCheatLog{err: err} } defer rows.Close() var out []cheatEntry for rows.Next() { var r cheatEntry if err := rows.Scan(&r.FLSID, &r.CheatType, &r.EventTime, &r.CharacterName); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgCheatLog{err: err} } return msgCheatLog{rows: out} } } func cmdFetchEventLog(actorID int64) Cmd { return func() Msg { if globalDB == nil { return msgEvents{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT actor_id, to_char(universe_time AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'), COALESCE(map, ''), event_type, COALESCE(x, 0)::float8, COALESCE(y, 0)::float8, COALESCE(z, 0)::float8, COALESCE(custom_data::text, '{}') FROM dune.game_events WHERE actor_id = $1::bigint AND player_facing_event = true ORDER BY universe_time DESC LIMIT 200`, actorID) if err != nil { return msgEvents{err: err} } defer rows.Close() var out []gameEvent for rows.Next() { var r gameEvent if err := rows.Scan(&r.ActorID, &r.UniverseTime, &r.Map, &r.EventType, &r.X, &r.Y, &r.Z, &r.CustomData); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgEvents{err: err} } return msgEvents{rows: out} } } func cmdFetchPlayerDungeons(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgDungeons{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT dc.dungeon_id, dc.difficulty::text, dc.duration_ms, dc.players_num, dc.completion_id FROM dune.dungeon_completion_players dcp JOIN dune.dungeon_completion dc ON dc.completion_id = dcp.completion_id WHERE dcp.player_id = $1::bigint ORDER BY dc.completion_id DESC LIMIT 100`, playerID) if err != nil { return msgDungeons{err: err} } defer rows.Close() var out []dungeonRecord for rows.Next() { var r dungeonRecord if err := rows.Scan(&r.DungeonID, &r.Difficulty, &r.DurationMs, &r.PlayersNum, &r.CompletionID); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgDungeons{err: err} } return msgDungeons{rows: out} } } var cheatLocations = []teleportLocation{ {Name: "Windsack", X: 974276.75, Y: 20084.312, Z: 5112.283}, {Name: "EcoLabs", X: 826879.3, Y: -925967.2, Z: 4974.4277}, {Name: "CrashSite", X: 330284.22, Y: 205236.98, Z: 2251.008}, {Name: "MediumStarter", X: 268515.8, Y: 207559.39, Z: 5000.0}, {Name: "ConvoyAmbush", X: -920080.0, Y: 909620.0, Z: 300.0}, {Name: "SpiceRaid", X: 271590.0, Y: -493122.0, Z: 8471.0}, {Name: "PS5_ESW_0", X: -113881.4, Y: -305252.1, Z: 20864.5}, {Name: "PS5_ESW_1", X: -109861.8, Y: -307020.0, Z: 21192.9}, {Name: "PS5_ESW_2", X: -129029.6, Y: -312757.8, Z: 21099.6}, {Name: "PS5_ESW_3", X: -117312.0, Y: -305453.9, Z: 21649.8}, } func cmdListPartitions() Cmd { return func() Msg { if globalLocationStore != nil { locs, err := globalLocationStore.list() if err == nil { return msgPartitions{rows: locs} } } // Fallback to compile-time seeds when the store is unavailable. return msgPartitions{rows: cheatLocations} } } func cmdTeleportPlayer(flsID string, locationName string) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } loc, err := resolveLocation(locationName) if err != nil { return msgMutate{err: err} } ctx := context.Background() // Use the player's current partition so the zone server is correct. var partitionID int64 if scanErr := globalDB.QueryRow(ctx, ` SELECT COALESCE(a.partition_id, 0) FROM dune.encrypted_accounts e JOIN dune.player_state ps ON ps.account_id = e.id JOIN dune.actors a ON a.id = ps.player_pawn_id WHERE convert_from(e.encrypted_funcom_id, 'UTF8') = $1`, flsID).Scan(&partitionID); scanErr != nil || partitionID == 0 { _ = globalDB.QueryRow(ctx, `SELECT id FROM dune.world_partition WHERE blocked = false LIMIT 1`).Scan(&partitionID) } if _, execErr := globalDB.Exec(ctx, ` SELECT dune.admin_move_offline_player_to_partition($1::text, $2::bigint, ROW($3::float8,$4::float8,$5::float8)::dune.Vector)`, flsID, partitionID, loc.X, loc.Y, loc.Z); execErr != nil { return msgMutate{err: fmt.Errorf("teleport: %w", execErr)} } return msgMutate{ok: fmt.Sprintf("Moved %s to %s", flsID, locationName)} } } // playerPosition is the live world position of a player's character. type playerPosition struct { PartitionID int64 `json:"partition_id"` Map string `json:"map"` X float64 `json:"x"` Y float64 `json:"y"` Z float64 `json:"z"` } // cmdGetPlayerPosition reads a player's current world position from the // actors table. The transform column is a composite type holding a vector // location plus a quaternion rotation; we only need the vector. // playerID is the actor id (dune.actors.id) — matches playerInfo.ID. func cmdGetPlayerPosition(playerID int64) Cmd { return func() Msg { if globalDB == nil { return msgPlayerPosition{err: fmt.Errorf("not connected")} } var pos playerPosition err := globalDB.QueryRow(context.Background(), ` SELECT COALESCE(a.partition_id, 0), COALESCE(a.map, ''), ((a.transform).location).x, ((a.transform).location).y, ((a.transform).location).z FROM dune.actors a WHERE a.id = $1`, playerID).Scan(&pos.PartitionID, &pos.Map, &pos.X, &pos.Y, &pos.Z) if err != nil { return msgPlayerPosition{err: fmt.Errorf("read position: %w", err)} } return msgPlayerPosition{pos: pos} } } // cmdTeleportPlayerToCoords moves an offline player to a specific // (partition_id, x, y, z). For online players this should be skipped in // favour of rmqTeleportTo, which has immediate effect. func cmdTeleportPlayerToCoords(flsID string, partitionID int64, x, y, z float64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() if partitionID == 0 { // Try the player's current partition first (most likely to be valid). _ = globalDB.QueryRow(ctx, ` SELECT COALESCE(a.partition_id, 0) FROM dune.accounts ac JOIN dune.player_state ps ON ps.account_id = ac.id JOIN dune.actors a ON a.id = ps.player_pawn_id WHERE ac."user" = $1`, flsID).Scan(&partitionID) } if partitionID == 0 { // Fall back to any non-blocked partition (handles offline/no-actor state). _ = globalDB.QueryRow(ctx, `SELECT id FROM dune.world_partition WHERE blocked = false ORDER BY id LIMIT 1`, ).Scan(&partitionID) } if partitionID == 0 { return msgMutate{err: fmt.Errorf("could not resolve a valid partition for teleport")} } if _, execErr := globalDB.Exec(ctx, ` SELECT dune.admin_move_offline_player_to_partition($1::text, $2::bigint, ROW($3::float8,$4::float8,$5::float8)::dune.Vector)`, flsID, partitionID, x, y, z); execErr != nil { return msgMutate{err: fmt.Errorf("teleport: %w", execErr)} } return msgMutate{ok: fmt.Sprintf("Moved %s to (%.0f, %.0f, %.0f)", flsID, x, y, z)} } } // ── live map commands ───────────────────────────────────────────────────────── // liveMapKeys is the allow-list of maps the Live Map supports (v1: open-world // only). The value is the gameplay partition index for that map — not used by // the read query (which filters by a.map) but kept here as the single source of // truth for teleport routing (Phase 3) and to gate caller-supplied input. var liveMapKeys = map[string]int64{ "HaggaBasin": 1, "DeepDesert": 8, } // validateMapKey rejects any map the Live Map does not support, so caller input // can never reach the query as an unexpected value. func validateMapKey(key string) error { if key == "" { return fmt.Errorf("map required") } if _, ok := liveMapKeys[key]; !ok { return fmt.Errorf("unsupported map: %q", key) } return nil } // cmdFetchMapMarkers returns every plottable entity (players + vehicles) on the // given map, reading positions from dune.actors.transform. Bases are added in // Phase 2b. The map key is validated, then passed as a bound parameter. func cmdFetchMapMarkers(ctx context.Context, pool *pgxpool.Pool, mapKey string) ([]mapMarker, error) { if err := validateMapKey(mapKey); err != nil { return nil, err } markers := []mapMarker{} // Players: position is the player's pawn actor transform. // fls_id must be accounts."user" (hex UUID) — that is what RMQ PlayerId and // isHexIDOnline both expect. encrypted_funcom_id is the display name and is // NOT valid for those uses. playerRows, err := pool.Query(ctx, ` SELECT a.id, COALESCE(NULLIF(ps.character_name, ''), 'Unknown') AS name, COALESCE(ps.online_status::text, '') AS online_status, COALESCE(a.partition_id, 0) AS partition_id, COALESCE(ac."user", '') AS fls_id, ((a.transform).location).x, ((a.transform).location).y, ((a.transform).location).z FROM dune.actors a JOIN dune.player_state ps ON ps.player_pawn_id = a.id LEFT JOIN dune.accounts ac ON ac.id = ps.account_id WHERE a.map = $1 AND a.transform IS NOT NULL`, mapKey) if err != nil { return nil, fmt.Errorf("query player markers: %w", err) } defer playerRows.Close() for playerRows.Next() { m := mapMarker{Type: "player", Map: mapKey} if err := playerRows.Scan(&m.ID, &m.Name, &m.OnlineStatus, &m.PartitionID, &m.FLSID, &m.X, &m.Y, &m.Z); err != nil { return nil, fmt.Errorf("scan player marker: %w", err) } markers = append(markers, m) } if err := playerRows.Err(); err != nil { return nil, fmt.Errorf("iterate player markers: %w", err) } // Vehicles: dune.vehicles joined to its actor row for transform + class. vehicleRows, err := pool.Query(ctx, ` SELECT a.id, a.class, COALESCE(a.partition_id, 0) AS partition_id, ((a.transform).location).x, ((a.transform).location).y, ((a.transform).location).z FROM dune.vehicles v JOIN dune.actors a ON a.id = v.id WHERE a.map = $1 AND a.transform IS NOT NULL`, mapKey) if err != nil { return nil, fmt.Errorf("query vehicle markers: %w", err) } defer vehicleRows.Close() for vehicleRows.Next() { m := mapMarker{Type: "vehicle", Map: mapKey} if err := vehicleRows.Scan(&m.ID, &m.Class, &m.PartitionID, &m.X, &m.Y, &m.Z); err != nil { return nil, fmt.Errorf("scan vehicle marker: %w", err) } m.Class = shortClass(m.Class) m.Name = m.Class markers = append(markers, m) } if err := vehicleRows.Err(); err != nil { return nil, fmt.Errorf("iterate vehicle markers: %w", err) } bases, err := cmdFetchBaseMarkers(ctx, pool, mapKey) if err != nil { return nil, err } markers = append(markers, bases...) return markers, nil } // cmdFetchBaseMarkers returns base-totem map markers for the given map. Each // building's canonical totem actor (lowest owner_entity_id) is joined to // permission_actor for the display name, mirroring cmdListBases. func cmdFetchBaseMarkers(ctx context.Context, pool *pgxpool.Pool, mapKey string) ([]mapMarker, error) { rows, err := pool.Query(ctx, ` SELECT b.id, COALESCE(NULLIF(pa.actor_name, ''), 'Base') AS name, ((t.transform).location).x, ((t.transform).location).y, ((t.transform).location).z FROM dune.buildings b JOIN ( SELECT building_id, MIN(owner_entity_id) AS owner_entity_id FROM dune.building_instances GROUP BY building_id ) first_inst ON first_inst.building_id = b.id JOIN dune.actor_fgl_entities afe ON afe.entity_id = first_inst.owner_entity_id JOIN dune.actors t ON t.id = afe.actor_id AND t.class ILIKE '%Totem%' LEFT JOIN dune.permission_actor pa ON pa.actor_id = t.id WHERE t.map = $1 AND t.transform IS NOT NULL`, mapKey) if err != nil { return nil, fmt.Errorf("query base markers: %w", err) } defer rows.Close() var out []mapMarker for rows.Next() { m := mapMarker{Type: "base", Map: mapKey} if err := rows.Scan(&m.ID, &m.Name, &m.X, &m.Y, &m.Z); err != nil { return nil, fmt.Errorf("scan base marker: %w", err) } out = append(out, m) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate base markers: %w", err) } return out, nil } // ── storage container commands ──────────────────────────────────────────────── type storageContainerRow struct { ID int64 `json:"id"` Name string `json:"name"` Class string `json:"class"` Map string `json:"map"` ItemCount int64 `json:"item_count"` ItemTemplates []string `json:"item_templates"` ItemNames []string `json:"item_names"` OwnerName string `json:"owner_name"` } type msgStorageContainers struct { rows []storageContainerRow err error } func cmdListStorageContainers() Msg { if globalDB == nil { return msgStorageContainers{err: fmt.Errorf("not connected")} } // Drive from dune.placeables so we catch player-built containers regardless // of whether they've been promoted to an actor row yet (the game creates the // actor lazily on first interaction). building_type is the in-data identity // of the placeable kind; the four below cover the storage-container tiers, // noting that "Small Storage Container" registers as SpiceSilo_Placeable // despite sharing the type name with world POI silos — owner_entity_id // distinguishes player-built from world-spawned. // User-given container names live on dune.permission_actor.actor_name. // Unnamed containers default to 'None' or '##_Placeable' — // filter both out so only real custom names surface. rows, err := globalDB.Query(context.Background(), ` SELECT p.id, COALESCE(MAX(CASE WHEN pa.actor_name NOT LIKE '##%' AND pa.actor_name <> 'None' THEN pa.actor_name END), '') AS name, p.building_type AS class, COALESCE(a.map, '') AS map, COUNT(DISTINCT i.id) AS item_count, COALESCE(array_agg(DISTINCT i.template_id) FILTER (WHERE i.template_id IS NOT NULL), '{}') AS item_templates, COALESCE(MAX(ps.character_name), MAX(convert_from(e.encrypted_funcom_id, 'UTF8')), '') AS owner_name FROM dune.placeables p LEFT JOIN dune.actors a ON a.id = p.id LEFT JOIN dune.permission_actor pa ON pa.actor_id = p.id LEFT JOIN dune.inventories inv ON inv.actor_id = p.id LEFT JOIN dune.items i ON i.inventory_id = inv.id LEFT JOIN dune.actor_fgl_entities afe ON afe.entity_id = p.owner_entity_id LEFT JOIN dune.permission_actor_rank par ON par.permission_actor_id = afe.actor_id LEFT JOIN dune.actors player_a ON player_a.id = par.player_id LEFT JOIN dune.encrypted_accounts e ON e.id = player_a.owner_account_id LEFT JOIN dune.player_state ps ON ps.account_id = player_a.owner_account_id WHERE p.building_type IN ( 'SpiceSilo_Placeable', 'GenericContainer_Placeable', 'StorageContainer_Placeable', 'MediumStorageContainer_Placeable' ) AND p.is_hologram = false AND p.owner_entity_id IS NOT NULL AND p.owner_entity_id != 0 GROUP BY p.id, p.building_type, a.map ORDER BY p.id`) if err != nil { return msgStorageContainers{err: err} } defer rows.Close() var out []storageContainerRow for rows.Next() { var r storageContainerRow var templates []string if err := rows.Scan(&r.ID, &r.Name, &r.Class, &r.Map, &r.ItemCount, &templates, &r.OwnerName); err != nil { continue } if templates != nil { r.ItemTemplates = templates } else { r.ItemTemplates = []string{} } r.ItemNames = []string{} for _, t := range templates { if name := itemData.Names[strings.ToLower(t)]; name != "" { r.ItemNames = append(r.ItemNames, name) } } out = append(out, r) } if rows.Err() != nil { return msgStorageContainers{err: rows.Err()} } return msgStorageContainers{rows: out} } type msgContainerInventory struct { rows []itemInfo err error } func cmdGetContainerInventory(actorID int64) Cmd { return func() Msg { if globalDB == nil { return msgContainerInventory{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT i.id, i.template_id, i.stack_size, i.quality_level, COALESCE((i.stats->'FItemStackAndDurabilityStats'->1->>'CurrentDurability'), 'N/A'), COALESCE((i.stats->'FItemStackAndDurabilityStats'->1->>'MaxDurability'), 'N/A') FROM dune.items i JOIN dune.inventories inv ON i.inventory_id = inv.id WHERE inv.actor_id = $1 ORDER BY i.template_id`, actorID) if err != nil { return msgContainerInventory{err: err} } defer rows.Close() var items []itemInfo for rows.Next() { var it itemInfo if err := rows.Scan(&it.ID, &it.TemplateID, &it.StackSize, &it.Quality, &it.Durability, &it.MaxDurability); err != nil { continue } it.Name = itemData.Names[strings.ToLower(it.TemplateID)] items = append(items, it) } if err := rows.Err(); err != nil { return msgContainerInventory{err: err} } return msgContainerInventory{rows: items} } } func cmdGiveItemToContainer(actorID int64, templateID string, qty, quality int64) Cmd { return func() Msg { if globalDB == nil { return msgMutate{err: fmt.Errorf("not connected")} } ctx := context.Background() // Find the container's inventory (any type). var invID int64 var maxCount int var maxVol float32 err := globalDB.QueryRow(ctx, ` SELECT id, max_item_count, max_item_volume FROM dune.inventories WHERE actor_id = $1 LIMIT 1`, actorID).Scan(&invID, &maxCount, &maxVol) if err != nil { return msgMutate{err: fmt.Errorf("find container inventory: %w", err)} } // Count current items. var currentCount int64 if err := globalDB.QueryRow(ctx, `SELECT COUNT(*) FROM dune.items WHERE inventory_id = $1`, invID).Scan(¤tCount); err != nil { return msgMutate{err: fmt.Errorf("count items: %w", err)} } if maxCount > 0 && currentCount >= int64(maxCount) { return msgMutate{err: fmt.Errorf("container inventory full (%d/%d)", currentCount, maxCount)} } // Insert item with minimal valid stats matching game-generated items. _, err = globalDB.Exec(ctx, ` INSERT INTO dune.items (inventory_id, template_id, stack_size, quality_level, position_index, stats) VALUES ($1, $2, $3, $4, $5, '{"FCustomizationStats":[[],{}],"FItemStackAndDurabilityStats":[[],{}]}')`, invID, templateID, qty, quality, currentCount) if err != nil { return msgMutate{err: fmt.Errorf("insert item: %w", err)} } return msgMutate{ok: fmt.Sprintf("Added %dx %s (quality %d) to container %d", qty, templateID, quality, actorID)} } } func cmdListBases() Msg { if globalDB == nil { return msgBaseList{err: fmt.Errorf("not connected")} } rows, err := globalDB.Query(context.Background(), ` SELECT b.id, COALESCE(pa.actor_name, '') AS name, COALESCE(inst.cnt, 0) AS pieces, COALESCE(plac.cnt, 0) AS placeables FROM dune.buildings b LEFT JOIN ( SELECT building_id, MIN(owner_entity_id) AS owner_entity_id, COUNT(*) AS cnt FROM dune.building_instances GROUP BY building_id ) inst ON inst.building_id = b.id LEFT JOIN dune.actor_fgl_entities afe ON afe.entity_id = inst.owner_entity_id LEFT JOIN dune.actors t ON t.id = afe.actor_id AND t.class ILIKE '%Totem%' LEFT JOIN dune.permission_actor pa ON pa.actor_id = t.id LEFT JOIN ( SELECT bi.building_id, COUNT(*) AS cnt FROM dune.building_instances bi JOIN dune.placeables p ON p.owner_entity_id = bi.owner_entity_id GROUP BY bi.building_id ) plac ON plac.building_id = b.id ORDER BY b.id`) if err != nil { return msgBaseList{err: err} } defer rows.Close() var out []baseRow for rows.Next() { var r baseRow if err := rows.Scan(&r.ID, &r.Name, &r.Pieces, &r.Placeables); err != nil { continue } out = append(out, r) } if err := rows.Err(); err != nil { return msgBaseList{err: err} } return msgBaseList{rows: out} } // ── player stats ───────────────────────────────────────────────────────────── // cmdFetchOnlineAccountIDs returns the account IDs of all players currently // marked Online in player_state. Used by the session poller. func cmdFetchOnlineAccountIDs(ctx context.Context, pool *pgxpool.Pool) ([]int64, error) { rows, err := pool.Query(ctx, `SELECT account_id FROM dune.player_state WHERE online_status = 'Online' AND account_id <> $1`, gmIdentityAccountID) if err != nil { return nil, fmt.Errorf("fetch online account ids: %w", err) } defer rows.Close() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan account id: %w", err) } ids = append(ids, id) } return ids, rows.Err() } type playerPgStats struct { SolarisBal int64 `json:"solaris_balance"` ScripBal int64 `json:"scrip_balance"` SolarisEarned int64 `json:"solaris_earned"` SolarisSpent int64 `json:"solaris_spent"` POIsDiscovered int `json:"pois_discovered"` StoryMilestones int `json:"story_milestones"` MaxFactionTier int `json:"max_faction_tier"` Faction string `json:"faction"` CharXP int64 `json:"char_xp"` SkillPoints int `json:"skill_points"` LastSeen *time.Time `json:"last_seen"` } // cmdFetchPlayerPgStats gathers all Postgres-derived stats for a player. // // Economy uses two queries: // 1. Current balances from player_virtual_currency_balances (always works). // 2. Earned/spent totals from event_log, derived via a balance-match CTE: // the most recent solaris_balance in the event_log is matched against the // current balance to identify the player's hex FLS entity ID. This works // as long as the live balance equals the last logged balance. func cmdFetchPlayerPgStats(ctx context.Context, pool *pgxpool.Pool, accountID int64) (playerPgStats, error) { var stats playerPgStats // Current currency balances via player_controller_id. rows, err := pool.Query(ctx, ` SELECT pvc.currency_id, pvc.balance FROM dune.player_virtual_currency_balances pvc JOIN dune.player_state ps ON ps.player_controller_id = pvc.player_controller_id WHERE ps.account_id = $1 `, accountID) if err != nil { return stats, fmt.Errorf("fetch currency for account %d: %w", accountID, err) } defer rows.Close() for rows.Next() { var cid int16 var bal int64 if err := rows.Scan(&cid, &bal); err != nil { return stats, fmt.Errorf("scan currency: %w", err) } switch cid { case 0: stats.SolarisBal = bal case 1: stats.ScripBal = bal } } if err := rows.Err(); err != nil { return stats, fmt.Errorf("iterate currency: %w", err) } // Earned/spent totals from event_log, joined directly via dune.accounts."user" // which stores the hex PlayFab entity ID used as event_log.meta->>'fls_id'. row := pool.QueryRow(ctx, ` SELECT COALESCE(SUM(CASE WHEN COALESCE((el.meta->>'solaris_delta')::float, 0) > 0 THEN (el.meta->>'solaris_delta')::float ELSE 0 END), 0)::bigint, COALESCE(SUM(CASE WHEN COALESCE((el.meta->>'solaris_delta')::float, 0) < 0 THEN ABS((el.meta->>'solaris_delta')::float) ELSE 0 END), 0)::bigint FROM dune.event_log el JOIN dune.accounts ac ON ac."user" = el.meta->>'fls_id' WHERE ac.id = $1 AND el.meta->>'solaris_delta' IS NOT NULL `, accountID) if err := row.Scan(&stats.SolarisEarned, &stats.SolarisSpent); err != nil { return stats, fmt.Errorf("fetch solaris earned/spent for account %d: %w", accountID, err) } // Tag-derived stats: POIs discovered, story milestones, max faction tier. row = pool.QueryRow(ctx, ` SELECT COUNT(*) FILTER (WHERE tag LIKE 'Exploration.POI.%'), COUNT(*) FILTER (WHERE tag LIKE 'BigMoments.%.Complete'), COALESCE(MAX( CASE WHEN tag ~ '^Faction\.[^.]+\.Tier[0-9]+$' THEN CAST(SUBSTRING(tag FROM '[0-9]+$') AS INTEGER) ELSE NULL END ), 0) FROM dune.player_tags WHERE account_id = $1 `, accountID) if err := row.Scan(&stats.POIsDiscovered, &stats.StoryMilestones, &stats.MaxFactionTier); err != nil { return stats, fmt.Errorf("fetch tag stats for account %d: %w", accountID, err) } // Character XP and total skill points from FLevelComponent — joined via pawn_id. row = pool.QueryRow(ctx, ` SELECT COALESCE((fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, 0), COALESCE((fe.components->'FLevelComponent'->1->>'TotalSkillPoints')::int, 0) FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id JOIN dune.player_state ps ON ps.player_pawn_id = afe.actor_id WHERE afe.slot_name = 'DuneCharacter' AND ps.account_id = $1 `, accountID) if err := row.Scan(&stats.CharXP, &stats.SkillPoints); err != nil && !errors.Is(err, pgx.ErrNoRows) { return stats, fmt.Errorf("fetch char xp for account %d: %w", accountID, err) } // Last seen: most recent avatar activity timestamp. var lastSeen pgtype.Timestamptz row = pool.QueryRow(ctx, `SELECT last_avatar_activity FROM dune.player_state WHERE account_id = $1`, accountID) if err := row.Scan(&lastSeen); err != nil && !errors.Is(err, pgx.ErrNoRows) { return stats, fmt.Errorf("fetch last seen for account %d: %w", accountID, err) } if lastSeen.Valid { t := lastSeen.Time stats.LastSeen = &t } // Faction alignment (#117 review item 3) — see fetchAccountFaction. stats.Faction, err = fetchAccountFaction(ctx, pool, accountID) if err != nil { return stats, err } return stats, nil } // fetchAccountFaction resolves a player's faction name by account. Faction is // stored on the PlayerController actor, NOT the PlayerCharacter, so it's resolved // per-account (same rationale as factionByAccountJoin). Returns "" when the // player has no faction row (Unaligned). func fetchAccountFaction(ctx context.Context, pool *pgxpool.Pool, accountID int64) (string, error) { var faction string err := pool.QueryRow(ctx, ` SELECT COALESCE(f.name, '') FROM dune.factions f WHERE f.id = ( SELECT pf.faction_id FROM dune.player_faction pf JOIN dune.actors fa ON fa.id = pf.actor_id WHERE fa.owner_account_id = $1 LIMIT 1 )`, accountID).Scan(&faction) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return "", fmt.Errorf("fetch faction for account %d: %w", accountID, err) } return faction, nil } // solarisRaw is an intermediate scan target before cumulative sums are applied. type solarisRaw struct { Time string Balance int64 Delta int64 } type solarisPoint struct { Time string `json:"time"` Balance int64 `json:"balance"` CumEarned int64 `json:"cum_earned"` CumSpent int64 `json:"cum_spent"` } // accumulateSolarisPoints converts raw (time, balance, delta) rows into points // with monotonically-increasing cumulative earned and spent totals. func accumulateSolarisPoints(raws []solarisRaw) []solarisPoint { out := make([]solarisPoint, 0, len(raws)) var cumEarned, cumSpent int64 for _, r := range raws { if r.Delta > 0 { cumEarned += r.Delta } else if r.Delta < 0 { cumSpent += -r.Delta } out = append(out, solarisPoint{ Time: r.Time, Balance: r.Balance, CumEarned: cumEarned, CumSpent: cumSpent, }) } return out } // cmdFetchSolarisHistory returns timestamped solaris balance snapshots for a // player, joined directly via dune.accounts."user" = event_log.meta->>'fls_id'. // Returns at most 500 points with cumulative earned/spent in ascending order. func cmdFetchSolarisHistory(ctx context.Context, pool *pgxpool.Pool, accountID int64) ([]solarisPoint, error) { rows, err := pool.Query(ctx, ` SELECT to_char(el.event_time AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ROUND((el.meta->>'solaris_balance')::float)::bigint, COALESCE(ROUND((el.meta->>'solaris_delta')::float)::bigint, 0) FROM dune.event_log el JOIN dune.accounts ac ON ac."user" = el.meta->>'fls_id' WHERE ac.id = $1 AND el.meta->>'solaris_balance' IS NOT NULL ORDER BY el.event_time ASC LIMIT 500 `, accountID) if err != nil { return nil, fmt.Errorf("fetch solaris history for account %d: %w", accountID, err) } defer rows.Close() var raws []solarisRaw for rows.Next() { var r solarisRaw if err := rows.Scan(&r.Time, &r.Balance, &r.Delta); err != nil { return nil, fmt.Errorf("scan solaris point: %w", err) } raws = append(raws, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate solaris history for account %d: %w", accountID, err) } out := accumulateSolarisPoints(raws) if len(out) == 0 { out = []solarisPoint{} } return out, nil } // cmdFetchPlayerSnapshot queries the current stat values for one player from // Postgres. Returns a statSnapshot ready to write to SQLite. func cmdFetchPlayerSnapshot(ctx context.Context, pool *pgxpool.Pool, accountID int64, snappedAt string) (statSnapshot, error) { snap := statSnapshot{AccountID: accountID, SnappedAt: snappedAt} // Character XP and skill points from FLevelComponent. row := pool.QueryRow(ctx, ` SELECT (fe.components->'FLevelComponent'->1->>'TotalXPEarned')::bigint, (fe.components->'FLevelComponent'->1->>'TotalSkillPoints')::int FROM dune.fgl_entities fe JOIN dune.actor_fgl_entities afe ON afe.entity_id = fe.entity_id JOIN dune.player_state ps ON ps.player_pawn_id = afe.actor_id WHERE afe.slot_name = 'DuneCharacter' AND ps.account_id = $1 `, accountID) var charXP int64 var sp int if err := row.Scan(&charXP, &sp); err != nil && !errors.Is(err, pgx.ErrNoRows) { return snap, fmt.Errorf("fetch char xp snapshot for account %d: %w", accountID, err) } else if err == nil { snap.CharXP = &charXP snap.SkillPoints = &sp } // Intel points from TechKnowledgePlayerComponent on the pawn actor. row = pool.QueryRow(ctx, ` SELECT (a.properties->'TechKnowledgePlayerComponent'->>'m_TechKnowledgePoints')::int FROM dune.actors a JOIN dune.player_state ps ON ps.player_pawn_id = a.id WHERE ps.account_id = $1 AND a.properties ? 'TechKnowledgePlayerComponent' `, accountID) var intel int if err := row.Scan(&intel); err != nil && !errors.Is(err, pgx.ErrNoRows) { return snap, fmt.Errorf("fetch intel snapshot for account %d: %w", accountID, err) } else if err == nil { snap.IntelPoints = &intel } // Spec track XPs — NULL for players not in specialization_tracks. rows, err := pool.Query(ctx, ` SELECT track_type::text, xp_amount FROM dune.specialization_tracks st JOIN dune.player_state ps ON ps.player_controller_id = st.player_id WHERE ps.account_id = $1 `, accountID) if err != nil { return snap, fmt.Errorf("fetch spec snapshot for account %d: %w", accountID, err) } defer rows.Close() for rows.Next() { var track string var xp int if err := rows.Scan(&track, &xp); err != nil { return snap, fmt.Errorf("scan spec track: %w", err) } xpCopy := xp switch track { case "Combat": snap.CombatXP = &xpCopy case "Crafting": snap.CraftingXP = &xpCopy case "Gathering": snap.GatheringXP = &xpCopy case "Exploration": snap.ExplorationXP = &xpCopy case "Sabotage": snap.SabotageXP = &xpCopy } } if err := rows.Err(); err != nil { return snap, fmt.Errorf("iterate spec tracks for account %d: %w", accountID, err) } snap.SolarisBalance, err = fetchSolarisBalance(ctx, pool, accountID) if err != nil { return snap, err } return snap, nil } func fetchSolarisBalance(ctx context.Context, pool *pgxpool.Pool, accountID int64) (*int64, error) { var bal int64 err := pool.QueryRow(ctx, ` SELECT pvc.balance FROM dune.player_virtual_currency_balances pvc JOIN dune.player_state ps ON ps.player_controller_id = pvc.player_controller_id WHERE ps.account_id = $1 AND pvc.currency_id = 0 `, accountID).Scan(&bal) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("fetch solaris snapshot for account %d: %w", accountID, err) } return &bal, nil } // cmdActorIDFromFlsID resolves the player pawn actor ID from their hex FLS ID // (accounts."user"). Used by cmdRefillWaterOffline and similar offline writes // that require an actor_id to locate inventories. func cmdActorIDFromFlsID(ctx context.Context, flsID string) (int64, error) { if globalDB == nil { return 0, fmt.Errorf("not connected") } var actorID int64 err := globalDB.QueryRow(ctx, ` SELECT ps.player_pawn_id FROM dune.accounts ac JOIN dune.player_state ps ON ps.account_id = ac.id WHERE ac."user" = $1 LIMIT 1`, flsID).Scan(&actorID) if err != nil { return 0, fmt.Errorf("resolve actor id for fls_id %s: %w", flsID, err) } return actorID, nil } // cmdRefillWaterOffline sets CurrentAmount = MaxAmount for every water-fillable // item in the player's carried inventories (backpack, equipment, weapon slots, // action wheel, bank). Uses the waterFillableTemplates list generated from // DT_ItemTableFillables.json. For online players use rmqUpdateAllWaterFillables // instead — this path takes effect on the player's next relog. func cmdRefillWaterOffline(ctx context.Context, actorID int64) (int64, error) { if globalDB == nil { return 0, fmt.Errorf("not connected") } tag, err := globalDB.Exec(ctx, ` UPDATE dune.items i SET stats = jsonb_set( i.stats, '{FFillableItemStats,1,CurrentAmount}', (i.stats->'FFillableItemStats'->1->'MaxAmount') ) FROM dune.inventories inv WHERE inv.actor_id = $1 AND inv.inventory_type = ANY($2::int[]) AND i.inventory_id = inv.id AND lower(i.template_id) = ANY($3::text[]) AND i.stats ? 'FFillableItemStats' AND (i.stats->'FFillableItemStats'->1->'MaxAmount') IS NOT NULL`, actorID, repairGearInventoryTypes, waterFillableTemplates) if err != nil { return 0, fmt.Errorf("refill water offline actor %d: %w", actorID, err) } return tag.RowsAffected(), nil }