Appearance
Game Saves
game:saves lets a personal plugin read the user's own local save files for a supported game. The Dissent desktop app reads the files on the user's computer and hands the plugin an allow-listed projection: every field that reaches a plugin is named on purpose, and a game update that adds data to its saves adds nothing to what plugins receive.
The rule the projection follows: a plugin receives only what the game has already shown the player. No RNG seeds, no upcoming fights or events, no later acts, no Steam IDs, no file paths. Nothing is uploaded — the data goes from the file to your frame.
Methods
javascript
await Dissent.gameSaves.currentRun(game)
await Dissent.gameSaves.runs(game, { limit, before })
await Dissent.gameSaves.run(game, id)
await Dissent.gameSaves.profileStats(game)
const off = Dissent.gameSaves.onChange(({ game }) => { /* pull again */ })game is a catalog id. Supported today: slay-the-spire-2 (Windows).
Statuses — answers, never exceptions
Every answer is an object with a status. Only ok carries data. Render the others as plain sentences rather than an empty panel.
status | Meaning | Say something like |
|---|---|---|
ok | The answer's data follows | — |
desktop_only | Not running in the desktop app (web, mobile) | "Open Dissent desktop to see your runs." |
no_game_data | No save folder for this game on this computer | "No saves found on this computer." |
no_current_run | currentRun: no run in progress | "No run in progress." |
unsupported_version | The game changed its save format; the desktop app needs an update | "Dissent desktop needs an update to read this game's saves." |
unsupported_coop | currentRun: a co-op run is in progress and the desktop app is older than v1.2.239. Current versions read co-op runs | "Update Dissent desktop to see this co-op run." |
unreadable | The file was being written; retry on the next cue | "Couldn't read the save — it will retry." |
not_found | run: no finished run with that id | "That run is no longer in your history." |
unsupported_game | No adapter for that catalog id | — |
Freshness
After a plugin's first game.saves call, the desktop app watches the save folder and emits game.saves.changed within about two seconds of the game rewriting a file. Treat it as a cue to pull again, not as data.
As of the last room change
Slay the Spire 2 writes its save when the player enters or leaves a room — never during a fight. Say "as of entering this room" rather than implying live state.
onChange is delivered to a plugin's in-app page. Overlay panels do not receive it; re-read when the overlay opens (see Overlay Panels).
Ids
Ids in answers are the game's own, prefixed by kind: CARD.BASH, RELIC.BURNING_BLOOD, POTION.FIRE_POTION, ENCOUNTER.NIBBITS_WEAK, EVENT.NEOW, CHARACTER.IRONCLAD, ACT.UNDERDOCKS. Strip the prefix to look an entity up in your own data.
Old runs mention things that no longer exist
A card removed by a game patch still appears in runs played before it. Render an unknown id plainly — never a blank tile, never a crash.
Slay the Spire 2 — answer shapes
currentRun
typescript
{
status: 'ok',
game: 'slay-the-spire-2',
saved_at: number, // unix seconds
you: number | null, // which entry in players[] is this computer's player; null if unknown (co-op)
character: string, // YOUR character (player 1's when `you` is null)
ascension: number,
game_mode: string, // 'standard'
act: { index: number, id: string, boss: string }, // the CURRENT act only; its boss is on the map
map: {
width: number, height: number,
start: MapNode, boss: MapNode,
nodes: MapNode[],
visited: { col: number, row: number }[],
},
players: [{ // one per player — two or more in co-op
player: number, // 1, 2, … — a position, never a Steam id
character: string,
hp: number, max_hp: number, gold: number,
max_energy: number, potion_slots: number,
deck: Card[],
relics: string[],
potions: string[],
}],
floors: Floor[], // floors played so far
}
type MapNode = { col: number, row: number, type: string, children: { col: number, row: number }[] };
type Card = { id: string, upgrades: number, enchantment?: string };runs — finished runs, newest first
typescript
{
status: 'ok',
game: 'slay-the-spire-2',
runs: RunSummary[],
skipped: number, // files that could not be read (e.g. another game version)
next_before?: string | null, // pass as `before` for the next page; null when nothing older remains
}
type RunSummary = {
id: string, // digits; pass to run(id) and as `before`
started_at: number, // unix seconds
run_time: number, // seconds
characters: string[],
players: number,
you: number | null, // which player (1, 2, …) was this computer's; null in co-op if unknown
ascension: number,
game_mode: string,
win: boolean,
abandoned: boolean,
floors: number,
killed_by: string | null, // 'ENCOUNTER.…' or 'EVENT.…'
build: string, // game build that wrote the run, e.g. 'v0.99.1'
};limit is 1–100 (default 20). To page, pass next_before as before; stop when it is null.
Paging past unreadable files
limit counts files before unreadable ones are dropped into skipped, so a page can come back with fewer runs than limit — or none — while older runs still exist. next_before accounts for that. Older desktop apps do not send it: then pass the last run's id, and stop on an empty page rather than starting again from the top.
run — one finished run
typescript
{
status: 'ok',
game: 'slay-the-spire-2',
summary: RunSummary,
acts: string[],
players: [{ player: number, character: string, deck: Card[], relics: string[], potions: string[] }],
floors: Floor[],
}Players are numbered 1, 2, … in the order the game lists them; identifiers from the file are never passed through.
Floor
typescript
type Floor = {
act: number, // 0-based
type: string, // 'monster' | 'elite' | 'boss' | 'shop' | 'rest_site' | 'treasure' | 'ancient' | 'unknown' …
rooms: { id: string | null, type: string, monsters: string[], turns: number }[],
players: FloorPlayer[],
};
type FloorPlayer = {
player: number,
hp: number, max_hp: number, gold: number,
damage_taken: number, hp_healed: number,
max_hp_gained: number, max_hp_lost: number,
gold_gained: number, gold_spent: number, gold_lost: number,
card_choices: { id: string, picked: boolean }[], // rewards — and a shop's cards for sale
relic_choices: { id: string, picked: boolean }[],
potion_choices: { id: string, picked: boolean }[],
ancient_choices: { id: string, picked: boolean }[], // id UNPREFIXED, e.g. 'YUMMY_COOKIE' (a relic)
cards_gained: string[],
cards_removed: string[],
cards_transformed: { from: string, to: string }[],
upgraded_cards: string[],
bought_relics: string[],
potions_used: string[],
rest_site_choices: string[], // 'HEAL', 'SMITH', …
event_choices: string[], // localisation keys, e.g. 'BYRDONIS_NEST.pages.INITIAL.options.TAKE.title'
};A shop is not a reward
On a shop floor, card_choices lists the cards that were for sale; picked means bought. Label it accordingly.
profileStats
typescript
{
status: 'ok',
game: 'slay-the-spire-2',
cards: { id: string, picked: number, skipped: number, won: number, lost: number }[],
characters: {
id: string, wins: number, losses: number,
best_streak: number, current_streak: number, max_ascension: number,
fastest_win_secs: number, playtime_secs: number,
}[],
}