package games import ( "context" "database/sql" "errors" "fmt" "time" "github.com/mithral/vellum/internal/core" ) // MaxSaveBytes caps an uploaded save state. EmulatorJS states are typically a // few hundred kilobytes; anything near this limit is a client bug. const MaxSaveBytes = 16 << 20 // 16 MiB // Save is one stored save state. type Save struct { Slot string `json:"slot"` Size int64 `json:"size"` UpdatedAt time.Time `json:"updated_at"` } // ListSaves returns a user's save slots for an item, newest first. func ListSaves(ctx context.Context, db *sql.DB, userID, itemID int64) ([]Save, error) { rows, err := db.QueryContext(ctx, ` SELECT slot, size, updated_at FROM game_saves WHERE user_id = ? AND item_id = ? ORDER BY updated_at DESC`, userID, itemID) if err != nil { return nil, fmt.Errorf("games: list saves: %w", err) } defer rows.Close() out := []Save{} for rows.Next() { var s Save var updated string if err := rows.Scan(&s.Slot, &s.Size, &updated); err != nil { return nil, err } s.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) out = append(out, s) } return out, rows.Err() } // GetSave returns one save state's bytes. The bool is false when no save // exists for that slot. func GetSave(ctx context.Context, db *sql.DB, userID, itemID int64, slot string) ([]byte, bool, error) { var data []byte err := db.QueryRowContext(ctx, ` SELECT data FROM game_saves WHERE user_id = ? AND item_id = ? AND slot = ?`, userID, itemID, slot).Scan(&data) if errors.Is(err, sql.ErrNoRows) { return nil, false, nil } if err != nil { return nil, false, fmt.Errorf("games: get save: %w", err) } return data, true, nil } // PutSave upserts a save state. func PutSave(ctx context.Context, db *sql.DB, userID, itemID int64, slot string, data []byte) error { if len(data) == 0 { return errors.New("games: save is empty") } if len(data) > MaxSaveBytes { return fmt.Errorf("games: save exceeds %d bytes", MaxSaveBytes) } _, err := db.ExecContext(ctx, ` INSERT INTO game_saves (user_id, item_id, slot, data, size, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, item_id, slot) DO UPDATE SET data = excluded.data, size = excluded.size, updated_at = excluded.updated_at`, userID, itemID, slot, data, len(data), core.Now()) if err != nil { return fmt.Errorf("games: put save: %w", err) } return nil } // DeleteSave removes one save slot. func DeleteSave(ctx context.Context, db *sql.DB, userID, itemID int64, slot string) error { _, err := db.ExecContext(ctx, ` DELETE FROM game_saves WHERE user_id = ? AND item_id = ? AND slot = ?`, userID, itemID, slot) return err }