openvibers/openvibe.games Commits
Games: the editor E2E runs in CI and is green again (metrics summed per room; paint checks stroke a visible point) (#62)
Changes
@@@ -17,9 +17,29 @@ jobs:extra: |(cd apps/server && npx openvibe-contracts-check --service games --src src)pnpm --filter @openvibe/client build-- # Initial JS is enforced; the 400 KB chunk limit is reported until instancedMesh.pure (2.08 MB) and rapier-- # (4.37 MB, lazy) are split. Drop --chunks=report then.++ # Initial JS is enforced; the 400 KB chunk limit is reported until Babylon's core (instancedMesh.pure, 2.09 MB)++ # is split (rapier is a wasm file since #61). Drop --chunks=report then.node --import tsx apps/client/scripts/bundleBudget.ts --chunks=report++ # The editor's browser acceptance suites (ADR-0007 M6: editor E2E in CI). The real server and the built client in++ # Chromium: pnpm test:editor (one editor, every tool, live saves reaching the server) and pnpm test:collab (two++ # editors on one map). They ran only by hand before, and 15 checks had quietly gone red; this keeps them green.++ editor-e2e:++ runs-on: ubuntu-latest++ timeout-minutes: 30++ permissions:++ contents: read++ steps:++ - uses: actions/checkout@v4++ - uses: pnpm/action-setup@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1++ cache: pnpm++ - run: pnpm install --frozen-lockfile++ - run: pnpm exec playwright install --with-deps chromium++ - run: pnpm --filter @openvibe/client build++ - run: pnpm test:editor++ - run: pnpm test:collab# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@66b76f868ea8228aad7811309eafef3726b036f3@@@ -273,6 +273,31 @@ const screenOf = (id: string): Promise<[number, number]> =>return w.__editor.worldToScreen(t.position)}, id)++/**++ * A screen point on `id` where a brush stroke reaches the canvas. An object's centre can sit under the brush settings++ * bar the paint and sculpt tools open along the viewport's bottom edge, and a stroke there lands on the bar, not the++ * canvas. So walk up from the centre until the point and a ±20 px box around it (the strokes wander that far) are all++ * the canvas and all pick `id`.++ */++const paintPointOf = async (id: string): Promise<[number, number]> => {++ const [cx, cy] = await screenOf(id)++ for (let up = 0; up <= 300; up += 10) {++ const clear = await page.evaluate(++ ([x, y, oid]) => {++ const probeApi = (window as never as { __editor: { pickIdAt: (px: number, py: number) => string | null } }).__editor++ const box: [number, number][] = [[0, 0], [-20, -20], [20, -20], [-20, 20], [20, 20]]++ return box.every(([dx, dy]) => {++ const el = document.elementFromPoint(x + dx, y + dy)++ return el?.tagName === 'CANVAS' && probeApi.pickIdAt(x + dx, y + dy) === oid++ })++ },++ [cx, cy - up, id] as [number, number, string],++ )++ if (clear) return [cx, cy - up]++ }++ throw new Error(`no visible point of ${id} to paint`)++}++/** transformOf across the process boundary (closures do not transfer). */const xf = (id: string): Promise<{ position: number[]; rotation: number[] | null }> =>page.evaluate(([p, oid]) => (p as Probe).transformOf(oid as string)!, [@@@ -624,7 +649,7 @@ if (terrains.length > 0) {await page.waitForTimeout(200)await ev(page, (p) => p.setPaintTexture('wood_planks'))await page.waitForTimeout(200)-- const [px, py] = await screenOf(tid)++ const [px, py] = await paintPointOf(tid)await page.mouse.move(px, py)await page.mouse.down()for (let i = 0; i < 5; i++) {@@@ -330,18 +330,21 @@ export class GameServer {// 14. Metrics sampling.ctx.metrics.tick = ctx.clock.tickctx.metrics.lastTickAt = Date.now()-- ctx.metrics.entities = ctx.world.entities.size-- ctx.metrics.constraints = ctx.world.constraintCount-- ctx.metrics.constraintIslands = ctx.world.islands.islandCount()-- ctx.metrics.activeRegions = ctx.regions.activeRegionCount-- ctx.metrics.occupiedRegions = ctx.regions.occupiedRegionCount++ // This room's world gauges; the process-wide fields are the sum over every place and room it hosts.const [npcFull, npcAbstract] = s.npcs.counts()-- ctx.metrics.npcsFull = npcFull-- ctx.metrics.npcsAbstract = npcAbstract-- ctx.metrics.mapStatics = ctx.world.mapStaticCount()-- ctx.metrics.mapTerrains = ctx.world.mapTerrainCount()-- ctx.metrics.mapZones = ctx.world.mapZoneCount()-- ctx.metrics.mapRebuilds = ctx.world.mapRebuildCount()++ ctx.metrics.sampleRoom(`${ctx.config.placeId}:${ctx.config.instanceSlot ?? 0}`, {++ entities: ctx.world.entities.size,++ constraints: ctx.world.constraintCount,++ constraintIslands: ctx.world.islands.islandCount(),++ activeRegions: ctx.regions.activeRegionCount,++ occupiedRegions: ctx.regions.occupiedRegionCount,++ npcsFull: npcFull,++ npcsAbstract: npcAbstract,++ mapStatics: ctx.world.mapStaticCount(),++ mapTerrains: ctx.world.mapTerrainCount(),++ mapZones: ctx.world.mapZoneCount(),++ mapRebuilds: ctx.world.mapRebuildCount(),++ })ctx.metrics.recordTick(performance.now() - tickStart, s.physics.physicsMs)}}nnew file mode 100644@@@ -0,0 +1,41 @@++import { describe, expect, it } from 'vitest'++import { ServerMetrics, type RoomGauges } from './metrics.js'++++const gauges = (over: Partial<RoomGauges> = {}): RoomGauges => ({++ entities: 0,++ constraints: 0,++ constraintIslands: 0,++ activeRegions: 0,++ occupiedRegions: 0,++ npcsFull: 0,++ npcsAbstract: 0,++ mapStatics: 0,++ mapTerrains: 0,++ mapZones: 0,++ mapRebuilds: 0,++ ...over,++})++++describe('ServerMetrics room gauges', () => {++ it('sums every room instead of letting the last room to tick win', () => {++ const m = new ServerMetrics()++ // Scraplandia's room holds the saved static; Salvage Rush's room (same process) has none and ticks after it.++ m.sampleRoom('scraplandia:0', gauges({ mapStatics: 1, entities: 4 }), 1_000)++ m.sampleRoom('salvage-rush:0', gauges({ mapStatics: 0, entities: 2 }), 1_010)++ expect(m.mapStatics).toBe(1)++ expect(m.entities).toBe(6)++ expect(m.snapshot()['rooms']).toBe(2)++ // A room's next sample replaces its own, never another room's.++ m.sampleRoom('scraplandia:0', gauges({ mapStatics: 2, entities: 4 }), 1_050)++ expect(m.mapStatics).toBe(2)++ expect(m.snapshot()['mapStatics']).toBe(2)++ })++++ it('drops a room that stopped ticking', () => {++ const m = new ServerMetrics()++ m.sampleRoom('scraplandia:1', gauges({ mapZones: 3 }), 0)++ m.sampleRoom('scraplandia:0', gauges({ mapZones: 1 }), 10_000)++ expect(m.mapZones).toBe(1)++ expect(m.roomCount).toBe(1)++ })++})@@@ -1,9 +1,35 @@import type { Logger } from '@openvibe/shared'++/** One room's world gauges, sampled by its tick loop (GameServer). */++export interface RoomGauges {++ entities: number++ constraints: number++ constraintIslands: number++ activeRegions: number++ occupiedRegions: number++ npcsFull: number++ npcsAbstract: number++ mapStatics: number++ mapTerrains: number++ mapZones: number++ mapRebuilds: number++}++const ROOM_GAUGES: readonly (keyof RoomGauges)[] = [++ 'entities', 'constraints', 'constraintIslands', 'activeRegions', 'occupiedRegions',++ 'npcsFull', 'npcsAbstract', 'mapStatics', 'mapTerrains', 'mapZones', 'mapRebuilds',++]++/** A room that has not ticked for this long (its lease was lost, it was disposed) no longer counts. */++const ROOM_STALE_MS = 5_000++/*** Development observability: cheap counters sampled by the tick loop,* exposed as JSON on /metrics and summarized to the structured log* periodically. A real metrics backend can consume the same object later.++ *++ * One process hosts several places and rooms, all sharing this object. The world gauges (entities, regions, NPCs,++ * map layers, …) are each room's own: every room reports them through sampleRoom() and the fields hold the sum over++ * the rooms that ticked recently. (They used to be plain fields each room overwrote, so the last room to tick won:++ * Salvage Rush's empty map hid every static saved to Scraplandia's.)*/export class ServerMetrics {tick = 0@@@ -49,6 +75,26 @@ export class ServerMetrics {scriptModFailures = 0private emaAlpha = 0.05++ private readonly rooms = new Map<string, { at: number; gauges: RoomGauges }>()++++ /** One room's world gauges this tick (`room` is "<place>:<slot>"); the gauge fields become the sum over rooms. */++ sampleRoom(room: string, gauges: RoomGauges, now = Date.now()): void {++ this.rooms.set(room, { at: now, gauges })++ const totals = Object.fromEntries(ROOM_GAUGES.map((k) => [k, 0])) as Record<keyof RoomGauges, number>++ for (const [key, r] of this.rooms) {++ if (now - r.at > ROOM_STALE_MS) {++ this.rooms.delete(key)++ continue++ }++ for (const k of ROOM_GAUGES) totals[k] += r.gauges[k]++ }++ for (const k of ROOM_GAUGES) this[k] = totals[k]++ }++++ /** How many rooms reported recently. */++ get roomCount(): number {++ return this.rooms.size++ }recordTick(totalMs: number, physicsMs: number): void {this.tickDurationMs += (totalMs - this.tickDurationMs) * this.emaAlpha@@@ -85,6 +131,7 @@ export class ServerMetrics {scriptModsRunning: this.scriptModsRunning,scriptModsDisabled: this.scriptModsDisabled,scriptModFailures: this.scriptModFailures,++ rooms: this.rooms.size,memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),}}@@@ -146,6 +146,14 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegacstreams (1.18 MB gzip) instead of a 4.37 MB JavaScript chunk of base64 (1.65 MB gzip). Node keeps the compat build.Still over the 400 KB per-chunk line: Babylon's core (one 2.09 MB chunk: engines, materials, meshes, maths). A splitby Babylon folder was tried and refused: circular chunks, and it pulled the lazily loaded shaders into the initial-- graph (3.97 MB). Left for M6: that chunk, KTX2 and atlases, baked icons, editor E2E in CI.++ graph (3.97 MB). Left for M6: that chunk, KTX2 and atlases, baked icons.++ *2026-10-14, later:* the editor E2E runs in CI (`editor-e2e` job: `pnpm test:editor` and `pnpm test:collab` in++ Chromium against the real server and the built client). Run by hand only until now, 15 of its checks had gone red:++ - the paint checks stroked the terrain's centre, which the bottom dock had pushed under the brush settings bar (the++ stroke landed on the bar); the suite now paints a point of the object the canvas actually shows;++ - a real server bug: the world gauges on `/metrics` were last-writer-wins across the places and rooms one process++ hosts, so Salvage Rush's empty map hid every static saved to Scraplandia's. `ServerMetrics.sampleRoom` sums them++ per room (a room that stops ticking drops out after 5 s).++ Editor 137/137, collaboration 26/26.Each milestone is playable end to end and keeps the slice harness green.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/observability/metrics.test.ts'++// covers: 'apps/server/src/observability'++// Generated by node test/run.js --index; do not edit.
@@ -17,9 +17,29 @@ jobs:
@@ -17,9 +17,29 @@ jobs:
extra: |
extra: |
(cd apps/server && npx openvibe-contracts-check --service games --src src)
(cd apps/server && npx openvibe-contracts-check --service games --src src)
pnpm --filter @openvibe/client build
pnpm --filter @openvibe/client build
- # Initial JS is enforced; the 400 KB chunk limit is reported until instancedMesh.pure (2.08 MB) and rapier
- # (4.37 MB, lazy) are split. Drop --chunks=report then.
+ # Initial JS is enforced; the 400 KB chunk limit is reported until Babylon's core (instancedMesh.pure, 2.09 MB)
+ # is split (rapier is a wasm file since #61). Drop --chunks=report then.
node --import tsx apps/client/scripts/bundleBudget.ts --chunks=report
node --import tsx apps/client/scripts/bundleBudget.ts --chunks=report
+ # The editor's browser acceptance suites (ADR-0007 M6: editor E2E in CI). The real server and the built client in
+ # Chromium: pnpm test:editor (one editor, every tool, live saves reaching the server) and pnpm test:collab (two
+ # editors on one map). They ran only by hand before, and 15 checks had quietly gone red; this keeps them green.
+ editor-e2e:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm --filter @openvibe/client build
+ - run: pnpm test:editor
+ - run: pnpm test:collab
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@66b76f868ea8228aad7811309eafef3726b036f3
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@66b76f868ea8228aad7811309eafef3726b036f3
@@ -273,6 +273,31 @@ const screenOf = (id: string): Promise<[number, number]> =>
@@ -273,6 +273,31 @@ const screenOf = (id: string): Promise<[number, number]> =>
return w.__editor.worldToScreen(t.position)
return w.__editor.worldToScreen(t.position)
}, id)
}, id)
+/**
+ * A screen point on `id` where a brush stroke reaches the canvas. An object's centre can sit under the brush settings
+ * bar the paint and sculpt tools open along the viewport's bottom edge, and a stroke there lands on the bar, not the
+ * canvas. So walk up from the centre until the point and a ±20 px box around it (the strokes wander that far) are all
+ * the canvas and all pick `id`.
+ */
+const paintPointOf = async (id: string): Promise<[number, number]> => {
+ const [cx, cy] = await screenOf(id)
+ for (let up = 0; up <= 300; up += 10) {
+ const clear = await page.evaluate(
+ ([x, y, oid]) => {
+ const probeApi = (window as never as { __editor: { pickIdAt: (px: number, py: number) => string | null } }).__editor
+ const box: [number, number][] = [[0, 0], [-20, -20], [20, -20], [-20, 20], [20, 20]]
+ return box.every(([dx, dy]) => {
+ const el = document.elementFromPoint(x + dx, y + dy)
+ return el?.tagName === 'CANVAS' && probeApi.pickIdAt(x + dx, y + dy) === oid
+ })
+ },
+ [cx, cy - up, id] as [number, number, string],
+ )
+ if (clear) return [cx, cy - up]
+ }
+ throw new Error(`no visible point of ${id} to paint`)
+}
+
/** transformOf across the process boundary (closures do not transfer). */
/** transformOf across the process boundary (closures do not transfer). */
const xf = (id: string): Promise<{ position: number[]; rotation: number[] | null }> =>
const xf = (id: string): Promise<{ position: number[]; rotation: number[] | null }> =>
page.evaluate(([p, oid]) => (p as Probe).transformOf(oid as string)!, [
page.evaluate(([p, oid]) => (p as Probe).transformOf(oid as string)!, [
@@ -624,7 +649,7 @@ if (terrains.length > 0) {
@@ -624,7 +649,7 @@ if (terrains.length > 0) {
await page.waitForTimeout(200)
await page.waitForTimeout(200)
await ev(page, (p) => p.setPaintTexture('wood_planks'))
await ev(page, (p) => p.setPaintTexture('wood_planks'))
await page.waitForTimeout(200)
await page.waitForTimeout(200)
- const [px, py] = await screenOf(tid)
+ const [px, py] = await paintPointOf(tid)
await page.mouse.move(px, py)
await page.mouse.move(px, py)
await page.mouse.down()
await page.mouse.down()
for (let i = 0; i < 5; i++) {
for (let i = 0; i < 5; i++) {
@@ -330,18 +330,21 @@ export class GameServer {
@@ -330,18 +330,21 @@ export class GameServer {
// 14. Metrics sampling.
// 14. Metrics sampling.
ctx.metrics.tick = ctx.clock.tick
ctx.metrics.tick = ctx.clock.tick
ctx.metrics.lastTickAt = Date.now()
ctx.metrics.lastTickAt = Date.now()
- ctx.metrics.entities = ctx.world.entities.size
- ctx.metrics.constraints = ctx.world.constraintCount
- ctx.metrics.constraintIslands = ctx.world.islands.islandCount()
- ctx.metrics.activeRegions = ctx.regions.activeRegionCount
- ctx.metrics.occupiedRegions = ctx.regions.occupiedRegionCount
+ // This room's world gauges; the process-wide fields are the sum over every place and room it hosts.
const [npcFull, npcAbstract] = s.npcs.counts()
const [npcFull, npcAbstract] = s.npcs.counts()
- ctx.metrics.npcsFull = npcFull
- ctx.metrics.npcsAbstract = npcAbstract
- ctx.metrics.mapStatics = ctx.world.mapStaticCount()
- ctx.metrics.mapTerrains = ctx.world.mapTerrainCount()
- ctx.metrics.mapZones = ctx.world.mapZoneCount()
- ctx.metrics.mapRebuilds = ctx.world.mapRebuildCount()
+ ctx.metrics.sampleRoom(`${ctx.config.placeId}:${ctx.config.instanceSlot ?? 0}`, {
+ entities: ctx.world.entities.size,
+ constraints: ctx.world.constraintCount,
+ constraintIslands: ctx.world.islands.islandCount(),
+ activeRegions: ctx.regions.activeRegionCount,
+ occupiedRegions: ctx.regions.occupiedRegionCount,
+ npcsFull: npcFull,
+ npcsAbstract: npcAbstract,
+ mapStatics: ctx.world.mapStaticCount(),
+ mapTerrains: ctx.world.mapTerrainCount(),
+ mapZones: ctx.world.mapZoneCount(),
+ mapRebuilds: ctx.world.mapRebuildCount(),
+ })
ctx.metrics.recordTick(performance.now() - tickStart, s.physics.physicsMs)
ctx.metrics.recordTick(performance.now() - tickStart, s.physics.physicsMs)
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { ServerMetrics, type RoomGauges } from './metrics.js'
+
+const gauges = (over: Partial<RoomGauges> = {}): RoomGauges => ({
+ entities: 0,
+ constraints: 0,
+ constraintIslands: 0,
+ activeRegions: 0,
+ occupiedRegions: 0,
+ npcsFull: 0,
+ npcsAbstract: 0,
+ mapStatics: 0,
+ mapTerrains: 0,
+ mapZones: 0,
+ mapRebuilds: 0,
+ ...over,
+})
+
+describe('ServerMetrics room gauges', () => {
+ it('sums every room instead of letting the last room to tick win', () => {
+ const m = new ServerMetrics()
+ // Scraplandia's room holds the saved static; Salvage Rush's room (same process) has none and ticks after it.
+ m.sampleRoom('scraplandia:0', gauges({ mapStatics: 1, entities: 4 }), 1_000)
+ m.sampleRoom('salvage-rush:0', gauges({ mapStatics: 0, entities: 2 }), 1_010)
+ expect(m.mapStatics).toBe(1)
+ expect(m.entities).toBe(6)
+ expect(m.snapshot()['rooms']).toBe(2)
+ // A room's next sample replaces its own, never another room's.
+ m.sampleRoom('scraplandia:0', gauges({ mapStatics: 2, entities: 4 }), 1_050)
+ expect(m.mapStatics).toBe(2)
+ expect(m.snapshot()['mapStatics']).toBe(2)
+ })
+
+ it('drops a room that stopped ticking', () => {
+ const m = new ServerMetrics()
+ m.sampleRoom('scraplandia:1', gauges({ mapZones: 3 }), 0)
+ m.sampleRoom('scraplandia:0', gauges({ mapZones: 1 }), 10_000)
+ expect(m.mapZones).toBe(1)
+ expect(m.roomCount).toBe(1)
+ })
+})
@@ -1,9 +1,35 @@
@@ -1,9 +1,35 @@
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
+/** One room's world gauges, sampled by its tick loop (GameServer). */
+export interface RoomGauges {
+ entities: number
+ constraints: number
+ constraintIslands: number
+ activeRegions: number
+ occupiedRegions: number
+ npcsFull: number
+ npcsAbstract: number
+ mapStatics: number
+ mapTerrains: number
+ mapZones: number
+ mapRebuilds: number
+}
+const ROOM_GAUGES: readonly (keyof RoomGauges)[] = [
+ 'entities', 'constraints', 'constraintIslands', 'activeRegions', 'occupiedRegions',
+ 'npcsFull', 'npcsAbstract', 'mapStatics', 'mapTerrains', 'mapZones', 'mapRebuilds',
+]
+/** A room that has not ticked for this long (its lease was lost, it was disposed) no longer counts. */
+const ROOM_STALE_MS = 5_000
+
/**
/**
* Development observability: cheap counters sampled by the tick loop,
* Development observability: cheap counters sampled by the tick loop,
* exposed as JSON on /metrics and summarized to the structured log
* exposed as JSON on /metrics and summarized to the structured log
* periodically. A real metrics backend can consume the same object later.
* periodically. A real metrics backend can consume the same object later.
+ *
+ * One process hosts several places and rooms, all sharing this object. The world gauges (entities, regions, NPCs,
+ * map layers, …) are each room's own: every room reports them through sampleRoom() and the fields hold the sum over
+ * the rooms that ticked recently. (They used to be plain fields each room overwrote, so the last room to tick won:
+ * Salvage Rush's empty map hid every static saved to Scraplandia's.)
*/
*/
export class ServerMetrics {
export class ServerMetrics {
tick = 0
tick = 0
@@ -49,6 +75,26 @@ export class ServerMetrics {
@@ -49,6 +75,26 @@ export class ServerMetrics {
scriptModFailures = 0
scriptModFailures = 0
private emaAlpha = 0.05
private emaAlpha = 0.05
+ private readonly rooms = new Map<string, { at: number; gauges: RoomGauges }>()
+
+ /** One room's world gauges this tick (`room` is "<place>:<slot>"); the gauge fields become the sum over rooms. */
+ sampleRoom(room: string, gauges: RoomGauges, now = Date.now()): void {
+ this.rooms.set(room, { at: now, gauges })
+ const totals = Object.fromEntries(ROOM_GAUGES.map((k) => [k, 0])) as Record<keyof RoomGauges, number>
+ for (const [key, r] of this.rooms) {
+ if (now - r.at > ROOM_STALE_MS) {
+ this.rooms.delete(key)
+ continue
+ }
+ for (const k of ROOM_GAUGES) totals[k] += r.gauges[k]
+ }
+ for (const k of ROOM_GAUGES) this[k] = totals[k]
+ }
+
+ /** How many rooms reported recently. */
+ get roomCount(): number {
+ return this.rooms.size
+ }
recordTick(totalMs: number, physicsMs: number): void {
recordTick(totalMs: number, physicsMs: number): void {
this.tickDurationMs += (totalMs - this.tickDurationMs) * this.emaAlpha
this.tickDurationMs += (totalMs - this.tickDurationMs) * this.emaAlpha
@@ -85,6 +131,7 @@ export class ServerMetrics {
@@ -85,6 +131,7 @@ export class ServerMetrics {
scriptModsRunning: this.scriptModsRunning,
scriptModsRunning: this.scriptModsRunning,
scriptModsDisabled: this.scriptModsDisabled,
scriptModsDisabled: this.scriptModsDisabled,
scriptModFailures: this.scriptModFailures,
scriptModFailures: this.scriptModFailures,
+ rooms: this.rooms.size,
memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),
memRssMb: round2(process.memoryUsage.rss() / 1024 / 1024),
}
}
}
}
@@ -146,6 +146,14 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -146,6 +146,14 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
streams (1.18 MB gzip) instead of a 4.37 MB JavaScript chunk of base64 (1.65 MB gzip). Node keeps the compat build.
streams (1.18 MB gzip) instead of a 4.37 MB JavaScript chunk of base64 (1.65 MB gzip). Node keeps the compat build.
Still over the 400 KB per-chunk line: Babylon's core (one 2.09 MB chunk: engines, materials, meshes, maths). A split
Still over the 400 KB per-chunk line: Babylon's core (one 2.09 MB chunk: engines, materials, meshes, maths). A split
by Babylon folder was tried and refused: circular chunks, and it pulled the lazily loaded shaders into the initial
by Babylon folder was tried and refused: circular chunks, and it pulled the lazily loaded shaders into the initial
- graph (3.97 MB). Left for M6: that chunk, KTX2 and atlases, baked icons, editor E2E in CI.
+ graph (3.97 MB). Left for M6: that chunk, KTX2 and atlases, baked icons.
+ *2026-10-14, later:* the editor E2E runs in CI (`editor-e2e` job: `pnpm test:editor` and `pnpm test:collab` in
+ Chromium against the real server and the built client). Run by hand only until now, 15 of its checks had gone red:
+ - the paint checks stroked the terrain's centre, which the bottom dock had pushed under the brush settings bar (the
+ stroke landed on the bar); the suite now paints a point of the object the canvas actually shows;
+ - a real server bug: the world gauges on `/metrics` were last-writer-wins across the places and rooms one process
+ hosts, so Salvage Rush's empty map hid every static saved to Scraplandia's. `ServerMetrics.sampleRoom` sums them
+ per room (a room that stops ticking drops out after 5 s).
+ Editor 137/137, collaboration 26/26.
Each milestone is playable end to end and keeps the slice harness green.
Each milestone is playable end to end and keeps the slice harness green.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/observability/metrics.test.ts'
+// covers: 'apps/server/src/observability'
+// Generated by node test/run.js --index; do not edit.