@looop-games/cli 0.1.20 → 0.1.22
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 +33 -0
- package/bin/looop.mjs +5 -1
- package/lib/changelog.mjs +1 -1
- package/lib/feedback-sync.mjs +232 -0
- package/lib/feedback.mjs +20 -2
- package/lib/model-bake.mjs +54 -5
- package/lib/update.mjs +22 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,39 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.22] - 2026-08-01
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `looop feedback` now brings the conversation back: after sending your unsent
|
|
21
|
+
reports, it pulls each report's outcome from the Looop team and stamps it into
|
|
22
|
+
the report file itself — `status:` / `resolved-in:` in the frontmatter, team
|
|
23
|
+
messages appended under `## Updates`. The file under `notes/feedback/` is the
|
|
24
|
+
whole thread, readable offline.
|
|
25
|
+
- You can reply on a report: add a `## Reply (unsent)` section to a sent report
|
|
26
|
+
file and run `looop feedback` — the reply joins the same thread, and a closed
|
|
27
|
+
report you reply to is automatically reopened on the Looop side. Reply when
|
|
28
|
+
something still needs doing; a new problem is a new report.
|
|
29
|
+
- `looop update` now tells you when a report you filed was resolved in one of
|
|
30
|
+
the versions you just crossed ("Feedback you filed landed in this update"),
|
|
31
|
+
next to the changelog it already prints.
|
|
32
|
+
|
|
33
|
+
## [0.1.21] - 2026-07-30
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`looop model bake` resolves a pivot for the model.** `<model>.bake.json`
|
|
38
|
+
may declare `"pivot": "origin"` (default) | `"center"` | `"feet"` |
|
|
39
|
+
`[x, y, z]` — the model-space point that lands on your entity's
|
|
40
|
+
`body.(x,y,z)`. The bake resolves it against the bind-pose bounding box,
|
|
41
|
+
writes it into the skeleton artifact, and measures the broadphase radius
|
|
42
|
+
about it (a sphere centred on the pivot but measured from the origin could
|
|
43
|
+
silently reject true edge hits). Every model re-bakes once on your next
|
|
44
|
+
`looop dev`/`test`/`publish` to pick up the new artifact format; without a
|
|
45
|
+
`pivot` entry nothing changes in how it plays. Opting into a non-origin
|
|
46
|
+
pivot needs an engine that reads it (0.1.35+) — run `npx looop update`
|
|
47
|
+
first, or the room will place the model by its origin while the broadphase
|
|
48
|
+
is measured about the pivot.
|
|
49
|
+
|
|
17
50
|
## [0.1.20] - 2026-07-29
|
|
18
51
|
|
|
19
52
|
### Added
|
package/bin/looop.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { lintCmd } from '../lib/lint.mjs';
|
|
|
13
13
|
import { update } from '../lib/update.mjs';
|
|
14
14
|
import { changelog } from '../lib/changelog.mjs';
|
|
15
15
|
import { sendFeedback } from '../lib/feedback.mjs';
|
|
16
|
+
import { syncFeedback } from '../lib/feedback-sync.mjs';
|
|
16
17
|
import { clearToken } from '../lib/config.mjs';
|
|
17
18
|
|
|
18
19
|
const [, , cmd, ...rest] = process.argv;
|
|
@@ -34,7 +35,7 @@ Usage:
|
|
|
34
35
|
looop update Move this game to the latest engine release (re-pins looop.engine)
|
|
35
36
|
looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
|
|
36
37
|
looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
|
|
37
|
-
looop feedback Send
|
|
38
|
+
looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
|
|
38
39
|
looop login Authenticate this machine as your Looop player account
|
|
39
40
|
looop logout Forget the stored token
|
|
40
41
|
looop whoami Show who this machine publishes as
|
|
@@ -103,7 +104,10 @@ try {
|
|
|
103
104
|
await publish({ slug: flag('slug') });
|
|
104
105
|
break;
|
|
105
106
|
case 'feedback':
|
|
107
|
+
// Send first, then sync: a report filed this run is already stamped with
|
|
108
|
+
// its id, so the same invocation can pull any outcome waiting for it.
|
|
106
109
|
await sendFeedback();
|
|
110
|
+
await syncFeedback();
|
|
107
111
|
break;
|
|
108
112
|
case 'login':
|
|
109
113
|
await login();
|
package/lib/changelog.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `looop changelog` — what changed in the engine
|
|
1
|
+
// `looop changelog` — what changed in the engine.
|
|
2
2
|
//
|
|
3
3
|
// The gap this closes: `looop update` used to say "Engine updated: 0.1.10 →
|
|
4
4
|
// 0.1.12" and then list the SKILL FILES it rewrote. It said nothing about what
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// `looop feedback` — the back-channel half. The send half
|
|
2
|
+
// (feedback.mjs) ships reports OUT; this module brings the conversation BACK:
|
|
3
|
+
//
|
|
4
|
+
// 1. Push replies. A report file can carry a `## Reply (unsent)` section —
|
|
5
|
+
// the agent writes one when something about the outcome still needs
|
|
6
|
+
// doing. It ships to /api/creator/feedback/reply and the heading is
|
|
7
|
+
// stamped with the returned fu_ id, so it never ships twice.
|
|
8
|
+
// 2. Fetch outcomes. /api/creator/feedback/status returns, for every report
|
|
9
|
+
// this account filed: its status (read / done / discarded), the engine
|
|
10
|
+
// version a fix landed in, and the update thread. Everything is stamped
|
|
11
|
+
// back INTO the report file — `status:` / `resolved-in:` in frontmatter,
|
|
12
|
+
// unseen updates appended under `## Updates` — so the file under
|
|
13
|
+
// notes/feedback/ IS the thread, readable offline by any agent.
|
|
14
|
+
//
|
|
15
|
+
// Idempotency is by fu_ id: every update written into the file carries its
|
|
16
|
+
// `fu_<16 hex>` marker (in an HTML comment, or on a sent-reply heading), and
|
|
17
|
+
// anything whose id already appears in the file is never appended again.
|
|
18
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { basename } from 'node:path';
|
|
20
|
+
import { findProject } from './project.mjs';
|
|
21
|
+
import { getToken, getApiBase } from './config.mjs';
|
|
22
|
+
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
23
|
+
import { discoverSent } from './feedback.mjs';
|
|
24
|
+
import { compareVersions } from './changelog.mjs';
|
|
25
|
+
|
|
26
|
+
const UPDATE_ID = /fu_[0-9a-f]{16}/g;
|
|
27
|
+
const UNSENT_REPLY = /^## Reply \(unsent\)\s*$/m;
|
|
28
|
+
|
|
29
|
+
// Every unsent reply section: the text between a `## Reply (unsent)` heading
|
|
30
|
+
// and the next `## ` heading (or EOF). A `## ` line inside a ``` / ~~~ fence
|
|
31
|
+
// is CONTENT, not a boundary — replies quote markdown evidence, and stopping
|
|
32
|
+
// at a fenced heading would ship half the reply and strand the rest under an
|
|
33
|
+
// already-stamped heading where it can never be sent.
|
|
34
|
+
export function extractUnsentReplies(text) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const lines = text.split('\n');
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
if (!/^## Reply \(unsent\)\s*$/.test(lines[i])) continue;
|
|
39
|
+
const body = [];
|
|
40
|
+
let fenced = false;
|
|
41
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
42
|
+
if (/^(```|~~~)/.test(lines[j])) fenced = !fenced;
|
|
43
|
+
else if (!fenced && /^## /.test(lines[j])) break;
|
|
44
|
+
body.push(lines[j]);
|
|
45
|
+
}
|
|
46
|
+
out.push({ body: body.join('\n').trim() });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function knownUpdateIds(text) {
|
|
52
|
+
return new Set(text.match(UPDATE_ID) ?? []);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Upsert `status:` / `resolved-in:` into the frontmatter block. The report was
|
|
56
|
+
// stamped `sent:` + `id:` when it shipped, so a synced file always has
|
|
57
|
+
// frontmatter; a file without one is left alone. CRLF files (a Windows clone
|
|
58
|
+
// with autocrlf) get their line endings preserved.
|
|
59
|
+
export function applyStatus(text, { status, resolved_in }) {
|
|
60
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
61
|
+
if (!fm) return text;
|
|
62
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
63
|
+
let block = fm[1];
|
|
64
|
+
const upsert = (key, value) => {
|
|
65
|
+
if (value == null) return;
|
|
66
|
+
const line = `${key}: ${value}`;
|
|
67
|
+
if (new RegExp(`^${key}:.*$`, 'm').test(block)) block = block.replace(new RegExp(`^${key}:[^\\r\\n]*`, 'm'), line);
|
|
68
|
+
else block = `${block}${eol}${line}`;
|
|
69
|
+
};
|
|
70
|
+
upsert('status', status);
|
|
71
|
+
upsert('resolved-in', resolved_in);
|
|
72
|
+
return text.slice(0, fm.index) + `---${eol}${block}${eol}---` + text.slice(fm.index + fm[0].length);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Append unseen thread updates under `## Updates` (created at EOF if missing).
|
|
76
|
+
// Each entry carries its fu_ marker; `knownIds` is what makes re-runs no-ops.
|
|
77
|
+
//
|
|
78
|
+
// The body is written as a BLOCKQUOTE, not raw markdown. That is a protocol
|
|
79
|
+
// necessity, not styling: this file is re-parsed by the reply/section regexes
|
|
80
|
+
// on every sync, so a message that merely QUOTES a heading like "## Reply
|
|
81
|
+
// (unsent)" must not become one — raw injection would ship the team's own
|
|
82
|
+
// words back as a creator reply (reopening the report it arrived on).
|
|
83
|
+
export function appendUpdates(text, updates, knownIds) {
|
|
84
|
+
const fresh = updates.filter((u) => !knownIds.has(u.id));
|
|
85
|
+
if (fresh.length === 0) return { text, appended: 0 };
|
|
86
|
+
|
|
87
|
+
let entries = '';
|
|
88
|
+
for (const u of fresh) {
|
|
89
|
+
const who = u.author === 'creator' ? 'You' : 'Looop';
|
|
90
|
+
const when = new Date(u.created_at * 1000).toISOString().slice(0, 10);
|
|
91
|
+
const quoted = u.body.split('\n').map((l) => (l.trim() ? `> ${l}` : '>')).join('\n');
|
|
92
|
+
entries += `\n### ${who} — ${when} <!-- ${u.id} -->\n\n${quoted}\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// New entries go at the END OF THE SECTION, not the end of the file: once a
|
|
96
|
+
// reply section exists below `## Updates`, appending at EOF would interleave
|
|
97
|
+
// platform messages under the creator's reply and the thread stops reading
|
|
98
|
+
// in order.
|
|
99
|
+
const lines = text.split('\n');
|
|
100
|
+
const head = lines.findIndex((l) => /^## Updates\s*$/.test(l));
|
|
101
|
+
if (head === -1) {
|
|
102
|
+
return { text: `${text.trimEnd()}\n\n## Updates\n${entries}`, appended: fresh.length };
|
|
103
|
+
}
|
|
104
|
+
let end = lines.length;
|
|
105
|
+
for (let j = head + 1; j < lines.length; j++) {
|
|
106
|
+
if (/^## /.test(lines[j])) { end = j; break; }
|
|
107
|
+
}
|
|
108
|
+
const section = lines.slice(0, end).join('\n').trimEnd();
|
|
109
|
+
const rest = lines.slice(end).join('\n');
|
|
110
|
+
return {
|
|
111
|
+
text: `${section}\n${entries}${rest ? `\n${rest}` : ''}`,
|
|
112
|
+
appended: fresh.length,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Which reports landed in the versions an engine update just crossed:
|
|
117
|
+
// (from, to], or everything at/below `to` when no previous pin exists.
|
|
118
|
+
// `status` must still be done — a report reopened after its fix shipped keeps
|
|
119
|
+
// the resolved_in stamp as history, but announcing it as "landed" would claim
|
|
120
|
+
// a fix the reply just said didn't hold.
|
|
121
|
+
export function landedIn(reports, { from, to }) {
|
|
122
|
+
return reports.filter(
|
|
123
|
+
(r) =>
|
|
124
|
+
r.status === 'done' &&
|
|
125
|
+
r.resolved_in &&
|
|
126
|
+
compareVersions(r.resolved_in, to) <= 0 &&
|
|
127
|
+
(!from || compareVersions(r.resolved_in, from) > 0),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function fetchStatus({ apiBase, fetchImpl = fetch }) {
|
|
132
|
+
const token = getToken();
|
|
133
|
+
if (!token) throw new Error('reading feedback status requires login — run `looop login` first.');
|
|
134
|
+
const res = await fetchImpl(`${apiBase}/api/creator/feedback/status`, {
|
|
135
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok) throw new Error(`feedback status fetch failed (HTTP ${res.status})`);
|
|
138
|
+
const { reports } = await res.json();
|
|
139
|
+
return Array.isArray(reports) ? reports : [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function pushReplies({ path, id, apiBase, fetchImpl, log }) {
|
|
143
|
+
let text = readFileSync(path, 'utf8');
|
|
144
|
+
let sent = 0;
|
|
145
|
+
// One at a time: each stamp rewrites the first unsent heading, so the next
|
|
146
|
+
// loop iteration sees the next reply (if any) as the new first.
|
|
147
|
+
while (UNSENT_REPLY.test(text)) {
|
|
148
|
+
const [reply] = extractUnsentReplies(text);
|
|
149
|
+
if (!reply?.body) {
|
|
150
|
+
log(`⚠️ ${basename(path)} has an empty "## Reply (unsent)" section — write the reply, then rerun.`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const token = getToken();
|
|
154
|
+
const res = await fetchImpl(`${apiBase}/api/creator/feedback/reply`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
157
|
+
body: JSON.stringify({ id, body: reply.body }),
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const detail = await res.text().catch(() => '');
|
|
161
|
+
throw new Error(`reply send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
162
|
+
}
|
|
163
|
+
const { id: updateId } = await res.json();
|
|
164
|
+
text = text.replace(UNSENT_REPLY, `## Reply (sent ${new Date().toISOString()}, ${updateId})`);
|
|
165
|
+
writeFileSync(path, text);
|
|
166
|
+
sent += 1;
|
|
167
|
+
log(`↩️ Sent your reply on ${basename(path)} → ${updateId}`);
|
|
168
|
+
}
|
|
169
|
+
return sent;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The whole sync: replies out, outcomes in. Fails soft on nothing-to-do (no
|
|
173
|
+
// sent reports → silent no-op, no login needed); fails LOUD on a send error,
|
|
174
|
+
// leaving the reply unsent so the next run retries it.
|
|
175
|
+
export async function syncFeedback({
|
|
176
|
+
cwd = process.cwd(),
|
|
177
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
178
|
+
log = console.log,
|
|
179
|
+
fetchImpl = fetch,
|
|
180
|
+
} = {}) {
|
|
181
|
+
const project = findProject(cwd);
|
|
182
|
+
const sentFiles = discoverSent(project.dir);
|
|
183
|
+
if (sentFiles.length === 0) return { replies: 0, updated: [] };
|
|
184
|
+
|
|
185
|
+
if (!getToken()) throw new Error('syncing feedback requires login — run `looop login` first.');
|
|
186
|
+
|
|
187
|
+
// A failing reply must not rob the OTHER reports of their outcomes: push
|
|
188
|
+
// everything, stamp everything, and only THEN re-throw the first failure —
|
|
189
|
+
// loud (so the run is visibly not clean) but after the useful work. The
|
|
190
|
+
// failed reply's heading is still `(unsent)`, so the next run retries it.
|
|
191
|
+
let replies = 0;
|
|
192
|
+
let replyError = null;
|
|
193
|
+
for (const f of sentFiles) {
|
|
194
|
+
try {
|
|
195
|
+
replies += await pushReplies({ ...f, apiBase, fetchImpl, log });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
replyError ??= err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Fetch-and-stamp is best-effort: the send half already succeeded, and a
|
|
202
|
+
// platform without the endpoint (or a transient failure) must not turn a
|
|
203
|
+
// delivered report into an apparent failure. The replies stay loud.
|
|
204
|
+
let reports;
|
|
205
|
+
try {
|
|
206
|
+
reports = await fetchStatus({ apiBase, fetchImpl });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
log(`(could not check your reports' outcomes right now — ${err.message} They'll sync on the next run.)`);
|
|
209
|
+
if (replyError) throw replyError;
|
|
210
|
+
return { replies, updated: [] };
|
|
211
|
+
}
|
|
212
|
+
const byId = new Map(reports.map((r) => [r.id, r]));
|
|
213
|
+
|
|
214
|
+
const updated = [];
|
|
215
|
+
for (const { path, id } of sentFiles) {
|
|
216
|
+
const report = byId.get(id);
|
|
217
|
+
if (!report) continue;
|
|
218
|
+
const before = readFileSync(path, 'utf8');
|
|
219
|
+
let text = applyStatus(before, report);
|
|
220
|
+
const { text: withUpdates, appended } = appendUpdates(text, report.updates ?? [], knownUpdateIds(text));
|
|
221
|
+
text = withUpdates;
|
|
222
|
+
if (text === before) continue;
|
|
223
|
+
writeFileSync(path, text);
|
|
224
|
+
updated.push({ file: path, id, status: report.status, resolved_in: report.resolved_in, newUpdates: appended });
|
|
225
|
+
|
|
226
|
+
const landed = report.resolved_in ? ` — landed in ${report.resolved_in}` : '';
|
|
227
|
+
const news = appended ? ` (+${appended} update${appended === 1 ? '' : 's'})` : '';
|
|
228
|
+
log(`📬 ${basename(path)}: ${report.status}${landed}${news}`);
|
|
229
|
+
}
|
|
230
|
+
if (replyError) throw replyError;
|
|
231
|
+
return { replies, updated };
|
|
232
|
+
}
|
package/lib/feedback.mjs
CHANGED
|
@@ -25,7 +25,10 @@ export const MAX_ATTACHMENTS = 20;
|
|
|
25
25
|
export const MAX_ATTACHMENT_BYTES = 256 * 1024; // per file
|
|
26
26
|
export const MAX_ATTACHMENTS_BYTES = 1024 * 1024; // all of them together
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// CRLF-tolerant: a Windows clone with autocrlf rewrites these files, and a
|
|
29
|
+
// frontmatter that stops matching means a sent report reads as unsent and is
|
|
30
|
+
// re-filed on every run.
|
|
31
|
+
const frontmatter = (text) => /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? '';
|
|
29
32
|
|
|
30
33
|
// A report is "sent" when its frontmatter carries a `sent:` stamp.
|
|
31
34
|
function isSent(text) {
|
|
@@ -128,7 +131,7 @@ function titleOf(text, path) {
|
|
|
128
131
|
|
|
129
132
|
function stamp(text, id) {
|
|
130
133
|
const lines = `sent: ${new Date().toISOString()}\nid: ${id}`;
|
|
131
|
-
if (/^---\n/.test(text)) return text.replace(/^---\n/,
|
|
134
|
+
if (/^---\r?\n/.test(text)) return text.replace(/^---\r?\n/, (m) => `${m}${lines}\n`);
|
|
132
135
|
return `---\n${lines}\n---\n${text}`;
|
|
133
136
|
}
|
|
134
137
|
|
|
@@ -142,6 +145,21 @@ export function discoverUnsent(projectDir) {
|
|
|
142
145
|
.sort();
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
// Delivered reports, with the id the platform stamped into each — what the
|
|
149
|
+
// back-channel (feedback-sync.mjs) replies on and fetches outcomes for.
|
|
150
|
+
export function discoverSent(projectDir) {
|
|
151
|
+
const dir = join(projectDir, 'notes', 'feedback');
|
|
152
|
+
if (!existsSync(dir)) return [];
|
|
153
|
+
return readdirSync(dir)
|
|
154
|
+
.filter((name) => name.endsWith('.md'))
|
|
155
|
+
.map((name) => join(dir, name))
|
|
156
|
+
.sort()
|
|
157
|
+
.flatMap((path) => {
|
|
158
|
+
const id = /^id:\s*(fb_[0-9a-f]{16})\s*$/m.exec(frontmatter(readFileSync(path, 'utf8')))?.[1];
|
|
159
|
+
return id ? [{ path, id }] : [];
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
145
163
|
export async function sendFeedback({
|
|
146
164
|
cwd = process.cwd(),
|
|
147
165
|
apiBase = getApiBase(DEFAULT_API_BASE),
|
package/lib/model-bake.mjs
CHANGED
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
// {
|
|
30
30
|
// "zones": { "head": ["Head", "Neck*"], "arm": ["*_Arm*"] },
|
|
31
31
|
// "clips": ["fly", "idle"], // subset to bake; default = all
|
|
32
|
-
// "generate": "drifter-gen.mjs"
|
|
33
|
-
//
|
|
32
|
+
// "generate": "drifter-gen.mjs", // generator script, relative to the GLB
|
|
33
|
+
// "pivot": "center" // where body.(x,y,z) sits in the model:
|
|
34
|
+
// } // "origin" (default) | "center" | "feet" | [x,y,z]
|
|
34
35
|
// Zones use the dominant-bone rule (a triangle belongs to whichever bone most
|
|
35
36
|
// influences its vertices — the same rule the big engines use to size their
|
|
36
37
|
// bone-attached proxy shapes, pointed at triangles). Patterns match bone
|
|
@@ -64,7 +65,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
64
65
|
// meaning — it participates in the staleness hash, so old artifacts re-bake.
|
|
65
66
|
// v2: the staleness hash gained the generator-script component, and skeletons
|
|
66
67
|
// may carry generated additive clips.
|
|
67
|
-
|
|
68
|
+
// v3: skeletons carry a resolved `pivot`, and the broadphase radius is
|
|
69
|
+
// measured about it instead of the model origin.
|
|
70
|
+
export const BAKE_VERSION = 3;
|
|
68
71
|
|
|
69
72
|
// ── pure helpers (unit-tested without a browser) ─────────────────────────────
|
|
70
73
|
|
|
@@ -106,6 +109,24 @@ export function boneZonesFromConfig(boneNames, zonesConfig) {
|
|
|
106
109
|
return { names, byBone };
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
// Resolve the config's `pivot` — the model-space point that lands ON the
|
|
113
|
+
// entity's body.(x,y,z) — against the bind-pose bounding box. There is no
|
|
114
|
+
// universal right anchor (a flyer wants its centre, a walker its feet, a turret
|
|
115
|
+
// its base), so it's a per-model choice; "origin" reproduces the GLB's own
|
|
116
|
+
// origin and is the default. The resolved vector is written into the skeleton
|
|
117
|
+
// artifact; the runtime composes it into PLACEMENT only, never the inverse
|
|
118
|
+
// bind, so skinning is unaffected.
|
|
119
|
+
export function resolvePivot(pivotCfg, bbox) {
|
|
120
|
+
if (pivotCfg == null || pivotCfg === 'origin') return [0, 0, 0];
|
|
121
|
+
const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
|
|
122
|
+
if (pivotCfg === 'center') return [mid(0), mid(1), mid(2)];
|
|
123
|
+
if (pivotCfg === 'feet') return [mid(0), bbox.min[1], mid(2)];
|
|
124
|
+
if (Array.isArray(pivotCfg) && pivotCfg.length === 3 && pivotCfg.every((n) => Number.isFinite(n))) {
|
|
125
|
+
return [pivotCfg[0], pivotCfg[1], pivotCfg[2]];
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(pivotCfg)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
// The dominant bone of a triangle: the bone with the highest summed skin weight
|
|
110
131
|
// across its three vertices. `acc` is scratch sized to the bone count.
|
|
111
132
|
export function dominantBone(skinIndex, skinWeight, influences, index, tri, acc) {
|
|
@@ -478,18 +499,44 @@ async function bakeInPage(page, cfg) {
|
|
|
478
499
|
clips[anim.name] = { duration: anim.duration, tracks };
|
|
479
500
|
}
|
|
480
501
|
|
|
502
|
+
// ── pivot: where body.(x,y,z) sits in the model ──────────────────────────
|
|
503
|
+
// Resolved against the bind-pose bbox of the extracted positions. Mirrors
|
|
504
|
+
// resolvePivot — this copy runs in-page, because the broadphase radius
|
|
505
|
+
// below must be measured about the pivot: the runtime centres its sphere on
|
|
506
|
+
// body, and body IS the pivot point, so a radius measured about the origin
|
|
507
|
+
// could undersize the sphere and silently reject true edge hits.
|
|
508
|
+
const bbox = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
|
|
509
|
+
for (let i = 0; i < V * 3; i += 3) {
|
|
510
|
+
for (let a = 0; a < 3; a++) {
|
|
511
|
+
const c = position[i + a];
|
|
512
|
+
if (c < bbox.min[a]) bbox.min[a] = c;
|
|
513
|
+
if (c > bbox.max[a]) bbox.max[a] = c;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
let pivot;
|
|
517
|
+
{
|
|
518
|
+
const p = config.pivot;
|
|
519
|
+
const mid = (a) => (bbox.min[a] + bbox.max[a]) / 2;
|
|
520
|
+
if (p == null || p === 'origin') pivot = [0, 0, 0];
|
|
521
|
+
else if (p === 'center') pivot = [mid(0), mid(1), mid(2)];
|
|
522
|
+
else if (p === 'feet') pivot = [mid(0), bbox.min[1], mid(2)];
|
|
523
|
+
else if (Array.isArray(p) && p.length === 3 && p.every((n) => Number.isFinite(n))) pivot = [p[0], p[1], p[2]];
|
|
524
|
+
else return { error: `"pivot" must be "origin", "center", "feet", or [x, y, z] — got ${JSON.stringify(p)}` };
|
|
525
|
+
}
|
|
526
|
+
|
|
481
527
|
// ── broadphase radius: worst case across rest + every baked clip ─────────
|
|
482
528
|
// The sphere exists to REJECT cheaply, so it must never reject a true hit:
|
|
483
529
|
// measure every pose the clips can produce, then carry 3% slack (a ray
|
|
484
530
|
// grazing an extremity is near-tangent, and float error then discards a hit
|
|
485
|
-
// that visibly connects).
|
|
531
|
+
// that visibly connects). Distances are about the pivot — the sphere's
|
|
532
|
+
// runtime centre.
|
|
486
533
|
let radius = 0;
|
|
487
534
|
const measure = () => {
|
|
488
535
|
scene.updateMatrixWorld(true);
|
|
489
536
|
sk.skeleton.update?.();
|
|
490
537
|
for (let i = 0; i < V; i++) {
|
|
491
538
|
sk.getVertexPosition(i, v3);
|
|
492
|
-
const d = Math.hypot(v3.x, v3.y, v3.z);
|
|
539
|
+
const d = Math.hypot(v3.x - pivot[0], v3.y - pivot[1], v3.z - pivot[2]);
|
|
493
540
|
if (d > radius) radius = d;
|
|
494
541
|
}
|
|
495
542
|
};
|
|
@@ -534,6 +581,7 @@ async function bakeInPage(page, cfg) {
|
|
|
534
581
|
return {
|
|
535
582
|
notes,
|
|
536
583
|
root: { t: [rp.x, rp.y, rp.z], r: [rq.x, rq.y, rq.z, rq.w], s: [rs.x, rs.y, rs.z] },
|
|
584
|
+
pivot,
|
|
537
585
|
bones: outBones,
|
|
538
586
|
clips,
|
|
539
587
|
mesh: {
|
|
@@ -638,6 +686,7 @@ export async function bakeModel(glbPath, { dir = process.cwd(), log = console.lo
|
|
|
638
686
|
|
|
639
687
|
const skeletonJson = {
|
|
640
688
|
root: { t: baked.root.t, r: baked.root.r, s: rootS },
|
|
689
|
+
pivot: baked.pivot,
|
|
641
690
|
bones: baked.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, r: b.r, s: b.sAvg })),
|
|
642
691
|
clips: baked.clips,
|
|
643
692
|
};
|
package/lib/update.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { login } from './login.mjs';
|
|
|
17
17
|
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
18
18
|
import { compareVersions, renderReleases } from './changelog.mjs';
|
|
19
19
|
import { syncCli, reportCli } from './self-update.mjs';
|
|
20
|
+
import { fetchStatus, landedIn } from './feedback-sync.mjs';
|
|
20
21
|
|
|
21
22
|
// The report is the point. A creator reading this must be able to answer, with
|
|
22
23
|
// no further digging: what changed, was any of it mine, and what do I do now.
|
|
@@ -78,7 +79,7 @@ export async function update({
|
|
|
78
79
|
const { latest, releases } = await res.json();
|
|
79
80
|
if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
|
|
80
81
|
|
|
81
|
-
// What the update is about to change UNDER the game
|
|
82
|
+
// What the update is about to change UNDER the game.
|
|
82
83
|
// Reporting the skill files we rewrote and nothing about the engine was the
|
|
83
84
|
// whole gap: an agent crossed two versions of the shared library blind.
|
|
84
85
|
//
|
|
@@ -172,6 +173,19 @@ export async function update({
|
|
|
172
173
|
reportCli(log, cli);
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// Feedback this repo filed that a crossed release resolved. Best-effort on
|
|
177
|
+
// purpose: the back-channel must never block or fail an engine update, so
|
|
178
|
+
// any error here (an older platform without the endpoint, offline after the
|
|
179
|
+
// download, a bad response) reduces to "no landings to report".
|
|
180
|
+
let landed = [];
|
|
181
|
+
if (updated) {
|
|
182
|
+
try {
|
|
183
|
+
landed = landedIn(await fetchStatus({ apiBase, fetchImpl }), { from, to: latest });
|
|
184
|
+
} catch {
|
|
185
|
+
landed = [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
175
189
|
if (updated) {
|
|
176
190
|
log('');
|
|
177
191
|
if (crossed === null) {
|
|
@@ -180,8 +194,14 @@ export async function update({
|
|
|
180
194
|
} else if (crossed.length) {
|
|
181
195
|
log(renderReleases(crossed, { pinned: from, latest }));
|
|
182
196
|
}
|
|
197
|
+
if (landed.length) {
|
|
198
|
+
log('');
|
|
199
|
+
log(' Feedback you filed landed in this update:');
|
|
200
|
+
for (const r of landed) log(` • "${r.title}" — resolved in ${r.resolved_in}`);
|
|
201
|
+
log(' Run `npx looop feedback` to pull the details into notes/feedback/.');
|
|
202
|
+
}
|
|
183
203
|
log('');
|
|
184
204
|
log(' Republish (`npx looop publish`) when you want the live game on it.');
|
|
185
205
|
}
|
|
186
|
-
return { from, to: latest, updated, surface, crossed, cli };
|
|
206
|
+
return { from, to: latest, updated, surface, crossed, cli, landed };
|
|
187
207
|
}
|