The RomM replacement. - registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA, N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket, WonderSwan, Atari, Coleco, Intellivision, and PlayStation. - games: platform detection from the file extension, mapping each to an EmulatorJS core. Platforms with no browser core are recognised but marked not-playable so the UI offers a download instead of a broken player. - migration 0005: game_saves keyed by (user, item, slot). Save states are user data, not media — two people playing the same ROM keep their own progress. - save endpoints with a 16 MiB cap and a validated slot name; the upload is read through a LimitReader so an unbounded body is refused rather than buffered. - igdb provider: Twitch client-credentials flow with the token cached for its ~2 month lifetime, Apicalypse query body, and cover URLs normalised from protocol-relative t_thumb to https t_cover_big. - searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog" matches immediately. Stored titles keep the original. - web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons. Verified live against IGDB: Sonic matched (id 316900) with real cover art; a 4 KB save uploaded, listed, downloaded byte-identical, and deleted.
88 lines
2.6 KiB
Go
88 lines
2.6 KiB
Go
package games
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// 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), time.Now().UTC().Format(time.RFC3339Nano))
|
|
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
|
|
}
|