@homie-rocks/studio 0.30.1 → 0.31.0
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 +111 -1
- package/README.md +361 -22
- 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 +142 -123
- 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 +127 -8
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/demo.mjs +16 -4
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +116 -5
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +36 -35
- 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 +693 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +132 -0
- package/lib/scaffold.mjs +88 -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 +424 -16
- package/netplay/netplay.ts +756 -40
- package/package.json +10 -1
- package/parts/PARTS.md +163 -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 +103 -19
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +3 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +13 -6
- 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 +15 -5
- 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 +15 -5
- 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 +141 -23
- package/worker/parts.mjs +234 -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
package/worker/license.mjs
CHANGED
|
@@ -1,46 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A GAME'S
|
|
3
|
-
*
|
|
2
|
+
* A GAME'S OWN LICENCE AND THE CREDIT IT OWES, shared by the site Worker (worker/site.mjs, worker/schema.mjs,
|
|
3
|
+
* worker/discover.mjs) and the CLI (lib/build.mjs).
|
|
4
4
|
*
|
|
5
|
-
* The licence is the owner
|
|
6
|
-
* (
|
|
5
|
+
* The licence is a statement of rights the owner may make about the game: game.json `"license"` names an SPDX
|
|
6
|
+
* identifier ("MIT", "CC-BY-4.0"), as the word or as { "spdx": "<id>" }. The game's page, its structured data and
|
|
7
|
+
* /llms.txt say it. A game that names none says nothing: no licence is granted, and no page pretends otherwise.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* REMIX WAS RETIRED. A game used to be handed over whole (its source at /games/<id>/source.json, brought in with
|
|
10
|
+
* `game remix`), and `"license"` also took three words about that: "remix-with-credit", "remix-freely" and
|
|
11
|
+
* "no-remix". Games now build on each other through parts, pieces a studio chooses to share (parts/PARTS.md), each
|
|
12
|
+
* under its own SPDX licence. Nothing here grants or refuses a remix any more:
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* and
|
|
14
|
+
* - the three words, and `"kind"` in the object form, are read as no licence named (an SPDX id beside them stays);
|
|
15
|
+
* - game.json `"remix"`, `"share"` and `landing.make` are ignored; retiredKeys() names the ones a game still
|
|
16
|
+
* carries, so the build can say so once;
|
|
17
|
+
* - `"remixOf"`, which `game remix` wrote into a game it made, is still read: it is a credit that game owes its
|
|
18
|
+
* original, and its landing and credits keep showing it ("Based on <game> by <studio>", basedOnRow()).
|
|
17
19
|
*/
|
|
18
|
-
export const LICENSE_KINDS = Object.freeze({
|
|
19
|
-
'remix-with-credit': 'Remix with credit',
|
|
20
|
-
'remix-freely': 'Remix freely',
|
|
21
|
-
'no-remix': 'Not for remixing',
|
|
22
|
-
});
|
|
23
|
-
export const DEFAULT_LICENSE = 'remix-with-credit';
|
|
24
20
|
const SPDX = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/;
|
|
21
|
+
/** The three words `"license"` took while a game could be handed over whole: none of them is a licence. */
|
|
22
|
+
const RETIRED_KINDS = new Set(['remix-with-credit', 'remix-freely', 'no-remix']);
|
|
25
23
|
|
|
26
|
-
/** {
|
|
24
|
+
/** { spdx } from what game.json (or a built catalogue row) says, or null when it names no licence. */
|
|
27
25
|
export function licenseOf(value) {
|
|
28
|
-
const v = typeof value === 'string'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const kind = Object.hasOwn(LICENSE_KINDS, v.kind) ? v.kind : DEFAULT_LICENSE;
|
|
32
|
-
const spdx = typeof v.spdx === 'string' && SPDX.test(v.spdx.trim()) ? v.spdx.trim() : null;
|
|
33
|
-
return { kind, spdx };
|
|
26
|
+
const v = typeof value === 'string' ? value : value && typeof value === 'object' ? value.spdx : null;
|
|
27
|
+
const spdx = typeof v === 'string' && SPDX.test(v.trim()) && !RETIRED_KINDS.has(v.trim()) ? v.trim() : null;
|
|
28
|
+
return spdx ? { spdx } : null;
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
/** "
|
|
37
|
-
export
|
|
38
|
-
const l = licenseOf(value);
|
|
39
|
-
return `${LICENSE_KINDS[l.kind]}${l.spdx ? ` (${l.spdx})` : ''}`;
|
|
40
|
-
}
|
|
31
|
+
/** "MIT", or null for a game that names no licence. */
|
|
32
|
+
export const licenseLabel = (value) => licenseOf(value)?.spdx ?? null;
|
|
41
33
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The retired settings a game.json still carries, as the words to print: `"remix"`, `"share"`, `landing.make`, and
|
|
36
|
+
* a `"license"` that is one of the three old words (or has a `"kind"`). `"remixOf"` is not one: it is still read.
|
|
37
|
+
*/
|
|
38
|
+
export function retiredKeys(g) {
|
|
39
|
+
const out = [];
|
|
40
|
+
if (g?.remix !== undefined) out.push('"remix"');
|
|
41
|
+
if (g?.share !== undefined) out.push('"share"');
|
|
42
|
+
const lic = g?.license;
|
|
43
|
+
if ((typeof lic === 'string' && RETIRED_KINDS.has(lic.trim())) || (lic && typeof lic === 'object' && lic.kind !== undefined)) out.push(`"license": ${JSON.stringify(typeof lic === 'string' ? lic.trim() : String(lic.kind)).slice(0, 40)}`);
|
|
44
|
+
if (g?.landing && typeof g.landing === 'object' && g.landing.make !== undefined) out.push('landing.make');
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
44
47
|
|
|
45
48
|
const line = (v, max) => (typeof v === 'string' && v.trim() ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, max) : null);
|
|
46
49
|
/** An https address, else null. */
|
|
@@ -48,8 +51,8 @@ export function httpsPage(v) {
|
|
|
48
51
|
try { const u = new URL(String(v ?? '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
/** A game's page from its source
|
|
52
|
-
|
|
54
|
+
/** A game's page from the address its source once had: https://<site>/games/<id>/source.json is https://<site>/<id>/. */
|
|
55
|
+
function pageOfSource(source) {
|
|
53
56
|
try {
|
|
54
57
|
const u = new URL(String(source ?? ''));
|
|
55
58
|
const m = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(u.pathname);
|
|
@@ -58,17 +61,18 @@ export function pageOfSource(source) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
64
|
+
* The credit a game owes the game it was made from, as it may be shown: the original's name, studio and page (an
|
|
65
|
+
* https link), plain text only. Read from game.json `"remixOf"` (what `game remix` wrote; one made before 0.14.4
|
|
66
|
+
* has only the original's source address, which names its page too) or from a built catalogue row's `basedOn`.
|
|
63
67
|
*/
|
|
64
|
-
export function
|
|
68
|
+
export function basedOnRow(r) {
|
|
65
69
|
if (!r || typeof r !== 'object') return null;
|
|
66
70
|
const name = line(r.name, 80);
|
|
67
71
|
return name ? { name, studio: line(r.studio, 80), page: httpsPage(r.page) ?? pageOfSource(r.source) } : null;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
/** "
|
|
71
|
-
export function
|
|
72
|
-
const row =
|
|
73
|
-
return row ? `
|
|
74
|
+
/** "Based on Gem Rush by Night Owl Games" (the studio left out when the record named none). */
|
|
75
|
+
export function basedOnCredit(r) {
|
|
76
|
+
const row = basedOnRow(r);
|
|
77
|
+
return row ? `Based on ${row.name}${row.studio ? ` by ${row.studio}` : ''}` : null;
|
|
74
78
|
}
|
package/worker/office-page.mjs
CHANGED
|
@@ -226,16 +226,12 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
226
226
|
b.setAttribute('data-v', p[0]); b.setAttribute('aria-pressed', g.launch === p[0] ? 'true' : 'false');
|
|
227
227
|
st.appendChild(b);
|
|
228
228
|
});
|
|
229
|
-
var remix = el('input', 'switch'); remix.type = 'checkbox'; remix.checked = g.remix; remix.disabled = !g.remixBuilt;
|
|
230
|
-
remix.setAttribute('aria-label', 'Remixable');
|
|
231
|
-
remix.onchange = function () { act('/_studio/api/game', { game: g.id, remix: remix.checked }, remix.checked ? g.name + '\'s source is open for remixing.' : g.name + '\'s source is withdrawn.'); };
|
|
232
229
|
var max = el('input'); max.type = 'number'; max.min = '1'; max.max = String(g.seats); max.value = String(g.maxPlayers); max.setAttribute('aria-label', 'Players per room');
|
|
233
230
|
var setMax = btn('Set', 'ghost small', function () {
|
|
234
231
|
var n = Math.floor(Number(max.value));
|
|
235
232
|
act('/_studio/api/game', { game: g.id, maxPlayers: n >= g.seats ? null : n }, 'Rooms of ' + g.name + ' now hold ' + Math.min(n, g.seats) + '.');
|
|
236
233
|
});
|
|
237
234
|
add(box, st,
|
|
238
|
-
add(el('label'), remix, el('span', '', !g.remixBuilt ? 'Remixable (not in the build)' : g.license === 'no-remix' ? 'Source shared (its licence: not for remixing)' : 'Remixable')),
|
|
239
235
|
add(el('label'), el('span', 'dim', 'Players per room'), max, el('span', 'faint', 'of ' + g.seats), setMax),
|
|
240
236
|
add(el('span', 'faint'), document.createTextNode(g.launchFrom === 'game.json' ? 'state from game.json' : '')));
|
|
241
237
|
return box;
|
package/worker/office-schema.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** The back office's D1 tables (worker/office.mjs), as the template's migration 0003 and `homie-studio upgrade` write them. */
|
|
2
2
|
export const OFFICE_MIGRATION_FILE = '0005_studio_office.sql';
|
|
3
|
+
/*
|
|
4
|
+
* This text is a migration that has run in studios in the field: it is never edited, not even its first comment.
|
|
5
|
+
* `office_games.remix` (the owner's switch for handing a game over whole) is still made here and still in every
|
|
6
|
+
* studio's table, though remix was retired: worker/office.mjs neither reads nor writes it, and no later migration
|
|
7
|
+
* drops it.
|
|
8
|
+
*/
|
|
3
9
|
export const OFFICE_MIGRATION = `-- The studio's back office (@homie-rocks/studio 0.13.0): each game's launch state, remix switch and room size,
|
|
4
10
|
-- its invites and the passes they gave, and the controls the owner's AI asked the owner to confirm.
|
|
5
11
|
CREATE TABLE IF NOT EXISTS office_games (
|
package/worker/office.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* /_studio/office the owner's live view: every live room of every game (players, bots, round,
|
|
6
6
|
* uptime) and who is in it, refreshing by itself; kick, mute, announce, close a room;
|
|
7
|
-
* each game's launch state,
|
|
7
|
+
* each game's launch state, room size and invites
|
|
8
8
|
* /_studio/confirm/<ask> a control the owner's AI asked for, waiting for the owner's one tap
|
|
9
9
|
* /_studio/api/... the same, as JSON, for the page, the in-game owner overlay, the CLI and the MCP
|
|
10
10
|
* /<game>/invite an invite code, spent for this browser's pass to an invite-only game
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* office key (`homie-studio office key`, also minted with that login; D1 keeps only its SHA-256). A player can
|
|
15
15
|
* never be the owner: the session is an HttpOnly cookie no page or game can read, and the games run in a sandboxed,
|
|
16
16
|
* opaque-origin frame that can send no request as this site. With an office key, the AI can look, announce and
|
|
17
|
-
* make invites; a control that takes something away (kick, mute, closing a room, a game's launch state
|
|
18
|
-
*
|
|
17
|
+
* make invites; a control that takes something away (kick, mute, closing a room, a game's launch state
|
|
18
|
+
* or room size) only becomes an ASK, which the owner confirms with one tap in their signed-in browser.
|
|
19
19
|
* No key can confirm an ask.
|
|
20
20
|
*
|
|
21
21
|
* CONTROLS ARE SIGNED. The Worker hands a room a control as a `ctl` message signed with the studio's own office
|
|
@@ -28,8 +28,12 @@
|
|
|
28
28
|
* is left out of the studio's pages, /api/games, /api/rooms and the directory manifest, so the directory drops it
|
|
29
29
|
* the next time it reads the studio; its own pages, rooms and play frame refuse anyone without access (the play
|
|
30
30
|
* frame and its sockets carry a signed ticket the play page mints). A Preview (no D1, an unlisted address of a
|
|
31
|
-
* branch under review) enforces no launch state.
|
|
32
|
-
*
|
|
31
|
+
* branch under review) enforces no launch state.
|
|
32
|
+
*
|
|
33
|
+
* `office_games` also has a `remix` column: the owner's switch for handing a game over whole, from when that existed
|
|
34
|
+
* (remix was retired). A migration that ran in the field is never edited and the column is never dropped, so it
|
|
35
|
+
* stays in the table with whatever it held; nothing here reads it and nothing writes it (settingsOf, the `game`
|
|
36
|
+
* action below).
|
|
33
37
|
*
|
|
34
38
|
* PLAYER ACCOUNTS (worker/players.mjs, handed over in worker/index.mjs with `usePlayers()`): `of(request, env)` names
|
|
35
39
|
* the signed-in player in their play page's ticket, so their seat carries their account and a kick holds it on every
|
|
@@ -61,7 +65,6 @@ import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain
|
|
|
61
65
|
import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
62
66
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
63
67
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
64
|
-
import { licenseOf } from './license.mjs';
|
|
65
68
|
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
66
69
|
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
67
70
|
import { checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
|
|
@@ -178,7 +181,7 @@ export async function settingsOf(env, { fresh = false } = {}) {
|
|
|
178
181
|
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
179
182
|
let map = new Map();
|
|
180
183
|
try {
|
|
181
|
-
const { results } = await env.DB.prepare('SELECT game, launch,
|
|
184
|
+
const { results } = await env.DB.prepare('SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500').all();
|
|
182
185
|
map = new Map((results ?? []).map((r) => [r.game, r]));
|
|
183
186
|
} catch { map = new Map(); }
|
|
184
187
|
settingsCache.set(env.DB, { at: Date.now(), map });
|
|
@@ -192,11 +195,6 @@ export function launchOf(meta, settings, env) {
|
|
|
192
195
|
const v = settings?.get(meta?.id)?.launch ?? meta?.launch ?? 'public';
|
|
193
196
|
return LAUNCH_STATES.includes(v) ? v : 'public';
|
|
194
197
|
}
|
|
195
|
-
/** Whether the game's source is offered for remix: built (game.json share.source) and not withdrawn by the owner. */
|
|
196
|
-
export function remixOf(meta, settings) {
|
|
197
|
-
if (meta?.landing?.source === false) return false;
|
|
198
|
-
return settings?.get(meta?.id)?.remix !== 0;
|
|
199
|
-
}
|
|
200
198
|
/** A room's seats: the game's own (its netplay manifest), lowered by the owner's room size. */
|
|
201
199
|
export function seatsFor(meta, settings) {
|
|
202
200
|
const own = seatsOf(meta);
|
|
@@ -616,7 +614,6 @@ export async function officeView(env, cat, origin) {
|
|
|
616
614
|
// 0.17.0: the game's AI guides have words of their own (agents.json): without it they play but never talk.
|
|
617
615
|
vocab: g.vocab === true,
|
|
618
616
|
passes: (await passList(env, { game: g.id })).filter((x) => x.live || Date.now() - x.createdAt < 7 * 86_400_000),
|
|
619
|
-
remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false, license: licenseOf(g.license).kind,
|
|
620
617
|
seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
|
|
621
618
|
play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
|
|
622
619
|
invites: launch === 'invite' || (await hasInvites(env, g.id)) ? await invitesOf(env, g.id, origin) : [],
|
|
@@ -691,7 +688,6 @@ function checkAction(cat, op, body) {
|
|
|
691
688
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
692
689
|
const launch = body.launch === undefined || body.launch === null ? undefined : String(body.launch);
|
|
693
690
|
if (launch !== undefined && !LAUNCH_STATES.includes(launch)) return bad('launch is private, invite or public');
|
|
694
|
-
const remix = body.remix === undefined || body.remix === null ? undefined : body.remix === true || body.remix === 'on';
|
|
695
691
|
let maxPlayers;
|
|
696
692
|
if (body.maxPlayers === null || body.maxPlayers === 'game') maxPlayers = null;
|
|
697
693
|
else if (body.maxPlayers !== undefined) {
|
|
@@ -699,9 +695,10 @@ function checkAction(cat, op, body) {
|
|
|
699
695
|
if (!(maxPlayers >= 1)) return bad('maxPlayers is a number from 1 to the game\'s own seats, or null for the game\'s own');
|
|
700
696
|
maxPlayers = Math.min(maxPlayers, seatsOf(meta));
|
|
701
697
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
698
|
+
// An older plugin or CLI may still send `remix` (the switch that offered a game whole). It is not a setting
|
|
699
|
+
// any more: alone it is refused in a sentence, and beside a launch state or a room size it is left out.
|
|
700
|
+
if (launch === undefined && maxPlayers === undefined) return bad(body.remix !== undefined && body.remix !== null ? 'remix was retired: a game is no longer handed over whole, so there is no switch to set. A studio shares pieces of its games as parts (/parts/)' : 'say what to change: launch or maxPlayers');
|
|
701
|
+
return { ok: true, action: { op, game: meta.id, ...(launch !== undefined ? { launch } : {}), ...(maxPlayers !== undefined ? { maxPlayers } : {}) } };
|
|
705
702
|
}
|
|
706
703
|
case 'server-create': {
|
|
707
704
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
@@ -822,7 +819,6 @@ export function describe(cat, a) {
|
|
|
822
819
|
case 'game': {
|
|
823
820
|
const bits = [];
|
|
824
821
|
if (a.launch) bits.push({ private: `make ${gname} private (only you can open it; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, invite: `make ${gname} an invite-only beta (invited players only; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, public: `make ${gname} public (anyone can play; listed in the directory on the next read)` }[a.launch]);
|
|
825
|
-
if (a.remix !== undefined) bits.push(a.remix ? `publish ${gname}'s source for remixing` : `withdraw ${gname}'s source from remixing`);
|
|
826
822
|
if (a.maxPlayers !== undefined) bits.push(a.maxPlayers === null ? `give ${gname}'s rooms the game's own number of seats` : `set ${gname}'s rooms to at most ${a.maxPlayers} players`);
|
|
827
823
|
const s = bits.join('; ');
|
|
828
824
|
return `${s.charAt(0).toUpperCase()}${s.slice(1)}.`;
|
|
@@ -950,16 +946,16 @@ export async function perform(env, cat, a) {
|
|
|
950
946
|
const row = settings.get(meta.id) ?? {};
|
|
951
947
|
const next = {
|
|
952
948
|
launch: a.launch ?? row.launch ?? null,
|
|
953
|
-
remix: a.remix === undefined ? (row.remix ?? null) : (a.remix ? 1 : 0),
|
|
954
949
|
max: a.maxPlayers === undefined ? (row.max_players ?? null) : a.maxPlayers,
|
|
955
950
|
};
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
951
|
+
// The table's `remix` column is not named: a new row leaves it NULL and an old row keeps what it had (above).
|
|
952
|
+
await env.DB.prepare(`INSERT INTO office_games (game, launch, max_players, updated_at) VALUES (?1, ?2, ?3, ?4)
|
|
953
|
+
ON CONFLICT(game) DO UPDATE SET launch = excluded.launch, max_players = excluded.max_players, updated_at = excluded.updated_at`)
|
|
954
|
+
.bind(meta.id, next.launch, next.max, Date.now()).run();
|
|
959
955
|
forgetSettings(env);
|
|
960
956
|
const fresh = await settingsOf(env, { fresh: true });
|
|
961
957
|
const launch = launchOf(meta, fresh, env);
|
|
962
|
-
const done = { ok: true, op: 'game', game: meta.id, launch,
|
|
958
|
+
const done = { ok: true, op: 'game', game: meta.id, launch, maxPlayers: seatsFor(meta, fresh), regating: 0 };
|
|
963
959
|
const rooms = await liveRooms(env, meta.id);
|
|
964
960
|
// Who may play changed: every live room finishes its current round, with a notice, and then whoever the new
|
|
965
961
|
// state leaves out is sent out of it (the owner, and in an invite-only beta the invited, play on). Back to a
|
package/worker/pages.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { esc, layout } from './site.mjs';
|
|
6
6
|
import { NET_PALETTE } from './room.mjs';
|
|
7
|
+
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
7
8
|
import { SKILLS } from './agents.mjs';
|
|
8
9
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
9
10
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -101,7 +102,7 @@ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, lo
|
|
|
101
102
|
const arrive = arrivalCard(cat, g, { screen });
|
|
102
103
|
const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
|
|
103
104
|
html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
|
|
104
|
-
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
|
|
105
|
+
iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
105
106
|
.chip { position: fixed; left: max(10px, env(safe-area-inset-left)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #dfe6f5; font: 12px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transition: opacity .6s; }
|
|
106
107
|
.chip.quiet { opacity: 0; }
|
|
107
108
|
.card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
|
|
@@ -171,7 +172,8 @@ ${arrive.css}${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''
|
|
|
171
172
|
const srv = server ? { id: server.id, name: server.name, badge: server.badge, line: server.line, policy: server.policy, kids: Boolean(server.kids), mentor: Boolean(server.mentor), levelMax: server.levelMax } : null;
|
|
172
173
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
173
174
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
174
|
-
|
|
175
|
+
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
176
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
175
177
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
176
178
|
const joinCorner = corner('join', 'bottom-right');
|
|
177
179
|
const first = places.desk;
|
|
@@ -401,25 +403,47 @@ const SHELL_JS = String.raw`(function () {
|
|
|
401
403
|
}
|
|
402
404
|
} catch (e) {}
|
|
403
405
|
var KEY = 'homie-net.' + boot.game + '.' + room + '.' + want;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
406
|
+
// The frame's address, made again for a reload of the game (a newer build, section 23): the seat's token as it is now.
|
|
407
|
+
function frameUrl() {
|
|
408
|
+
var token = null;
|
|
409
|
+
try { token = sessionStorage.getItem(KEY); } catch (e) {}
|
|
410
|
+
var q = new URLSearchParams({ room: room, device: device, want: want });
|
|
411
|
+
if (token) q.set('k', token);
|
|
412
|
+
q.set('b', roomKey);
|
|
413
|
+
if (boot.t) q.set('t', boot.t);
|
|
414
|
+
if (params.get('name')) q.set('name', params.get('name'));
|
|
415
|
+
else {
|
|
416
|
+
// A player with an account (or a named guest) on this studio plays under their own name in every room.
|
|
417
|
+
try { var who = JSON.parse(localStorage.getItem('homie.player') || 'null'); if (who && typeof who.name === 'string' && who.name) q.set('name', who.name.slice(0, 24)); } catch (e) {}
|
|
418
|
+
}
|
|
419
|
+
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
420
|
+
if (state.quietAi) q.set('hush', '1');
|
|
421
|
+
// Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
|
|
422
|
+
try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
|
|
423
|
+
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches (section 24).
|
|
424
|
+
// An allow-list: ?debug and ?q always, the port kit's three, and the names game.json "netplay.params" declares;
|
|
425
|
+
// never one this page uses itself (the room, the token, the ticket). The game reads them as net.params.
|
|
426
|
+
var okValue = new RegExp(boot.paramValue || '^[A-Za-z0-9_.~-]{0,48}$');
|
|
427
|
+
(boot.params || ['debug', 'q', 'touchdebug', 'cam', 'view']).forEach(function (k) { var v = params.get(k); if (v !== null && okValue.test(v)) q.set(k, v); });
|
|
428
|
+
// This page keeps net.prefs for the frame (section 24).
|
|
429
|
+
q.set('pf', '1');
|
|
430
|
+
return '/' + boot.game + '/__game/?' + q.toString();
|
|
414
431
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
432
|
+
state.params = {};
|
|
433
|
+
(boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
|
|
434
|
+
// A newer build of the game is live (section 23): load the frame again, at most twice a minute (a build whose
|
|
435
|
+
// files are still the old ones at the edge would otherwise reload for ever).
|
|
436
|
+
var reloads = [];
|
|
437
|
+
function reloadGame() {
|
|
438
|
+
var t = Date.now();
|
|
439
|
+
reloads = reloads.filter(function (x) { return t - x < 60000; });
|
|
440
|
+
if (reloads.length >= 2) { flash('This game was updated. Reload the page to play the new version.'); return false; }
|
|
441
|
+
reloads.push(t);
|
|
442
|
+
state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
|
|
443
|
+
try { frame.src = frameUrl(); } catch (e) {}
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
frame.src = frameUrl();
|
|
423
447
|
A.room(labelOf(room));
|
|
424
448
|
frame.addEventListener('load', function () { A.frameLoaded(); try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
|
|
425
449
|
window.addEventListener('pointerdown', function (e) { if ((ui && ui.contains(e.target)) || (e.target.closest && e.target.closest('[data-keep-focus]'))) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
|
|
@@ -428,10 +452,26 @@ const SHELL_JS = String.raw`(function () {
|
|
|
428
452
|
var m = ev.data;
|
|
429
453
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
430
454
|
A.message(m);
|
|
431
|
-
if (m.what === 'attached') state.attached = true;
|
|
455
|
+
if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
|
|
456
|
+
// The link (section 22): in the room, knocking again, or playing alone because the room never answered.
|
|
457
|
+
if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
|
|
458
|
+
// The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
|
|
459
|
+
// arrival should be 'game'. A performance probe reads both here.
|
|
460
|
+
if (m.what === 'ready') {
|
|
461
|
+
state.ready = { mode: m.mode === 'game' ? 'game' : 'auto', ms: Number(m.ms) || 0, lateMs: typeof m.lateMs === 'number' ? m.lateMs : null, atMs: Math.round(typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) };
|
|
462
|
+
if (A.state) { A.state.explicitMs = state.ready.atMs; A.state.lateMs = state.ready.lateMs; }
|
|
463
|
+
}
|
|
464
|
+
if (m.what === 'prefs') answerPrefs(m);
|
|
465
|
+
// A newer build of the game is live. Kept out of a room for it (final): the game loads again now. Still
|
|
466
|
+
// playing in its own room: it loads again at the round's break, or at once on a big screen (nothing to lose).
|
|
467
|
+
if (m.what === 'stale') {
|
|
468
|
+
state.stale = { ver: typeof m.ver === 'string' ? m.ver : null, final: m.final === true };
|
|
469
|
+
if (m.final === true || screenMode) reloadGame();
|
|
470
|
+
else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
|
|
471
|
+
}
|
|
432
472
|
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
433
473
|
if (m.what === 'stats') state.stats = m.stats;
|
|
434
|
-
if (m.what === 'round') onRound(m.round);
|
|
474
|
+
if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
|
|
435
475
|
if (m.what === 'roster') state.roster = m.slots;
|
|
436
476
|
if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
|
|
437
477
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
@@ -440,6 +480,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
440
480
|
});
|
|
441
481
|
watch(room);
|
|
442
482
|
shareReady(room);
|
|
483
|
+
addEventListener('resize', tellRects);
|
|
484
|
+
if (typeof setInterval === 'function') setInterval(tellRects, 1000);
|
|
443
485
|
if (screenMode && !document.querySelector('[data-join]') && !LOCAL) {
|
|
444
486
|
var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
|
|
445
487
|
var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play?room=' + encodeURIComponent(room);
|
|
@@ -447,6 +489,79 @@ const SHELL_JS = String.raw`(function () {
|
|
|
447
489
|
}
|
|
448
490
|
}
|
|
449
491
|
|
|
492
|
+
/*
|
|
493
|
+
* NET.PREFS (NETPLAY.md section 24). The game's frame is an opaque origin (no allow-same-origin: a stranger's game
|
|
494
|
+
* must never read this site's storage, cookies or the owner's session), so its own localStorage throws. This page
|
|
495
|
+
* keeps a few settings for it instead: one JSON object per game under this site's storage, 16 KB and 32 keys at
|
|
496
|
+
* most, read and written only for this page's own game. With no storage (a private window): for the visit.
|
|
497
|
+
*/
|
|
498
|
+
var PREFS_KEY = 'homie-prefs.' + boot.game;
|
|
499
|
+
var PREFS = boot.prefs || { bytes: 16384, keys: 32, key: 64 };
|
|
500
|
+
var prefsMem = null;
|
|
501
|
+
function prefsRead() {
|
|
502
|
+
if (prefsMem) return prefsMem;
|
|
503
|
+
try { var o = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch (e) { prefsMem = {}; return prefsMem; }
|
|
504
|
+
}
|
|
505
|
+
function prefsWrite(o) {
|
|
506
|
+
var text = JSON.stringify(o);
|
|
507
|
+
if (typeof text !== 'string' || text.length > PREFS.bytes || Object.keys(o).length > PREFS.keys) return 'too-large';
|
|
508
|
+
if (prefsMem) { prefsMem = o; return 'memory'; }
|
|
509
|
+
try { localStorage.setItem(PREFS_KEY, text); return 'kept'; } catch (e) { prefsMem = o; return 'memory'; }
|
|
510
|
+
}
|
|
511
|
+
function answerPrefs(m) {
|
|
512
|
+
var out = { t: 'homie-prefs', n: m.n, ok: false };
|
|
513
|
+
try {
|
|
514
|
+
var all = prefsRead();
|
|
515
|
+
var k = m.k;
|
|
516
|
+
var okKey = typeof k === 'string' && k.length > 0 && k.length <= PREFS.key && k !== '__proto__';
|
|
517
|
+
if (m.op === 'all') { out.ok = true; out.all = all; }
|
|
518
|
+
else if (!okKey) out.why = 'key';
|
|
519
|
+
else if (m.op === 'set' || m.op === 'del') {
|
|
520
|
+
var next = {};
|
|
521
|
+
Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
|
|
522
|
+
if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
|
|
523
|
+
var how = prefsWrite(next);
|
|
524
|
+
if (how === 'too-large') out.why = how; else { out.ok = true; out.kept = how; }
|
|
525
|
+
} else out.why = 'op';
|
|
526
|
+
} catch (e) { out.why = 'error'; }
|
|
527
|
+
state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
|
|
528
|
+
try { frame.contentWindow.postMessage(out, '*'); } catch (e2) {}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/*
|
|
532
|
+
* WHERE THIS PAGE'S OWN CONTROLS SIT OVER THE GAME (section 24). The frame fills the page, so these rectangles are
|
|
533
|
+
* in the game's own CSS pixels. A game's HUD check that only measures inside the frame cannot see them: the room
|
|
534
|
+
* button, the server pill, the chat pill, the "3 playing" chip (it fades, and comes back), the big screen's join
|
|
535
|
+
* card, the studio's banner. Said to the helper when it attaches, on a resize or a turn, and when one moves.
|
|
536
|
+
*/
|
|
537
|
+
var RECTS = [['room', '[data-share-toggle]'], ['server', '[data-server-toggle]'], ['chat', '[data-chat-toggle]'], ['chip', '[data-chip]', true], ['join', '[data-join]'], ['banner', '[data-banner]'], ['ticker', '.ctick', true], ['results', '[data-results]'], ['screen', '[data-screen]'], ['sheet', '[data-share-sheet]'], ['server-sheet', '[data-server-sheet]'], ['vote', '[data-vote]']];
|
|
538
|
+
var lastRects = '';
|
|
539
|
+
function shellRects() {
|
|
540
|
+
var out = [];
|
|
541
|
+
RECTS.forEach(function (r) {
|
|
542
|
+
var el = null;
|
|
543
|
+
try { el = document.querySelector(r[1]); } catch (e) { el = null; }
|
|
544
|
+
if (!el || el.hidden || typeof el.getBoundingClientRect !== 'function') return;
|
|
545
|
+
var b = el.getBoundingClientRect();
|
|
546
|
+
if (!b || !(b.width > 0) || !(b.height > 0)) return;
|
|
547
|
+
var o = { id: r[0], x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) };
|
|
548
|
+
if (r[2]) o.fades = true;
|
|
549
|
+
out.push(o);
|
|
550
|
+
});
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
function tellRects() {
|
|
554
|
+
if (!state.attached) return;
|
|
555
|
+
var w = typeof innerWidth === 'number' ? innerWidth : 0;
|
|
556
|
+
var h = typeof innerHeight === 'number' ? innerHeight : 0;
|
|
557
|
+
var msg = { t: 'homie-shell', device: device, orientation: w > h ? 'landscape' : 'portrait', width: w, height: h, rects: shellRects() };
|
|
558
|
+
var sig = JSON.stringify(msg);
|
|
559
|
+
if (sig === lastRects) return;
|
|
560
|
+
lastRects = sig;
|
|
561
|
+
state.rects = msg;
|
|
562
|
+
try { frame.contentWindow.postMessage(msg, '*'); } catch (e) {}
|
|
563
|
+
}
|
|
564
|
+
|
|
450
565
|
function onRound(r) {
|
|
451
566
|
if (!r) return;
|
|
452
567
|
state.round = r;
|
|
@@ -566,6 +681,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
566
681
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
567
682
|
if (state.full) bits.push('waiting for a seat');
|
|
568
683
|
if (state.closed) bits.push(state.closed === 'replaced' ? 'opened in another tab' : 'reconnecting');
|
|
684
|
+
// The link (section 22): cut off from the room and knocking again, or playing alone because it never answered.
|
|
685
|
+
else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
|
|
686
|
+
else if (state.link && state.link.state === 'alone') bits.push('offline, reconnecting');
|
|
569
687
|
// The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
|
|
570
688
|
if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
|
|
571
689
|
say(bits.join(' · ') || 'joining…');
|
|
@@ -899,7 +1017,7 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
|
|
|
899
1017
|
body { --u: clamp(12px, calc(0.8vmin + 7px), 22px); --band: #080b14; --line: rgba(255,255,255,.08); display: flex; flex-direction: column; font: 600 var(--u)/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
900
1018
|
body.big { --u: clamp(16px, calc(1vmin + 6px), 32px); }
|
|
901
1019
|
.stage { position: relative; flex: 1; min-height: 0; background: #04060c; }
|
|
902
|
-
iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; }
|
|
1020
|
+
iframe.game { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
903
1021
|
.wtop { flex: none; display: flex; align-items: center; gap: calc(var(--u) * .7); padding: max(calc(var(--u) * .5), env(safe-area-inset-top)) max(calc(var(--u) * .9), env(safe-area-inset-right)) calc(var(--u) * .5) max(calc(var(--u) * .9), env(safe-area-inset-left)); background: linear-gradient(#0b0f1a, var(--band)); border-bottom: 1px solid var(--line); min-width: 0; }
|
|
904
1022
|
.grow { flex: 1; }
|
|
905
1023
|
.live { flex: none; display: inline-flex; align-items: center; gap: .45em; padding: .3em .7em .3em .6em; border-radius: 999px; background: rgba(255,59,92,.16); border: 1px solid rgba(255,90,120,.5); font-weight: 800; font-size: .74em; letter-spacing: .14em; text-transform: uppercase; color: #ffd9e0; }
|