@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.
- package/CHANGELOG.md +36 -12
- package/dist/avatar-pixi.js +3 -3
- package/dist/doctor/cli.mjs +67 -4
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/shared/avatar.d.ts +3 -3
- package/dist/shared/avatar.d.ts.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/describe.d.ts.map +1 -1
- package/dist/shared/index.d.ts +8 -1
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/src.js +18 -0
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +153 -96
- package/dist/shared/svg-markup.js.map +1 -1
- package/package.json +3 -3
- package/src/avatar/adapters/pixi-crowd.ts +2 -2
- package/src/avatar/render/compose.ts +48 -22
- package/src/avatar/render/frame.ts +2 -2
- package/src/avatar/render/walker-parts.ts +24 -21
- package/src/avatar/render/walker.ts +100 -60
- package/src/core/hub.ts +1 -0
- package/src/core/rest/routes.gen.ts +1 -0
- package/src/core/turns/turn-game.ts +10 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/doctor/checks.ts +2 -2
- package/src/doctor/cli.ts +5 -2
- package/src/doctor/embed-check.ts +61 -0
|
@@ -1,48 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The world walker (`figure: 'walker'`): a small
|
|
3
|
-
* maps and crowds
|
|
4
|
-
*
|
|
5
|
-
* pose names: `walk` and
|
|
6
|
-
* `
|
|
7
|
-
* the
|
|
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
|
|
11
|
-
*
|
|
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.
|
|
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 =
|
|
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
|
|
27
|
+
export interface WalkerHeights {
|
|
27
28
|
readonly hip: number;
|
|
28
29
|
readonly shoulder: number;
|
|
29
30
|
readonly head: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
93
|
-
const
|
|
94
|
-
const
|
|
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
|
|
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
|
|
102
|
-
|
|
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) *
|
|
105
|
-
const elbow: Vec = [shoulder[0] + out *
|
|
106
|
-
return [shoulder, elbow, [elbow[0] + out *
|
|
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 =
|
|
109
|
-
const reach = Math.sin(
|
|
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.
|
|
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:
|
|
118
|
-
const thigh: Vec = [v.forward[0] *
|
|
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,
|
|
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] *
|
|
151
|
+
return [hip, knee, [knee[0] + v.forward[0] * 2, WALKER_SEAT_DROP - 2]];
|
|
124
152
|
},
|
|
125
153
|
arm: (sideOf) => {
|
|
126
|
-
const shoulder
|
|
127
|
-
const elbow: Vec = [shoulder[0] + sideOf * v.front *
|
|
128
|
-
const lap = add([lateralX(v, sideOf, pose === 'play' ? 4 :
|
|
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 :
|
|
171
|
+
const h = isSitting ? SEATED : WALKER_STANDING;
|
|
139
172
|
const cycle = Math.sin(phase * Math.PI * 2);
|
|
140
|
-
const
|
|
141
|
-
const hop = pose === 'jump' || pose === 'cheer' ? Math.abs(Math.sin(phase * Math.PI)) * (pose === 'jump' ?
|
|
142
|
-
|
|
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.
|
|
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
|
|
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 +
|
|
167
|
-
neck(c,
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
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.
|
|
3
|
+
export const HUB_CLIENT_VERSION = '2.0.0-next.12';
|
package/src/doctor/checks.ts
CHANGED
|
@@ -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 `
|
|
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
|
+
}
|