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)

OP OpenVibers committed 30 days ago · d4407a521a · 7 files, +162 −15

ADR-0007 M6, "editor E2E in CI".

`pnpm test:editor` and `pnpm test:collab` drive the real server and the built client in Chromium. Until now they ran only by hand. The status doc's 139/139 dates from 2026-08-17, and 15 checks had gone red since. Both causes are fixed, and the suites now run in CI so they stay green.

**1. A real server bug: `/metrics` world gauges were last-writer-wins.** One process hosts both places (Scraplandia and Salvage Rush) and their rooms, all sharing one `ServerMetrics`. Every room overwrote `entities`, `mapStatics`, `mapTerrains`, `mapZones`, `mapRebuilds`, the NPC and region counts and so on each tick. So Salvage Rush's empty map hid every static saved to Scraplandia, and the live-save checks ("a saved static has collision at once", "a sculpt rebuilds exactly one body", …) read 0.
- `ServerMetrics.sampleRoom("<place>:<slot>", gauges)` keeps each room's own gauges, and the fields are the sum over rooms that ticked in the last 5 s. A room whose lease was lost drops out.
- `/metrics` adds `rooms`.
- Unit test: `metrics.test.ts`.

**2. The paint checks stroked under the brush settings bar.** The bottom dock moved the terrain's centre under the bar that the paint tool opens along the viewport's bottom edge, so the stroke landed on the bar, not the canvas. Painting itself works. `paintPointOf()` now picks a point of the object that the canvas shows: the point and a ±20 px box must all be the canvas and pick that object.

**3. CI.** A new `editor-e2e` job:
- install;
- `playwright install --with-deps chromium`;
- client build;
- `pnpm test:editor`;
- `pnpm test:collab`.

The budget comment is also updated: rapier is a wasm file since #61, so only Babylon's core chunk is still over 400 KB.

**Results (local):**
- editor 137/137;
- collaboration 26/26;
- `pnpm typecheck && pnpm lint && pnpm test` green.

ADR-0007's M6 note is updated.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 1bc30db..51e6b62 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/apps/client/e2e/editorCheck.ts b/apps/client/e2e/editorCheck.ts
iindex ca35e1d..92d0ecc 100644
---- a/apps/client/e2e/editorCheck.ts
++++ b/apps/client/e2e/editorCheck.ts
@@@ -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++) {
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex d04ca76..ab05bba 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -330,18 +330,21 @@ export class GameServer {
// 14. Metrics sampling.
ctx.metrics.tick = ctx.clock.tick
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()
-- 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)
}
}
ddiff --git a/apps/server/src/observability/metrics.test.ts b/apps/server/src/observability/metrics.test.ts
nnew file mode 100644
iindex 0000000..725bb71
---- /dev/null
++++ b/apps/server/src/observability/metrics.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/observability/metrics.ts b/apps/server/src/observability/metrics.ts
iindex 02001b5..c736e55 100644
---- a/apps/server/src/observability/metrics.ts
++++ b/apps/server/src/observability/metrics.ts
@@@ -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 = 0
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 {
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),
}
}
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex 0c495d6..cc2415a 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -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.
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
-- 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.
ddiff --git a/test/apps__server__src__observability__metrics.test.js b/test/apps__server__src__observability__metrics.test.js
nnew file mode 100644
iindex 0000000..1b8d318
---- /dev/null
++++ b/test/apps__server__src__observability__metrics.test.js
@@@ -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.