package storage import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" ) // OfflineDigestRow is persisted counters + loot lines for the post-offline summary. type OfflineDigestRow struct { MonstersKilled int `json:"monstersKilled"` XPGained int64 `json:"xpGained"` GoldGained int64 `json:"goldGained"` LevelsGained int `json:"levelsGained"` Deaths int `json:"deaths"` Revives int `json:"revives"` Loot []model.LootDrop `json:"loot"` UpdatedAt time.Time `json:"updatedAt"` } // OfflineDigestStore accumulates hero_offline_digest rows. type OfflineDigestStore struct { pool *pgxpool.Pool } func NewOfflineDigestStore(pool *pgxpool.Pool) *OfflineDigestStore { return &OfflineDigestStore{pool: pool} } // ApplyDelta merges a delta into the hero's digest row (upsert). func (s *OfflineDigestStore) ApplyDelta(ctx context.Context, heroID int64, d OfflineDigestDelta) error { if d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 && d.Deaths == 0 && d.Revives == 0 && len(d.LootAppend) == 0 { return nil } lootFragment := "[]" if len(d.LootAppend) > 0 { b, err := json.Marshal(d.LootAppend) if err != nil { return fmt.Errorf("marshal loot fragment: %w", err) } lootFragment = string(b) } _, err := s.pool.Exec(ctx, ` INSERT INTO hero_offline_digest ( hero_id, monsters_killed, xp_gained, gold_gained, levels_gained, deaths, revives, loot, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now()) ON CONFLICT (hero_id) DO UPDATE SET monsters_killed = hero_offline_digest.monsters_killed + EXCLUDED.monsters_killed, xp_gained = hero_offline_digest.xp_gained + EXCLUDED.xp_gained, gold_gained = hero_offline_digest.gold_gained + EXCLUDED.gold_gained, levels_gained = hero_offline_digest.levels_gained + EXCLUDED.levels_gained, deaths = hero_offline_digest.deaths + EXCLUDED.deaths, revives = hero_offline_digest.revives + EXCLUDED.revives, loot = hero_offline_digest.loot || EXCLUDED.loot, updated_at = now() `, heroID, d.MonstersKilled, d.XPGained, d.GoldGained, d.LevelsGained, d.Deaths, d.Revives, lootFragment) if err != nil { return fmt.Errorf("apply offline digest delta: %w", err) } return nil } // OfflineDigestDelta is a single batch of offline stats to merge. type OfflineDigestDelta struct { MonstersKilled int XPGained int64 GoldGained int64 LevelsGained int Deaths int Revives int LootAppend []model.LootDrop } // Get returns the current digest row or zero values if missing. func (s *OfflineDigestStore) Get(ctx context.Context, heroID int64) (OfflineDigestRow, error) { var row OfflineDigestRow var lootRaw []byte err := s.pool.QueryRow(ctx, ` SELECT monsters_killed, xp_gained, gold_gained, levels_gained, deaths, revives, loot, updated_at FROM hero_offline_digest WHERE hero_id = $1 `, heroID).Scan( &row.MonstersKilled, &row.XPGained, &row.GoldGained, &row.LevelsGained, &row.Deaths, &row.Revives, &lootRaw, &row.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { row.Loot = []model.LootDrop{} return row, nil } return OfflineDigestRow{}, fmt.Errorf("get offline digest: %w", err) } if len(lootRaw) > 0 { if err := json.Unmarshal(lootRaw, &row.Loot); err != nil { row.Loot = nil } } if row.Loot == nil { row.Loot = []model.LootDrop{} } return row, nil } // TakeDelete returns the digest and removes the row (for handing summary to client once). func (s *OfflineDigestStore) TakeDelete(ctx context.Context, heroID int64) (OfflineDigestRow, error) { row, err := s.Get(ctx, heroID) if err != nil { return OfflineDigestRow{}, err } if _, err := s.pool.Exec(ctx, `DELETE FROM hero_offline_digest WHERE hero_id = $1`, heroID); err != nil { return OfflineDigestRow{}, fmt.Errorf("delete offline digest: %w", err) } return row, nil }