@looop-games/cli 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/bin/looop.mjs +18 -0
- package/lib/dev.mjs +6 -0
- package/lib/replay-cmd.mjs +155 -0
- package/lib/replay-export.mjs +515 -0
- package/lib/replay-store.mjs +440 -0
- package/lib/room-server.mjs +47 -6
- package/lib/static-server.mjs +363 -2
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,17 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.36] - 2026-09-04
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`looop dev` keeps the sessions you play.** As you play, the room hands the dev server each slice of the recording and it lands under your game's `.looop/replays/`. Watch one back by adding `?replay=<id>` to the game's URL — the id is printed to the console when the session starts. Nothing leaves your machine, and nothing is kept when the room is not a dev room.
|
|
22
|
+
- **`?replay=last`** — the newest recording, resolved by the dev server, so watching the session you just played does not start with copying an id.
|
|
23
|
+
- **Old recordings are swept, so a game folder stops growing forever.** When `looop dev` starts it keeps the newest **20 recordings** and up to **200 MB** per game, whichever runs out first — roughly an hour of play for a busy game — and removes the oldest past that, saying on the way up how many went. To protect one, hit **keep** on its row in the toolbox's Sessions panel; a kept recording is never swept. (It still counts toward the caps: keeping means "don't delete this", not "don't charge me for it".)
|
|
24
|
+
- **`looop replay export` — a session you played, as a table you can query.** A recording holds what you PRESSED, not what happened: no position, no speed, no score is in it anywhere. So this replays the session through your own game, reads the world at every tick, and writes it down as Parquet — one row per tick per entity, one column per declared field, plus a second table of every input you sent. Running the game costs a few seconds once; every question you ask afterwards is free, in SQL, with DuckDB or pandas or anything else that reads Parquet. Measured on a real 27-second session: 279,367 rows x 443 columns in 10 seconds, 3.9 MB on disk, and a query answered in 0.02 s. Numbers come back bit-for-bit as the simulation held them. `npx looop replay export --help` for the schema, or `shared/docs/replay-export.md`.
|
|
25
|
+
- **`looop replay export` tells you when the table will not be the session you played — before it spends the time building it.** A recording holds what you pressed, not what happened, so the numbers come from running your current code on old inputs. The export now compares the recording against what you are running and says up front if the engine version or the shape of your game has moved since. While it replays it also reports how many keyframes put playback back onto the recorded state, and the divergence warning no longer claims that everything after a divergence is worthless — a drift is bounded to the stretch before the next keyframe, and saying otherwise had people binning tables that were mostly fine.
|
|
26
|
+
- The dev server answers the toolbox's Sessions panel — listing this game's recordings, deleting one, and pinning one. Only ever on the dev server, and deleting or pinning a recording works only from a page this dev server itself served. `looop dev` listens on every network interface so your phone can play, which means everything else on that wifi can reach it too — on a cafe or coworking network that is not a set of machines you control, and nothing there can now remove one of your sessions.
|
|
27
|
+
|
|
17
28
|
## [0.1.35] - 2026-09-02
|
|
18
29
|
|
|
19
30
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -36,6 +36,7 @@ Usage:
|
|
|
36
36
|
looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
|
|
37
37
|
looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
|
|
38
38
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
39
|
+
looop replay export [<id>] Turn a recorded session into a table you can query (Parquet)
|
|
39
40
|
looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
|
|
40
41
|
looop login Authenticate this machine as your Looop player account
|
|
41
42
|
looop logout Forget the stored token
|
|
@@ -124,6 +125,23 @@ try {
|
|
|
124
125
|
await bakeModel(glb);
|
|
125
126
|
break;
|
|
126
127
|
}
|
|
128
|
+
case 'replay': {
|
|
129
|
+
// `replay` is the accessor for a recorded session; `export` is its first
|
|
130
|
+
// verb. Watching one back needs no command at all — it is `?replay=<id>`
|
|
131
|
+
// on the game's URL, or the toolbox's Sessions panel.
|
|
132
|
+
const { REPLAY_HELP, replayExport, parseReplayArgs } = await import('../lib/replay-cmd.mjs');
|
|
133
|
+
// Parsed in the lib so the argv split is under test — a flag's value must
|
|
134
|
+
// never slide into the session slot, and --help must be answered before
|
|
135
|
+
// anything is read as an id.
|
|
136
|
+
const { verb, id, out, help } = parseReplayArgs(rest);
|
|
137
|
+
if (help) {
|
|
138
|
+
console.log(REPLAY_HELP);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (verb !== 'export') throw new Error(`Unknown replay command: ${verb} — try: looop replay export`);
|
|
142
|
+
await replayExport({ stream: id, out });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
127
145
|
case 'publish':
|
|
128
146
|
await publish({ slug: flag('slug') });
|
|
129
147
|
break;
|
package/lib/dev.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { join, dirname, basename } from 'node:path';
|
|
|
14
14
|
import { findProject } from './project.mjs';
|
|
15
15
|
import { ensureEngine } from './engine.mjs';
|
|
16
16
|
import { createStaticServer } from './static-server.mjs';
|
|
17
|
+
import { pruneOnStart } from './replay-store.mjs';
|
|
17
18
|
import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
18
19
|
import { getToken, getApiBase } from './config.mjs';
|
|
19
20
|
import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
|
|
@@ -62,6 +63,11 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
62
63
|
const { ensureBaked } = await import('./model-bake.mjs');
|
|
63
64
|
await ensureBaked({ dir: project.dir, log });
|
|
64
65
|
|
|
66
|
+
// Recordings accumulate on the creator's own disk — every session played
|
|
67
|
+
// under `looop dev` writes one — so the oldest go here, before anything can
|
|
68
|
+
// be watching one. The caps and the keep marker live in replay-store.mjs.
|
|
69
|
+
pruneOnStart({ projectDir: project.dir, log });
|
|
70
|
+
|
|
65
71
|
// Which ports, and may we take them? An explicit --port is obeyed as given
|
|
66
72
|
// (takeover included). With no flag we step around anyone else's stack —
|
|
67
73
|
// another lane, another game, an unrelated app — and only ever reclaim our
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `looop replay export` — the recorded session, on disk as a table.
|
|
2
|
+
//
|
|
3
|
+
// This is the CLI's half: find the game, resolve the engine it is pinned to,
|
|
4
|
+
// hand both to the exporter, and then say enough about what came out that the
|
|
5
|
+
// next step is obvious. The table's shape and every decision about it live in
|
|
6
|
+
// replay-export.mjs; nothing here knows what a column is.
|
|
7
|
+
import { join, relative, resolve } from 'node:path';
|
|
8
|
+
import { statSync } from 'node:fs';
|
|
9
|
+
import { findProject } from './project.mjs';
|
|
10
|
+
import { streamDir } from './replay-store.mjs';
|
|
11
|
+
import { ensureEngine } from './engine.mjs';
|
|
12
|
+
import { exportSession } from './replay-export.mjs';
|
|
13
|
+
|
|
14
|
+
export const EXPORTS_DIR = ['.looop', 'exports'];
|
|
15
|
+
|
|
16
|
+
export const REPLAY_HELP = `looop replay — work with the sessions this game has recorded
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
looop replay export [<session>] [--out <dir>]
|
|
20
|
+
|
|
21
|
+
<session> Which recording. Defaults to the most recent one.
|
|
22
|
+
--out Where the tables go. Defaults to .looop/exports/
|
|
23
|
+
|
|
24
|
+
Writes two Parquet tables:
|
|
25
|
+
|
|
26
|
+
<session>.state.parquet one row per tick per entity, every declared field
|
|
27
|
+
a column (def/id/owner/tick lead)
|
|
28
|
+
<session>.inputs.parquet one row per input the player sent
|
|
29
|
+
|
|
30
|
+
A recording holds what was PRESSED, not what happened — so the export replays
|
|
31
|
+
the session through your game to work out where everything was, then writes it
|
|
32
|
+
down. That costs a few seconds once; every question you ask of the table
|
|
33
|
+
afterwards is free.
|
|
34
|
+
|
|
35
|
+
duckdb -c "SELECT def, count(*) FROM '.looop/exports/<session>.state.parquet' GROUP BY def"
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Split `looop replay …`'s arguments into the verb, the session, and the flags.
|
|
40
|
+
*
|
|
41
|
+
* A flag's VALUE is a positional-looking word, so dropping only the tokens that
|
|
42
|
+
* start with `--` leaves `tables` from `--out tables` sitting in the session
|
|
43
|
+
* slot — and the export then reports it cannot find a recording the creator
|
|
44
|
+
* never named. The same trap is why `--help` has to be answered before any of
|
|
45
|
+
* this is used as an id.
|
|
46
|
+
*/
|
|
47
|
+
export function parseReplayArgs(argv = []) {
|
|
48
|
+
const flag = (name) => {
|
|
49
|
+
const i = argv.indexOf(`--${name}`);
|
|
50
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
51
|
+
};
|
|
52
|
+
const positional = argv.filter((a, i) => !a.startsWith('-') && !argv[i - 1]?.startsWith('--'));
|
|
53
|
+
const [verb, id] = positional;
|
|
54
|
+
return {
|
|
55
|
+
verb,
|
|
56
|
+
id,
|
|
57
|
+
out: flag('out'),
|
|
58
|
+
help: !verb || verb === 'help' || argv.includes('--help') || argv.includes('-h'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mb = (file) => `${(statSync(file).size / 1024 / 1024).toFixed(1)} MB`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {string} [opts.cwd]
|
|
67
|
+
* @param {string} [opts.stream] - which recording; defaults to the most recent
|
|
68
|
+
* @param {string} [opts.out] - where the tables go
|
|
69
|
+
* @param {(msg: string) => void} [opts.log]
|
|
70
|
+
* @param {(dir: string, o: object) => Promise<object>} [opts.ensureEngineImpl]
|
|
71
|
+
* @param {(o: object) => Promise<object>} [opts.exportImpl]
|
|
72
|
+
*/
|
|
73
|
+
export async function replayExport({
|
|
74
|
+
cwd = process.cwd(), stream, out, log = console.log,
|
|
75
|
+
ensureEngineImpl = ensureEngine, exportImpl = exportSession,
|
|
76
|
+
} = {}) {
|
|
77
|
+
const project = findProject(cwd);
|
|
78
|
+
// The id is about to become a path to read from AND a filename to write. Every
|
|
79
|
+
// other door into the store runs one through `streamDir`, which refuses rather
|
|
80
|
+
// than sanitizes; this one is no different for being typed by hand.
|
|
81
|
+
if (stream) streamDir(project.dir, stream);
|
|
82
|
+
const outDir = out ? resolve(project.dir, out) : join(project.dir, ...EXPORTS_DIR);
|
|
83
|
+
// The tables are tens of megabytes and this creates directories to hold them.
|
|
84
|
+
// A path that climbs out of the game folder is a typo, not an intention.
|
|
85
|
+
if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}/`)) {
|
|
86
|
+
throw new Error(`--out must be inside the game folder, and ${out} is not`);
|
|
87
|
+
}
|
|
88
|
+
const engine = await ensureEngineImpl(project.dir, { log });
|
|
89
|
+
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
const res = await exportImpl({
|
|
92
|
+
dir: project.dir, stream, outDir, sharedDir: engine.sharedDir,
|
|
93
|
+
engineVersion: engine.version, log,
|
|
94
|
+
});
|
|
95
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
96
|
+
|
|
97
|
+
const rel = (f) => relative(project.dir, f);
|
|
98
|
+
log(`exported ${res.stream} in ${secs}s`);
|
|
99
|
+
log(` ${rel(res.stateFile)} ${res.rows.toLocaleString()} rows x ${res.columns} columns (${mb(res.stateFile)})`);
|
|
100
|
+
log(` ${rel(res.inputFile)} ${res.inputs.toLocaleString()} inputs (${mb(res.inputFile)})`);
|
|
101
|
+
// Three different ways the table can describe a world that is not the one
|
|
102
|
+
// that was played. None of them is buried: an export that looks clean and is
|
|
103
|
+
// not is worse than one that failed, because the answers it gives are wrong
|
|
104
|
+
// and confident.
|
|
105
|
+
if (res.divergences) {
|
|
106
|
+
// What bounds the damage is the keyframe: the stream carries the room's
|
|
107
|
+
// complete true state roughly every 30 s, and playback restores it, so a
|
|
108
|
+
// drift owns one keyframe interval rather than everything downstream.
|
|
109
|
+
// Saying otherwise sends a reader to bin a table that was mostly fine.
|
|
110
|
+
//
|
|
111
|
+
// Only a re-anchor AFTER the last divergence says that last drift was
|
|
112
|
+
// undone; earlier ones belong to earlier drifts. And a re-anchor is recorded
|
|
113
|
+
// only when the restore actually changed the world, so zero of them does not
|
|
114
|
+
// mean nothing corrected it — a drift that came right again on its own also
|
|
115
|
+
// records none. Claiming "the drift ran to the end of the session" from a
|
|
116
|
+
// zero count states something this code cannot know.
|
|
117
|
+
const corrected = res.lastReanchorTick != null
|
|
118
|
+
&& res.lastDivergenceTick != null
|
|
119
|
+
&& res.lastReanchorTick > res.lastDivergenceTick;
|
|
120
|
+
const putBack = corrected
|
|
121
|
+
? ` Playback was put back onto the recorded state at tick ${res.lastReanchorTick}, after the last of them.`
|
|
122
|
+
: ' Nothing is recorded as putting it back after the last one, so treat the rows from there to the end as this run\'s.';
|
|
123
|
+
log(` ⚠ the replay parted from the recording ${res.divergences} time(s) — from each of those `
|
|
124
|
+
+ 'ticks until the next keyframe, the rows are this run\'s world rather than the recorded one.'
|
|
125
|
+
+ putBack
|
|
126
|
+
+ ' The usual cause is that the game changed since the session was recorded; a replay runs '
|
|
127
|
+
+ 'the code as it is now.');
|
|
128
|
+
}
|
|
129
|
+
if (res.gaps) {
|
|
130
|
+
log(` ⚠ the recording is missing ${res.gaps} segment(s) — the inputs in those gaps were never `
|
|
131
|
+
+ 'stored, so the world after each one is not the session that was played');
|
|
132
|
+
}
|
|
133
|
+
if (res.unsupported) {
|
|
134
|
+
log(` ⚠ ${res.unsupported} recorded event(s) cannot be reproduced by a replay (a live tune, say) `
|
|
135
|
+
+ '— the world after each one is not the session that was played');
|
|
136
|
+
}
|
|
137
|
+
// Repeated from before the replay. That is the right place to say it first —
|
|
138
|
+
// the answer is known and the replay is the slow part — but a big export puts
|
|
139
|
+
// screens of output in between, so the line deciding whether to trust the
|
|
140
|
+
// table would be the one line scrolled off. Costs nothing; both values are
|
|
141
|
+
// already on the result.
|
|
142
|
+
if (res.recordedEngine && res.runningEngine && res.recordedEngine !== res.runningEngine) {
|
|
143
|
+
log(` ⚠ recorded by engine ${res.recordedEngine}, replayed on ${res.runningEngine}`);
|
|
144
|
+
}
|
|
145
|
+
if (res.codeChanged) {
|
|
146
|
+
log(' ⚠ the game has changed since this session was recorded');
|
|
147
|
+
}
|
|
148
|
+
if (res.undoubleable) {
|
|
149
|
+
log(` note: ${res.undoubleable} numeric cell(s) held NaN or ±Infinity, which Parquet has no double `
|
|
150
|
+
+ 'for, and read as null — the same as a field the entity kind does not declare');
|
|
151
|
+
}
|
|
152
|
+
log('');
|
|
153
|
+
log(`Ask it something: duckdb -c "SELECT def, count(*) FROM '${rel(res.stateFile)}' GROUP BY def"`);
|
|
154
|
+
return res;
|
|
155
|
+
}
|