@diffenderfer-games/hub 2.0.0-next.10 → 2.0.0-next.12

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.
@@ -1,48 +1,60 @@
1
1
  /**
2
- * The world walker (`figure: 'walker'`): a small full-body character for
3
- * maps and crowds, with legs, arms, a shirt, trousers and shoes, and the
4
- * head drawn smaller on top ({@link WALKER_HEAD_SCALE}). It takes the blob's
5
- * pose names: `walk` and `run` swing the legs and arms along the facing,
6
- * `sit` folds the legs over a seat, `idle` breathes, `wave` and `cheer` lift
7
- * the arms.
2
+ * The world walker (`figure: 'walker'`): a small, chunky, chibi-like
3
+ * full-body character for maps and crowds: a short round body with stubby
4
+ * legs and arms, a shirt, trousers and shoes, and a big head on top
5
+ * ({@link WALKER_HEAD_SCALE}). It takes the blob's pose names: `walk` and
6
+ * `run` waddle (quick steps that bounce and rock from foot to foot), `sit`
7
+ * folds the legs over a seat, `idle` breathes, `wave` and `cheer` lift the
8
+ * arms.
8
9
  *
9
10
  * Standing, the origin is the feet. Sitting (`sit`, `play`), the origin is
10
- * the seat: the hips rest on it and the feet hang {@link WALKER_SEAT_DROP}
11
- * units below, on the floor in front of it.
11
+ * the seat: the hips rest on it and the short legs dangle over its front
12
+ * edge, the feet {@link WALKER_SEAT_DROP} units below it.
12
13
  */
13
- import { ellipse, type SceneNode } from '../scene';
14
+ import { ellipse, g, rot, type SceneNode } from '../scene';
14
15
  import type { BodyLayout } from './body';
15
16
  import type { Colours } from './colours';
16
17
  import type { BodyPose } from './types';
17
18
  import { hand, hips, limb, neck, outfitFor, shirt, shoe, type Outfit, type Vec } from './walker-parts';
18
19
 
19
20
  /** The head's scale on a walker (the blob draws it at 1). */
20
- export const WALKER_HEAD_SCALE = 0.58;
21
+ export const WALKER_HEAD_SCALE = 0.62;
21
22
 
22
- /** How far below the seat a sitter's feet reach (rig units). */
23
- export const WALKER_SEAT_DROP = 26;
23
+ /** How far below the seat a sitter's dangling feet reach (rig units): short legs swing clear of the floor. */
24
+ export const WALKER_SEAT_DROP = 13;
24
25
 
25
26
  /** Heights of the hips, shoulders and head centre above the origin. */
26
- interface Heights {
27
+ export interface WalkerHeights {
27
28
  readonly hip: number;
28
29
  readonly shoulder: number;
29
30
  readonly head: number;
30
31
  }
31
32
 
32
- const STANDING: Heights = { hip: -35, shoulder: -72, head: -103 };
33
- const SEATED: Heights = { hip: -3, shoulder: -40, head: -71 };
34
- const LEG = 32;
35
- const UPPER_ARM = 13;
36
- const FOREARM = 12;
37
- const LEG_W = 10.5;
38
- const SLEEVE_W = 10;
39
- const ARM_W = 7.5;
33
+ /** Standing: a short, round body (half the first walker's 72 units to the shoulders) under the head. */
34
+ export const WALKER_STANDING: WalkerHeights = { hip: -18, shoulder: -38, head: -70 };
35
+ const SEATED: WalkerHeights = { hip: -3, shoulder: -23, head: -55 };
36
+ const LEG = 18;
37
+ const UPPER_ARM = 8;
38
+ const FOREARM = 7;
39
+ const LEG_W = 13;
40
+ const SLEEVE_W = 12.5;
41
+ const ARM_W = 9;
42
+ /** How far out the hips and shoulders sit from the middle. */
43
+ const HIP_X = 7.5;
44
+ const SHOULDER_X = 19;
45
+ /** The seated thigh, along the facing. */
46
+ const THIGH = 9;
40
47
  const DEG = Math.PI / 180;
41
48
 
42
- /** A walker's layout: the blob's fields plus where the head goes and its scale. */
49
+ /** The contact shadow's half-width under a walker (the round body is wide). */
50
+ export const WALKER_SHADOW = 22;
51
+
52
+ /** A walker's layout: the blob's fields plus where the head goes, its scale and the long-hair split. */
43
53
  export interface WalkerLayout extends BodyLayout {
44
54
  readonly headY: number;
45
55
  readonly headScale: number;
56
+ /** The shoulder line in head space: long hair below it hangs behind the body. */
57
+ readonly shoulderLine: number;
46
58
  }
47
59
 
48
60
  /** One side of the body: −1 is the character's right, +1 its left. */
@@ -64,7 +76,7 @@ interface View {
64
76
  function viewOf(angleDeg: number): View {
65
77
  const side = Math.sin(angleDeg * DEG);
66
78
  const front = Math.cos(angleDeg * DEG);
67
- return { side, front, forward: [side, front * 0.45], isBack: angleDeg > 90, width: 1 - 0.36 * Math.abs(side) };
79
+ return { side, front, forward: [side, front * 0.45], isBack: angleDeg > 90, width: 1 - 0.26 * Math.abs(side) };
68
80
  }
69
81
 
70
82
  /** A body side's screen x for a lateral distance (sides overlap when turned). */
@@ -82,64 +94,87 @@ interface Limbs {
82
94
  readonly arm: (sideOf: Side) => readonly [Vec, Vec, Vec];
83
95
  }
84
96
 
85
- function standingLimbs(v: View, h: Heights, pose: BodyPose, phase: number, bob: number): Limbs {
86
- const isRun = pose === 'run';
87
- const isWalking = pose === 'walk' || isRun || pose === 'dance';
88
- const stride = isRun ? 38 : 27;
89
- const swingOf = (sideOf: Side): number => (isWalking ? Math.sin((phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2) : 0);
97
+ /** The walk's and run's step: stride angle, foot lift, bounce and side-to-side rock (degrees). */
98
+ const GAIT = {
99
+ walk: { stride: 36, lift: 3.6, bounce: 3.2, rock: 5 },
100
+ run: { stride: 46, lift: 5.4, bounce: 4.4, rock: 3.5 },
101
+ } as const;
102
+
103
+ /** A gait's numbers. */
104
+ type Gait = (typeof GAIT)[keyof typeof GAIT];
105
+
106
+ /** The gait a pose steps with, if it steps. */
107
+ function gaitOf(pose: BodyPose): Gait | null {
108
+ return pose === 'run' ? GAIT.run : pose === 'walk' || pose === 'dance' ? GAIT.walk : null;
109
+ }
110
+
111
+ /** A shoulder joint: out at the torso's edge (narrower when turned), nudged along the facing. */
112
+ const shoulderAt = (v: View, h: WalkerHeights, sideOf: Side, bob: number): Vec => [lateralX(v, sideOf, SHOULDER_X * v.width) + v.forward[0] * 1.5, h.shoulder + 4 + bob];
113
+
114
+ function standingLimbs(v: View, h: WalkerHeights, pose: BodyPose, phase: number, bob: number): Limbs {
115
+ const gait = gaitOf(pose);
116
+ const stepOf = (sideOf: Side): number => (phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2;
117
+ const swingOf = (sideOf: Side): number => (gait ? Math.sin(stepOf(sideOf)) : 0);
90
118
  return {
91
119
  leg: (sideOf) => {
92
- const hip: Vec = [lateralX(v, sideOf, 6), h.hip + bob];
93
- const swing = swingOf(sideOf);
94
- const lift = isWalking ? Math.max(0, Math.cos((phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2)) * (isRun ? 7 : 4.5) : 0;
95
- const reach = Math.sin(stride * DEG) * LEG * swing;
120
+ const hip: Vec = [lateralX(v, sideOf, HIP_X), h.hip + bob];
121
+ const lift = gait ? Math.max(0, Math.cos(stepOf(sideOf))) * gait.lift : 0;
122
+ const reach = gait ? Math.sin(gait.stride * DEG) * LEG * swingOf(sideOf) : 0;
96
123
  const foot: Vec = [hip[0] + v.forward[0] * reach, -bob * 0.2 + v.forward[1] * reach - lift];
97
- const knee: Vec = [(hip[0] + foot[0]) / 2 + v.forward[0] * lift * 0.9, (hip[1] + foot[1]) / 2 + v.forward[1] * lift * 0.9];
124
+ const knee: Vec = [(hip[0] + foot[0]) / 2 + v.forward[0] * lift, (hip[1] + foot[1]) / 2 + v.forward[1] * lift];
98
125
  return [hip, knee, foot];
99
126
  },
100
127
  arm: (sideOf) => {
101
- const shoulder: Vec = [lateralX(v, sideOf, 15.5 * v.width) + v.forward[0] * 1.5, h.shoulder + 4 + bob];
102
- const out = sideOf * v.front * 2.2;
128
+ const shoulder = shoulderAt(v, h, sideOf, bob);
129
+ // The arms stand off the round belly.
130
+ const out = sideOf * v.front * 3.4;
103
131
  if (pose === 'cheer' || (pose === 'wave' && sideOf === -1)) {
104
- const wave = pose === 'wave' ? Math.sin(phase * Math.PI * 2) * 4 : 0;
105
- const elbow: Vec = [shoulder[0] + out * 3, shoulder[1] - 10];
106
- return [shoulder, elbow, [elbow[0] + out * 1.5 + wave, elbow[1] - 12]];
132
+ const wave = pose === 'wave' ? Math.sin(phase * Math.PI * 2) * 3 : 0;
133
+ const elbow: Vec = [shoulder[0] + out * 2.2, shoulder[1] - 7];
134
+ return [shoulder, elbow, [elbow[0] + out * 0.8 + wave, elbow[1] - 10]];
107
135
  }
108
- const swing = isWalking ? -swingOf(sideOf) * (isRun ? 1.3 : 1) : Math.sin(phase * Math.PI * 2) * 0.06;
109
- const reach = Math.sin(24 * DEG) * swing;
136
+ const swing = gait ? -swingOf(sideOf) * (pose === 'run' ? 1.4 : 1.1) : Math.sin(phase * Math.PI * 2) * 0.06;
137
+ const reach = Math.sin(30 * DEG) * swing;
110
138
  const elbow: Vec = [shoulder[0] + out + v.forward[0] * UPPER_ARM * reach * 0.6, shoulder[1] + UPPER_ARM + v.forward[1] * UPPER_ARM * reach];
111
- const wrist: Vec = [elbow[0] + out * 0.4 + v.forward[0] * FOREARM * reach * 1.6, elbow[1] + FOREARM - Math.abs(reach) * 3 + v.forward[1] * FOREARM * reach];
139
+ const wrist: Vec = [elbow[0] + out * 0.35 + v.forward[0] * FOREARM * reach * 1.6, elbow[1] + FOREARM - Math.abs(reach) * 2 + v.forward[1] * FOREARM * reach];
112
140
  return [shoulder, elbow, wrist];
113
141
  },
114
142
  };
115
143
  }
116
144
 
117
- function seatedLimbs(v: View, h: Heights, pose: BodyPose): Limbs {
118
- const thigh: Vec = [v.forward[0] * 15, v.forward[1] * 16];
145
+ function seatedLimbs(v: View, h: WalkerHeights, pose: BodyPose): Limbs {
146
+ const thigh: Vec = [v.forward[0] * THIGH, v.forward[1] * (THIGH + 1)];
119
147
  return {
120
148
  leg: (sideOf) => {
121
- const hip: Vec = [lateralX(v, sideOf, 6), h.hip];
149
+ const hip: Vec = [lateralX(v, sideOf, HIP_X), h.hip];
122
150
  const knee = add(hip, thigh);
123
- return [hip, knee, [knee[0] + v.forward[0] * 3, WALKER_SEAT_DROP - 2]];
151
+ return [hip, knee, [knee[0] + v.forward[0] * 2, WALKER_SEAT_DROP - 2]];
124
152
  },
125
153
  arm: (sideOf) => {
126
- const shoulder: Vec = [lateralX(v, sideOf, 15.5 * v.width) + v.forward[0] * 1.5, h.shoulder + 4];
127
- const elbow: Vec = [shoulder[0] + sideOf * v.front * 2 + v.forward[0] * 3, shoulder[1] + UPPER_ARM];
128
- const lap = add([lateralX(v, sideOf, pose === 'play' ? 4 : 7), h.hip - 4], thigh, pose === 'play' ? 0.9 : 0.6);
154
+ const shoulder = shoulderAt(v, h, sideOf, 0);
155
+ const elbow: Vec = [shoulder[0] + sideOf * v.front * 3 + v.forward[0] * 2, shoulder[1] + UPPER_ARM];
156
+ const lap = add([lateralX(v, sideOf, pose === 'play' ? 4 : 8), h.hip - 3], thigh, pose === 'play' ? 0.9 : 0.6);
129
157
  return [shoulder, elbow, lap];
130
158
  },
131
159
  };
132
160
  }
133
161
 
162
+ /** The long-hair split in head space: a little above the torso's top edge (the first walker's line sat 12 head units above it). */
163
+ function shoulderLineOf(torsoTop: number, headY: number): number {
164
+ return (torsoTop - 3 - headY) / WALKER_HEAD_SCALE - 12;
165
+ }
166
+
134
167
  /** The walker's drawing for a pose, at a facing angle (0 = S, 180 = N; west facings are mirrored by the caller). */
135
168
  export function walkerLayout(c: Colours, pose: BodyPose, angleDeg: number, phase: number, outfit: Outfit = outfitFor(c)): WalkerLayout {
136
169
  const v = viewOf(angleDeg);
137
170
  const isSitting = pose === 'sit' || pose === 'play';
138
- const h = isSitting ? SEATED : STANDING;
171
+ const h = isSitting ? SEATED : WALKER_STANDING;
139
172
  const cycle = Math.sin(phase * Math.PI * 2);
140
- const isWalking = pose === 'walk' || pose === 'run' || pose === 'dance';
141
- const hop = pose === 'jump' || pose === 'cheer' ? Math.abs(Math.sin(phase * Math.PI)) * (pose === 'jump' ? 16 : 8) : 0;
142
- const bob = isWalking ? -2.2 * (1 - Math.abs(cycle)) : pose === 'idle' ? cycle * 0.6 : 0;
173
+ const gait = isSitting ? null : gaitOf(pose);
174
+ const hop = pose === 'jump' || pose === 'cheer' ? Math.abs(Math.sin(phase * Math.PI)) * (pose === 'jump' ? 12 : 6) : 0;
175
+ // Short legs: a quick, bouncy waddle that rocks from foot to foot.
176
+ const bob = gait ? -gait.bounce * (1 - Math.abs(cycle)) : pose === 'idle' ? cycle * 0.6 : 0;
177
+ const rock = gait ? cycle * gait.rock * (0.4 + 0.6 * Math.abs(v.front)) : 0;
143
178
  const limbs = isSitting ? seatedLimbs(v, h, pose) : standingLimbs(v, h, pose, phase, bob);
144
179
  const o = outfit;
145
180
  const [farSide, nearSide]: readonly [Side, Side] = nearness(v, 1) > nearness(v, -1) ? [-1, 1] : [1, -1];
@@ -147,14 +182,15 @@ export function walkerLayout(c: Colours, pose: BodyPose, angleDeg: number, phase
147
182
  const lean = v.side * (isSitting ? 0.6 : 1);
148
183
  const armNodes = (sideOf: Side): SceneNode[] => {
149
184
  const [shoulder, elbow, wrist] = limbs.arm(sideOf);
150
- return [...limb([shoulder, elbow, wrist], ARM_W, c.skin, c.skinInk), ...limb([shoulder, add(shoulder, [elbow[0] - shoulder[0], elbow[1] - shoulder[1]], 0.62)], SLEEVE_W, c.body, c.bodyInk), hand(c, wrist)];
185
+ return [...limb([shoulder, elbow, wrist], ARM_W, c.skin, c.skinInk), ...limb([shoulder, add(shoulder, [elbow[0] - shoulder[0], elbow[1] - shoulder[1]], 0.7)], SLEEVE_W, c.body, c.bodyInk), hand(c, wrist)];
151
186
  };
152
187
  const legNodes = (sideOf: Side): SceneNode[] => {
153
188
  const points = limbs.leg(sideOf);
154
189
  const foot = points[2];
155
190
  return [...limb(points, LEG_W, o.trousers, o.trousersInk), shoe(foot, v.forward, o)];
156
191
  };
157
- const torso = shirt(c, v.width, h.shoulder + (isWalking ? bob : 0), h.hip + 3 + (isWalking ? bob : 0), lean, v.isBack);
192
+ const torsoTop = h.shoulder + bob;
193
+ const torso = shirt(c, v.width, torsoTop, h.hip + 3 + bob, lean, v.isBack);
158
194
  // Seated with our back to us, the arms reach forward and the legs are under the seat: both hide behind the back.
159
195
  const isTucked = isSitting && v.isBack;
160
196
  const behind: SceneNode[] = isTucked ? [...armNodes(farSide), ...armNodes(nearSide)] : isTurned ? armNodes(farSide) : [];
@@ -163,17 +199,21 @@ export function walkerLayout(c: Colours, pose: BodyPose, angleDeg: number, phase
163
199
  const legs = isTucked ? [] : [...legNodes(farSide), ...legNodes(nearSide)];
164
200
  const body: SceneNode[] = [
165
201
  ...(isLegsFirst ? legs : []),
166
- hips(o, v.width, h.hip + (isWalking ? bob : 0)),
167
- neck(c, h.shoulder + bob, v.forward[0] * 2),
202
+ hips(o, v.width, h.hip + bob),
203
+ neck(c, torsoTop, v.forward[0] * 2),
168
204
  ...torso,
169
205
  ...(isLegsFirst ? [] : legs),
170
206
  ...(isTucked ? [] : isTurned ? armNodes(nearSide) : [...armNodes(-1), ...armNodes(1)]),
171
207
  ];
172
- const prop = add([0, h.hip - 12], v.forward, 8);
208
+ // The rock turns the body about the feet; the head rides it and nods with it.
209
+ const rocked = (nodes: SceneNode[]): SceneNode[] => (rock === 0 || nodes.length === 0 ? nodes : [g(nodes, { tf: [rot(rock)] })]);
210
+ const headY = h.head + bob * 1.1;
211
+ const prop = add([0, h.hip - 6], v.forward, 8);
173
212
  return {
174
- behind, body, front: [],
175
- headDx: v.forward[0] * 1.5, headDy: 0, headY: h.head + bob * 1.1, headScale: WALKER_HEAD_SCALE,
176
- tilt: isWalking ? v.side * 2 : 0, lift: hop, prop: { x: prop[0], y: prop[1] },
213
+ behind: rocked(behind), body: rocked(body), front: [],
214
+ headDx: v.forward[0] * 1.5 - headY * Math.sin(rock * DEG), headDy: 0, headY, headScale: WALKER_HEAD_SCALE,
215
+ shoulderLine: shoulderLineOf(torsoTop, headY),
216
+ tilt: (gait ? v.side * 2 : 0) + rock, lift: hop, prop: { x: prop[0], y: prop[1] },
177
217
  };
178
218
  }
179
219
 
package/src/core/hub.ts CHANGED
@@ -186,6 +186,7 @@ export class Hub extends HubEconomy {
186
186
  setNotify: async (id, mode) => { await rest.call('setTurnGameNotify', { id, mode }); },
187
187
  rematch: (id) => rest.call('rematchTurnGame', { id }),
188
188
  invite: (id, userIds) => rest.call('inviteToTurnGame', { id, userIds: [...userIds] }),
189
+ kick: (id, seat) => rest.call('kickFromTurnGame', { id, seat }),
189
190
  share: (id, share) => rest.call('shareTurnGame', { id, ...share }),
190
191
  autoPlay: (id) => rest.call('autoPlayTurn', { id }),
191
192
  chat: (id, send) => rest.call('chatInTurnGame', { id, ...send }),
@@ -132,6 +132,7 @@ export const ROUTES = {
132
132
  setTurnGameNotify: { method: 'PUT', path: '/turns/:id/notify', query: [], offline: null },
133
133
  rematchTurnGame: { method: 'POST', path: '/turns/:id/rematch', query: [], offline: null },
134
134
  inviteToTurnGame: { method: 'POST', path: '/turns/:id/invite', query: [], offline: null },
135
+ kickFromTurnGame: { method: 'POST', path: '/turns/:id/kick', query: [], offline: null },
135
136
  shareTurnGame: { method: 'POST', path: '/turns/:id/share', query: [], offline: null },
136
137
  autoPlayTurn: { method: 'POST', path: '/turns/:id/auto', query: [], offline: null },
137
138
  chatInTurnGame: { method: 'POST', path: '/turns/:id/chat', query: [], offline: null },
@@ -71,6 +71,7 @@ export interface TurnGameHost {
71
71
  setNotify(id: string, mode: 'on' | 'off'): Promise<void>;
72
72
  rematch(id: string): Promise<TurnGameView>;
73
73
  invite(id: string, userIds: readonly number[]): Promise<TurnGameView>;
74
+ kick(id: string, seat: number): Promise<TurnGameView>;
74
75
  share(id: string, share: { readonly link?: boolean; readonly public?: boolean }): Promise<TurnGameView>;
75
76
  autoPlay(id: string): Promise<TurnGameView>;
76
77
  chat(id: string, send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine>;
@@ -248,6 +249,15 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
248
249
  this.update(await this.host.invite(this.id, userIds));
249
250
  }
250
251
 
252
+ /**
253
+ * Creator: take the player in `seat` out. Before the start their seat opens up
254
+ * again (an invite is withdrawn); once it has started, the computer plays
255
+ * the seat for the rest of the game, as when a player leaves.
256
+ */
257
+ async kick(seat: number): Promise<void> {
258
+ this.update(await this.host.kick(this.id, seat));
259
+ }
260
+
251
261
  /**
252
262
  * Whether "Let the computer play for <name>" may work now: another
253
263
  * player is due, the hub's hand-over wait (`summary.handOverAt`) has
@@ -1,3 +1,3 @@
1
1
  // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
2
2
  /** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
3
- export const HUB_CLIENT_VERSION = '2.0.0-next.10';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.12';
@@ -15,8 +15,8 @@ export type FindingLevel = 'error' | 'warning' | 'info';
15
15
  export interface Finding {
16
16
  /** How serious it is. */
17
17
  readonly level: FindingLevel;
18
- /** Which check found it: `version`, `vendored`, `manifest` or `world`. */
19
- readonly check: 'version' | 'vendored' | 'manifest' | 'world';
18
+ /** Which check found it: `version`, `vendored`, `manifest`, `world` or `embed`. */
19
+ readonly check: 'version' | 'vendored' | 'manifest' | 'world' | 'embed';
20
20
  /** What to tell the developer, with the fix. */
21
21
  readonly message: string;
22
22
  }
package/src/doctor/cli.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  * `supported` fails, `deprecated` warns (`--offline` skips this check);
13
13
  * - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
14
14
  * - the `package.json` `game.*` block must match the contract's schema;
15
- * - a `game.world.map` file must exist and be a valid `dg-world@1` map within the size limit.
15
+ * - a `game.world.map` file must exist and be a valid `dg-world@1` map within the size limit;
16
+ * - a `game.embed.frame` must exist and be a valid, safe frame SVG within the size limit, its screen
17
+ * shaped like `game.embed.size` (docs/hub.md "Playing over the world").
16
18
  *
17
19
  * Exits 1 when any check fails.
18
20
  */
@@ -22,6 +24,7 @@ import { parseArgs } from 'node:util';
22
24
  import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
23
25
  import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
24
26
  import { DEFAULT_HUB_TIMEOUT_MS, fetchHubVersion } from './hub-version-fetch';
27
+ import { checkEmbed } from './embed-check';
25
28
  import { checkWorld } from './world-check';
26
29
 
27
30
  const DEFAULT_HUB_URL = 'https://diffenderfer.games';
@@ -82,7 +85,7 @@ async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
82
85
  findings.push(...checkVendoredCopies(options.gameDir));
83
86
  try {
84
87
  const packageJson = readJsonFile(join(options.gameDir, 'package.json'));
85
- findings.push(...checkManifest(packageJson), ...checkWorld(options.gameDir, packageJson));
88
+ findings.push(...checkManifest(packageJson), ...checkWorld(options.gameDir, packageJson), ...checkEmbed(options.gameDir, packageJson));
86
89
  } catch (failure) {
87
90
  findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
88
91
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `hub-doctor`'s embed check (docs/hub.md "Playing over the world"): a
3
+ * `game.embed.frame` must exist inside the game, be within the size limit
4
+ * and pass the same sanitiser the hub runs at load (a valid SVG with one
5
+ * screen rect, nothing that runs or fetches anything); its screen should be
6
+ * shaped like `game.embed.size`.
7
+ */
8
+ import { existsSync, readFileSync, statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { FRAME_LIMITS, GamePackageJson, embedSizeOf, frameAspectMismatch, sanitizeFrameSvg } from '@diffenderfer-games/hub-contract';
11
+ import type { Finding } from './checks';
12
+
13
+ /** How far the screen's shape may be from `game.embed.size` before a warning. */
14
+ const ASPECT_TOLERANCE = 0.03;
15
+
16
+ /** Checks the frame `game.embed` names. A manifest that doesn't parse is left to `checkManifest`. */
17
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
18
+ export function checkEmbed(gameDir: string, packageJson: unknown): Finding[] {
19
+ const parsed = GamePackageJson.safeParse(packageJson);
20
+ const embed = parsed.success ? parsed.data.game.embed : undefined;
21
+ if (embed === undefined) {
22
+ return [];
23
+ }
24
+ const size = embedSizeOf(embed.size);
25
+ const summary = `game.embed: ${embed.supported ? 'plays over the world' : 'opens as its own page from the world'} at ${String(size.w)} × ${String(size.h)}`;
26
+ if (embed.frame === undefined) {
27
+ return [{ level: 'info', check: 'embed', message: `${summary}, in the hub's bezel` }];
28
+ }
29
+ return [{ level: 'info', check: 'embed', message: summary }, ...checkFrameFile(gameDir, embed.frame, size)];
30
+ }
31
+
32
+ /** One frame file's findings. */
33
+ export function checkFrameFile(gameDir: string, frameFile: string, size: { readonly w: number; readonly h: number }): Finding[] {
34
+ const path = join(gameDir, frameFile);
35
+ const label = `game.embed.frame (${frameFile})`;
36
+ const error = (message: string): Finding => ({ level: 'error', check: 'embed', message: `${label}: ${message}` });
37
+ if (!existsSync(path) || !statSync(path).isFile()) {
38
+ return [error('the file does not exist (commit it)')];
39
+ }
40
+ const bytes = statSync(path).size;
41
+ if (bytes > FRAME_LIMITS.bytes) {
42
+ return [error(`${String(bytes)} bytes is over the ${String(FRAME_LIMITS.bytes)}-byte limit (simplify the paths or drop embedded data)`)];
43
+ }
44
+ const result = sanitizeFrameSvg(readFileSync(path, 'utf8'));
45
+ const listed = result.problems.map((problem): Finding => ({ level: problem.severity, check: 'embed', message: `${label}: ${problem.message}` }));
46
+ if (!result.ok) {
47
+ return listed;
48
+ }
49
+ const { viewBox, screen } = result.geometry;
50
+ const mismatch = frameAspectMismatch(result.geometry, size);
51
+ if (mismatch > ASPECT_TOLERANCE) {
52
+ listed.push({
53
+ level: 'warning', check: 'embed',
54
+ message: `${label}: the screen is ${screen.w.toFixed(0)} × ${screen.h.toFixed(0)}, ${(mismatch * 100).toFixed(0)} % off the shape of game.embed.size (${String(size.w)} × ${String(size.h)}); the game will be shown at the screen's shape`,
55
+ });
56
+ }
57
+ return [...listed, {
58
+ level: 'info', check: 'embed',
59
+ message: `${label} is a valid frame: viewBox ${String(viewBox.w)} × ${String(viewBox.h)}, screen at ${String(screen.x)}, ${String(screen.y)} (${String(screen.w)} × ${String(screen.h)}), ${String(bytes)} bytes`,
60
+ }];
61
+ }