@homie-rocks/studio 0.30.2 → 0.31.1
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 +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
|
@@ -49,6 +49,13 @@
|
|
|
49
49
|
* byte). A model that is refused or not there is drawn as a stand-in in the same colours: a round never waits on, or
|
|
50
50
|
* breaks for, a model.
|
|
51
51
|
*
|
|
52
|
+
* HEADINGS, AND THERE ARE TWO: a body's facing (`fa`, drawn as `yaw`) is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
|
|
53
|
+
* (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
|
|
54
|
+
* `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
|
|
55
|
+
* package takes a body's facing as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
|
|
56
|
+
* `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
|
|
57
|
+
* `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
|
|
58
|
+
*
|
|
52
59
|
* PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
|
|
53
60
|
* renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
|
|
54
61
|
* disc under each body, coin, tree and rock (a computer draws the sun's real shadows). Repeated things are one
|
|
@@ -70,7 +77,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
|
|
70
77
|
import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
|
|
71
78
|
// Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
|
|
72
79
|
import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
|
|
73
|
-
import { createNetplay, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
80
|
+
import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
74
81
|
// The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
|
|
75
82
|
// labels that never pile up (port/view.ts).
|
|
76
83
|
import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
@@ -153,7 +160,7 @@ interface Gem { id: number; x: number; y: number }
|
|
|
153
160
|
/** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
|
|
154
161
|
interface Zone { n: number; x: number; y: number; r: number; until: number }
|
|
155
162
|
/** `firstRound`: each person's first whole round here (seat, round), so a promoted host eases the same round. */
|
|
156
|
-
interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; firstRound?: [number, number][] }
|
|
163
|
+
interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; occ?: [number, number][]; firstRound?: [number, number][] }
|
|
157
164
|
|
|
158
165
|
/* --------------------------------------------------------------- the net */
|
|
159
166
|
/**
|
|
@@ -165,6 +172,9 @@ const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(loc
|
|
|
165
172
|
// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
|
|
166
173
|
// arrival 'game': the play page's arrival card stays until this game says it is playable (THE ARRIVAL, by the HUD).
|
|
167
174
|
const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'hero-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], arrival: 'game', checkpoint: () => checkpoint() });
|
|
175
|
+
// A touch game guards its own page (NETPLAY.md section 24): a long press on the canvas never selects text or raises
|
|
176
|
+
// the copy/paste callout on a phone, and a touch on it never pans or zooms the page.
|
|
177
|
+
guardGestures({ touch: 'canvas' });
|
|
168
178
|
/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
|
|
169
179
|
const policy = () => net.policy;
|
|
170
180
|
|
|
@@ -243,9 +253,12 @@ function newZone(n: number): Zone {
|
|
|
243
253
|
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
244
254
|
|
|
245
255
|
/** A round starts with every hero on its spawn ring facing the camera (fa 0): a line-up the hero intro opens on. */
|
|
246
|
-
function startRound(n: number): void {
|
|
256
|
+
function startRound(n: number, rollover = false): void {
|
|
247
257
|
const now = net.now();
|
|
248
258
|
roster.trim();
|
|
259
|
+
// The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
|
|
260
|
+
// here), so whoever was seated with no body is given one now, before the bodies are placed.
|
|
261
|
+
if (rollover && !net.offline) seatWaiting();
|
|
249
262
|
syncBodiesFromRoster();
|
|
250
263
|
let i = 0;
|
|
251
264
|
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
@@ -290,7 +303,9 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
290
303
|
welcome(seat);
|
|
291
304
|
}
|
|
292
305
|
// Whoever is connected now is who plays: seats that left during the gap become bots.
|
|
293
|
-
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));
|
|
306
|
+
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null, occ: p.occ ?? null }));
|
|
307
|
+
// `occ` (NETPLAY.md section 25): which stay in its seat each peer's is, so a seat number that changed hands while
|
|
308
|
+
// nobody was hosting is claimed afresh instead of inheriting the last player's body in silence.
|
|
294
309
|
const { claimed } = roster.reconcile(peers);
|
|
295
310
|
syncBodiesFromRoster();
|
|
296
311
|
for (const s of claimed) welcome(s.seat);
|
|
@@ -308,7 +323,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
308
323
|
function restore(e: RoleChange<Snap, Ckpt>): void {
|
|
309
324
|
const ck = e.ckpt?.d ?? null;
|
|
310
325
|
if (ck) {
|
|
311
|
-
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
326
|
+
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy }, ck.occ ?? null);
|
|
312
327
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
313
328
|
gems = ck.gems.map((g) => ({ ...g }));
|
|
314
329
|
gemSeq = ck.gemSeq;
|
|
@@ -352,6 +367,8 @@ function checkpoint(): Ckpt {
|
|
|
352
367
|
bodies: [...bodies.values()].map((b) => ({ ...b })),
|
|
353
368
|
gems: gems.map((g) => ({ ...g })),
|
|
354
369
|
roster: roster.toJSON(),
|
|
370
|
+
// Whose stay each body is, beside the roster: how the next host tells a player who came back from a new one.
|
|
371
|
+
occ: roster.occupants(),
|
|
355
372
|
tick,
|
|
356
373
|
gemSeq,
|
|
357
374
|
firstRound: [...firstRound],
|
|
@@ -362,16 +379,31 @@ net.on('role', (e) => {
|
|
|
362
379
|
if (e.role === 'host') becomeHost(e);
|
|
363
380
|
else { hosting = false; }
|
|
364
381
|
});
|
|
365
|
-
|
|
366
|
-
|
|
382
|
+
/** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
|
|
383
|
+
function giveBody(p: Peer): boolean {
|
|
384
|
+
if (p.seat === null) return false;
|
|
367
385
|
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
368
|
-
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
|
|
369
|
-
if (!c) return;
|
|
386
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
387
|
+
if (!c) return false;
|
|
370
388
|
welcome(p.seat);
|
|
371
389
|
syncBodiesFromRoster();
|
|
372
390
|
const b = bodies.get(c.slot.slot);
|
|
373
391
|
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
374
392
|
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
|
|
397
|
+
* found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
|
|
398
|
+
* rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
|
|
399
|
+
*/
|
|
400
|
+
function seatWaiting(): boolean {
|
|
401
|
+
let any = false;
|
|
402
|
+
for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
|
|
403
|
+
return any;
|
|
404
|
+
}
|
|
405
|
+
net.on('join', (p) => {
|
|
406
|
+
if (!hosting || !giveBody(p)) return;
|
|
375
407
|
publishRoster();
|
|
376
408
|
net.snapshot(buildSnap(), tick, true);
|
|
377
409
|
});
|
|
@@ -380,7 +412,10 @@ net.on('leave', (p) => {
|
|
|
380
412
|
roster.release(p.seat); // their body stays, driven by a bot
|
|
381
413
|
firstRound.delete(p.seat); // whoever sits in this seat next is new here
|
|
382
414
|
syncBodiesFromRoster();
|
|
415
|
+
// A body just went to a bot: somebody seated with none takes it now.
|
|
416
|
+
const seated = seatWaiting();
|
|
383
417
|
publishRoster();
|
|
418
|
+
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
384
419
|
});
|
|
385
420
|
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
386
421
|
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
@@ -882,7 +917,7 @@ function stepHost(dt: number): void {
|
|
|
882
917
|
}
|
|
883
918
|
}
|
|
884
919
|
if (now >= round.endsAt) endRound();
|
|
885
|
-
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
920
|
+
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
886
921
|
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
887
922
|
}
|
|
888
923
|
|
|
@@ -2475,7 +2510,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
|
|
|
2475
2510
|
ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
|
|
2476
2511
|
});
|
|
2477
2512
|
}
|
|
2478
|
-
|
|
2513
|
+
// Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
|
|
2514
|
+
if (net.link === 'reconnecting' && net.role !== 'host') {
|
|
2479
2515
|
ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
|
|
2480
2516
|
const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
|
|
2481
2517
|
chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
|
|
@@ -2576,6 +2612,9 @@ exposePort(net, {
|
|
|
2576
2612
|
self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
|
|
2577
2613
|
size: R_AV,
|
|
2578
2614
|
score: () => { const seat = mySeat(); const b = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
|
|
2615
|
+
// The renderer's own counters, by the names `homie-studio perf` and the playtest read on the port probe (measured
|
|
2616
|
+
// scene cost while playing; port/probe.ts PortExtra). Nobody dies in this game, so there is no `alive` to say.
|
|
2617
|
+
extra: { drawCalls: () => renderer.info.render.calls, triangles: () => renderer.info.render.triangles },
|
|
2579
2618
|
});
|
|
2580
2619
|
|
|
2581
2620
|
void net.ready.then(() => {
|
package/worker/arrival.mjs
CHANGED
|
@@ -136,7 +136,10 @@ export const ARRIVAL_JS = String.raw`(function () {
|
|
|
136
136
|
var stepEl = el.querySelector('[data-arrive-step]');
|
|
137
137
|
var roomEl = el.querySelector('[data-arrive-room]');
|
|
138
138
|
var bar = el.querySelector('[data-arrive-bar]');
|
|
139
|
-
|
|
139
|
+
// mode: who was to say when the game is playable ('auto': the helper, 'game': the game, 'seat': an older helper);
|
|
140
|
+
// explicitMs: when the game itself said so (the page's clock; null: it never did); lateMs: how long after an
|
|
141
|
+
// automatic arrival had already lifted the card that was (null: it was not late). The shell's script fills both.
|
|
142
|
+
var a = { phase: 'room', step: stepEl.textContent, p: 0.06, lookMs: null, liftedMs: null, by: null, mode: null, explicitMs: null, lateMs: null };
|
|
140
143
|
api.state = a;
|
|
141
144
|
// The card's first frame on the screen: what perf reads as the time to the first meaningful frame.
|
|
142
145
|
frame(function () { frame(function () { a.lookMs = Math.round(now()); mark('homie:look'); }); });
|
package/worker/discover.mjs
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
* /sitemap.xml every public page (Home, Games and each landing, Music, Videos, Rooms, Posts, the studio's own
|
|
9
9
|
* pages), with lastmod only where a real date says when it changed
|
|
10
10
|
* /llms.txt the llms.txt convention (an H1, a > summary, then ## sections of links): what the studio is, each
|
|
11
|
-
* public game with its pitch, players, Play, Watch, the big screen
|
|
12
|
-
*
|
|
11
|
+
* public game with its pitch, players, Play, Watch, the big screen, the licence it names and the
|
|
12
|
+
* game it was based on (when it has either), songs,
|
|
13
13
|
* videos, posts with their feeds, the studio's own pages, and a link to homie.rocks's llms.txt
|
|
14
14
|
* /llms-full.txt the same, with each game's whole description, how to play, controls and credits, and every
|
|
15
15
|
* post's text
|
|
16
16
|
*
|
|
17
|
-
* Only public games are ever named (a private or invite-only game is in none of them), and
|
|
18
|
-
*
|
|
17
|
+
* Only public games are ever named (a private or invite-only game is in none of them), and no game is offered to
|
|
18
|
+
* be taken whole (the pieces a studio shares are its parts, at /parts/, worker/parts.mjs). A file of the
|
|
19
19
|
* studio's own in site/public (robots.txt, sitemap.xml, llms.txt, llms-full.txt) wins over the made one.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import { basedOnRow, licenseLabel } from './license.mjs';
|
|
22
22
|
|
|
23
23
|
export const DISCOVERY_FILES = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'];
|
|
24
24
|
|
|
@@ -111,15 +111,6 @@ export function sitemapXml(cat, origin, opts = {}) {
|
|
|
111
111
|
|
|
112
112
|
/* ------------------------------------------------------------------ llms.txt */
|
|
113
113
|
|
|
114
|
-
const HOMIE_INSTALL = 'in Claude Code, `/plugin marketplace add homie-rocks/homie` and then `/plugin install homie@homie` (Codex takes the same marketplace)';
|
|
115
|
-
|
|
116
|
-
function licenceWords(g) {
|
|
117
|
-
const l = licenseOf(g.license);
|
|
118
|
-
const name = `${LICENSE_KINDS[l.kind]}${l.spdx ? `, ${l.spdx}` : ''}`;
|
|
119
|
-
if (l.kind === 'remix-freely') return `${name}: a credit is welcome, not asked for`;
|
|
120
|
-
return `${name}: a remix says "Remix of ${one(g.name, 80)} by ${one(g.studio, 80)}" with a link back`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
114
|
function summaryOf(cat, origin) {
|
|
124
115
|
const name = studioName(cat);
|
|
125
116
|
const tag = one(cat.studio?.tagline, 140);
|
|
@@ -137,8 +128,8 @@ function summaryOf(cat, origin) {
|
|
|
137
128
|
];
|
|
138
129
|
}
|
|
139
130
|
|
|
140
|
-
/** /llms.txt
|
|
141
|
-
export function llmsTxt(cat, origin, {
|
|
131
|
+
/** /llms.txt (and, with `full`, /llms-full.txt). */
|
|
132
|
+
export function llmsTxt(cat, origin, { directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
|
|
142
133
|
const name = studioName(cat);
|
|
143
134
|
const games = (cat.games ?? []).map((g) => ({ ...g, studio: name }));
|
|
144
135
|
const songs = cat.songs ?? [];
|
|
@@ -154,9 +145,10 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
|
|
|
154
145
|
const L = g.landing ?? {};
|
|
155
146
|
const doors = [`Play: ${origin}/${g.id}/play`, ...(watchable(g) ? [`Watch a live room: ${origin}/${g.id}/watch`] : []), ...(tv(g) ? [`On a TV: ${origin}/${g.id}/tv`] : [])];
|
|
156
147
|
const facts = [players(g), rounds(g.roundSeconds), ...(Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : [])].filter(Boolean).join(', ');
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
148
|
+
// The credit a game owes the one it was made from, and the licence it names for itself: facts, when it has them.
|
|
149
|
+
const lineage = basedOnRow(g.basedOn ?? g.remixOf);
|
|
150
|
+
const licence = licenseLabel(g.license);
|
|
151
|
+
out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` Based on ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}.${licence ? ` Licence: ${licence}.` : ''}`);
|
|
160
152
|
if (full) {
|
|
161
153
|
const C = L.credits ?? {};
|
|
162
154
|
const lines = [];
|
|
@@ -173,12 +165,6 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
|
|
|
173
165
|
for (const l of lines) out.push(` ${l}`);
|
|
174
166
|
}
|
|
175
167
|
}
|
|
176
|
-
const open = games.filter((g) => remixable(g));
|
|
177
|
-
if (open.length) {
|
|
178
|
-
out.push('', '## Remix', '');
|
|
179
|
-
out.push(`Homie is a plugin for Claude Code and Codex that makes multiplayer games in a studio of your own, on your own free Cloudflare account. To remix one of these games, ${HOMIE_INSTALL}, then say "Remix <game> from <its source address> into a game of my own in my Homie studio". The plugin's \`game_remix\` tool (or \`npx --no-install homie-studio game remix <source address> --id <new-id>\` in a studio's folder) copies it in and writes the credit its licence asks for.`, '');
|
|
180
|
-
for (const g of open) out.push(`- [${label(g.name)} source](${origin}/games/${g.id}/source.json): ${licenceWords(g)}. Say: "Remix ${one(g.name, 80)} from ${origin}/games/${g.id}/source.json into a game of my own in my Homie studio".`);
|
|
181
|
-
}
|
|
182
168
|
}
|
|
183
169
|
if (songs.length) {
|
|
184
170
|
out.push('', '## Music', '');
|