Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions scripts/check-code-health.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function checkFormat() {
const observed = { files: report.summary.errors };
log(`TypeScript format debt: ${observed.files} files.`);
// Ratcheted legacy debt: https://github.com/Significant-Hobbies/motion/issues/26
failRegressions("TypeScript format", observed, { files: 19 });
failRegressions("TypeScript format", observed, { files: 0 });
}

function checkCoverage() {
Expand Down Expand Up @@ -168,9 +168,9 @@ function checkComplexity() {
);
// Ratcheted legacy debt: https://github.com/Significant-Hobbies/motion/issues/26
failRegressions("Complexity", observed, {
violations: 6,
violations: 3,
maxCcn: 22,
maxLength: 189,
maxLength: 180,
maxParams: 7,
});
}
Expand Down Expand Up @@ -208,9 +208,9 @@ function checkDuplication() {
);
// Ratcheted legacy debt: https://github.com/Significant-Hobbies/motion/issues/26
failRegressions("Duplication", observed, {
clones: 7,
duplicatedLines: 102,
percentage: 0.8814379536812997,
clones: 5,
duplicatedLines: 65,
percentage: 0.5543237250554324,
});
}

Expand Down
80 changes: 55 additions & 25 deletions scripts/check-landing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,24 @@ const [
ai,
sourceLogo,
publicLogo,
] =
await Promise.all([
readFile(path.join(landing, "index.html"), "utf8"),
readFile(path.join(landing, "changelog.html"), "utf8"),
readFile(path.join(landing, "privacy.html"), "utf8"),
readFile(path.join(landing, "styles.css"), "utf8"),
readFile(path.join(landing, "llms.txt"), "utf8"),
readFile(path.join(landing, "index.md"), "utf8"),
readFile(path.join(landing, "_redirects"), "utf8"),
readFile(path.join(landing, "sitemap.xml"), "utf8"),
readFile(path.join(landing, "api/ai.json"), "utf8").then(JSON.parse),
readFile(path.join(root, "web/public/motion-logo.png")),
readFile(path.join(landing, "assets/motion-logo.png")),
]);
] = await Promise.all([
readFile(path.join(landing, "index.html"), "utf8"),
readFile(path.join(landing, "changelog.html"), "utf8"),
readFile(path.join(landing, "privacy.html"), "utf8"),
readFile(path.join(landing, "styles.css"), "utf8"),
readFile(path.join(landing, "llms.txt"), "utf8"),
readFile(path.join(landing, "index.md"), "utf8"),
readFile(path.join(landing, "_redirects"), "utf8"),
readFile(path.join(landing, "sitemap.xml"), "utf8"),
readFile(path.join(landing, "api/ai.json"), "utf8").then(JSON.parse),
readFile(path.join(root, "web/public/motion-logo.png")),
readFile(path.join(landing, "assets/motion-logo.png")),
]);

assert.match(html, /<link rel="canonical" href="https:\/\/motion\.significanthobbies\.com\/"/);
assert.match(
html,
/<link rel="canonical" href="https:\/\/motion\.significanthobbies\.com\/"/
);
assert.match(html, /Your body is the controller/);
assert.match(html, /this website is not the game/i);
assert.match(html, /Camera frames are not transmitted or stored/);
Expand All @@ -62,20 +64,42 @@ assert.match(redirects, /^\/api\/ai \/api\/ai\.json 200/m);
assert.match(html, /href=["']\/changelog["']/);
assert.match(
changelog,
/<link\s+rel="canonical"\s+href="https:\/\/motion\.significanthobbies\.com\/changelog"/,
/<link\s+rel="canonical"\s+href="https:\/\/motion\.significanthobbies\.com\/changelog"/
);
assert.match(changelog, /<title>Changelog — Motion<\/title>/);
assert.equal((changelog.match(/<h1(?:\s|>)/g) ?? []).length, 1);
assert.equal((changelog.match(/<time datetime="\d{4}-\d{2}-\d{2}">/g) ?? []).length, 3);
assert.match(changelog, /https:\/\/github\.com\/Significant-Hobbies\/motion\/issues/);
assert.equal(
(changelog.match(/<time datetime="\d{4}-\d{2}-\d{2}">/g) ?? []).length,
3
);
assert.match(
changelog,
/https:\/\/github\.com\/Significant-Hobbies\/motion\/issues/
);
assert.match(changelog, /https:\/\/github\.com\/Significant-Hobbies\/motion/);
assert.match(sitemap, /https:\/\/motion\.significanthobbies\.com\/changelog/);
assert.match(privacy, /Camera frames are not transmitted to us or stored by us/);
assert.match(privacy, /href="https:\/\/github\.com\/Significant-Hobbies\/motion\/issues"/);
assert.match(
privacy,
/Camera frames are not transmitted to us or stored by us/
);
assert.match(
privacy,
/href="https:\/\/github\.com\/Significant-Hobbies\/motion\/issues"/
);
assert.match(sitemap, /https:\/\/motion\.significanthobbies\.com\/privacy/);

for (const text of [html, changelog, privacy, llms, markdown, JSON.stringify(ai)]) {
assert.doesNotMatch(text, /href=["']\/play|debug=1|camera=1|room=|127\.0\.0\.1|localhost/i);
for (const text of [
html,
changelog,
privacy,
llms,
markdown,
JSON.stringify(ai),
]) {
assert.doesNotMatch(
text,
/href=["']\/play|debug=1|camera=1|room=|127\.0\.0\.1|localhost/i
);
}

assert.equal(ai.canonicalUrl, "https://motion.significanthobbies.com/");
Expand All @@ -84,10 +108,16 @@ assert.equal(ai.privacy.cameraFramesTransmitted, false);
assert.equal(ai.privacy.publicSiteRequestsCamera, false);
assert.equal(
ai.agentEntrypoints.changelog,
"https://motion.significanthobbies.com/changelog",
"https://motion.significanthobbies.com/changelog"
);

const digest = (buffer) => createHash("sha256").update(buffer).digest("hex");
assert.equal(digest(sourceLogo), digest(publicLogo), "public logo must remain unchanged");
assert.equal(
digest(sourceLogo),
digest(publicLogo),
"public logo must remain unchanged"
);

console.log(`Motion landing: ${required.length} required files and public boundaries verified`);
console.log(
`Motion landing: ${required.length} required files and public boundaries verified`
);
70 changes: 51 additions & 19 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default class MotionServer implements Party.Server {
msg: AnyMessage,
raw: string,
conn: Party.Connection<ConnState>,
role: Role,
role: Role
) {
switch (msg.type) {
case "join":
Expand All @@ -112,7 +112,11 @@ export default class MotionServer implements Party.Server {

case "ping":
// Answer directly with a pong echoing `t`. Never relayed to the peer.
this.sendTo(conn, { v: PROTOCOL_VERSION, type: "pong", t: msg.t } as PongMessage);
this.sendTo(conn, {
v: PROTOCOL_VERSION,
type: "pong",
t: msg.t,
} as PongMessage);
return;

case "pong":
Expand All @@ -121,11 +125,7 @@ export default class MotionServer implements Party.Server {

case "pose":
// Controller → display only. Validate, rate-limit, then relay.
if (role !== "controller") return;
if (!isPosePacket(msg)) return; // drop junk silently.
if (!this.allowPose(conn.id)) return; // over budget → silent drop.
this.relayTo("display", raw);
this.debugPose(raw); // throttled dev log so we can watch the stream server-side.
this.routePose(msg, raw, conn, role);
return;

case "status":
Expand Down Expand Up @@ -162,22 +162,40 @@ export default class MotionServer implements Party.Server {
}
}

/** Validate, rate-limit, and relay a pose packet from the controller. */
private routePose(
msg: AnyMessage,
raw: string,
conn: Party.Connection<ConnState>,
role: Role
) {
if (role !== "controller") return;
if (!isPosePacket(msg)) return; // drop junk silently.
if (!this.allowPose(conn.id)) return; // over budget → silent drop.
this.relayTo("display", raw);
this.debugPose(raw); // throttled dev log so we can watch the stream server-side.
}

// ── Join handshake + role enforcement ────────────────────────────────────────

private handleJoin(msg: JoinMessage, conn: Party.Connection<ConnState>) {
if (msg.v !== PROTOCOL_VERSION) {
this.sendError(
conn,
"version_mismatch",
`Server speaks protocol v${PROTOCOL_VERSION}, client sent v${String(msg.v)}.`,
`Server speaks protocol v${PROTOCOL_VERSION}, client sent v${String(msg.v)}.`
);
conn.close();
return;
}

const role = msg.role;
if (role !== "display" && role !== "controller") {
this.sendError(conn, "bad_role", "Role must be 'display' or 'controller'.");
this.sendError(
conn,
"bad_role",
"Role must be 'display' or 'controller'."
);
conn.close();
return;
}
Expand All @@ -190,19 +208,29 @@ export default class MotionServer implements Party.Server {
// don't endlessly evict each other.
if (this.roleTaken(role, conn.id)) {
if (role === "controller") {
console.log(`[${this.room.id}] controller reconnect — evicting stale controller`);
console.log(
`[${this.room.id}] controller reconnect — evicting stale controller`
);
this.evictRole("controller", conn.id);
} else {
console.log(`[${this.room.id}] REJECTED ${role} (room_full — a ${role} is already here)`);
this.sendError(conn, "room_full", `A ${role} is already connected to this room.`);
console.log(
`[${this.room.id}] REJECTED ${role} (room_full — a ${role} is already here)`
);
this.sendError(
conn,
"room_full",
`A ${role} is already connected to this room.`
);
conn.close();
return;
}
}

// Tag the connection with its role — this is what marks it "joined".
conn.setState({ role });
console.log(`[${this.room.id}] JOINED ${role} (conns now: ${[...this.room.getConnections()].length})`);
console.log(
`[${this.room.id}] JOINED ${role} (conns now: ${[...this.room.getConnections()].length})`
);

// Broadcast authoritative presence to BOTH roles. Every side gets the TRUE current
// state (not an incremental event that can be missed during reconnect churn), so the
Expand All @@ -226,8 +254,10 @@ export default class MotionServer implements Party.Server {
for (const c of this.room.getConnections<ConnState>()) {
if (c.id === excludeId) continue;
const r = c.state?.role;
if (r === "display") this.safeSend(c, JSON.stringify(peerMsg("controller", hasController)));
else if (r === "controller") this.safeSend(c, JSON.stringify(peerMsg("display", hasDisplay)));
if (r === "display")
this.safeSend(c, JSON.stringify(peerMsg("controller", hasController)));
else if (r === "controller")
this.safeSend(c, JSON.stringify(peerMsg("display", hasDisplay)));
}
}

Expand All @@ -241,9 +271,10 @@ export default class MotionServer implements Party.Server {
// Cheap field-presence check tells us if the phone is on the NEW build:
// new build streams fingertips + elbows; old build has neither.
const fingertips = raw.includes('"fingertips"');
const elbows = raw.includes('"leftElbow"') || raw.includes('"rightElbow"');
const elbows =
raw.includes('"leftElbow"') || raw.includes('"rightElbow"');
console.log(
`[${this.room.id}] pose #${this.poseCount} — display:${hasDisplay} fingertips:${fingertips} elbows:${elbows}`,
`[${this.room.id}] pose #${this.poseCount} — display:${hasDisplay} fingertips:${fingertips} elbows:${elbows}`
);
}
}
Expand All @@ -261,7 +292,8 @@ export default class MotionServer implements Party.Server {
private evictRole(role: Role, exceptId: string) {
for (const c of this.room.getConnections<ConnState>()) {
if (c.id === exceptId) continue;
if (c.state?.role === role) c.close(1000, "replaced by a newer connection");
if (c.state?.role === role)
c.close(1000, "replaced by a newer connection");
}
}

Expand Down Expand Up @@ -295,7 +327,7 @@ export default class MotionServer implements Party.Server {
private sendError(
conn: Party.Connection<ConnState>,
code: ErrorMessage["code"],
message: string,
message: string
) {
this.sendTo(conn, { v: PROTOCOL_VERSION, type: "error", code, message });
}
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ function pickGame(): { game: Game; live: boolean } {
return { game: new Slice(), live: true };
default:
// Featured everywhere the phone hosts it or the MOTION mirror is on.
if (MOTION_MAKER || TRANSPORT === "bridge") return { game: new Slice(), live: true };
if (MOTION_MAKER || TRANSPORT === "bridge")
return { game: new Slice(), live: true };
return { game: new ReachDodge(), live: false };
}
}
Expand Down
10 changes: 7 additions & 3 deletions web/src/app/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export class Overlay implements SessionScreenRenderer {
pairing(ctx: PairingCtx): void {
const { code, conn, host, debug, recordOn, onToggleRecord } = ctx;
const dot =
conn === "open" ? "#35e0c8" : conn === "connecting" ? "#ffcc33" : "#ff4d6d";
conn === "open"
? "#35e0c8"
: conn === "connecting"
? "#ffcc33"
: "#ff4d6d";
const connLabel =
conn === "open"
? "connected to relay"
Expand Down Expand Up @@ -106,7 +110,7 @@ export class Overlay implements SessionScreenRenderer {
const stats = r.stats
.map(
(s) =>
`<div><b style="color:#f4f7ff">${escapeHtml(s.value)}</b><br><span style="color:#8a95b5">${escapeHtml(s.label)}</span></div>`,
`<div><b style="color:#f4f7ff">${escapeHtml(s.value)}</b><br><span style="color:#8a95b5">${escapeHtml(s.label)}</span></div>`
)
.join("");
this.panel(`
Expand All @@ -128,7 +132,7 @@ function spaced(code: string): string {
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;"
);
}

Expand Down
39 changes: 39 additions & 0 deletions web/src/games/canvas-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Shared canvas drawing helpers used by multiple games.
// Extracted to avoid duplication of geometry and path utilities.

/** Shortest distance from point (px,py) to the segment (ax,ay)–(bx,by). */
export function distPointToSegment(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number
): number {
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = t < 0 ? 0 : t > 1 ? 1 : t;
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}

/** Stroke a rounded rectangle path on the given 2D context. */
export function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
radius: number
): void {
const rr = Math.min(radius, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
Loading
Loading