Files
corrosion-admin-panel/docs/reference-repos/icehunter/cmd/dune-admin/db.go
Vantz Stockwell 651a35d4be
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:08:05 -04:00

6244 lines
208 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <id>"
// 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":<float>,"ReputationAmount":<int>}.
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(&currentRep)
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 (020).
// 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 (020) 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 020")}
}
// 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.<X>.Tier<N> (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.<X>.Tier<N> (N ∈ 05) 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(&current)
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.<Job>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.<Old>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.<Job>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.<Job>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.<X> 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 520 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.<X>.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 Tier05 + 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 (1200).
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(&currentXP, &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, &currentTotal, &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, &currentTotal, &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 0100 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, &currentDurability, &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 '##<PlaceableType>_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(&currentCount); 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
}