diff --git a/xbox/live/MARKETING.md b/xbox/live/MARKETING.md index 57b4e67dd1..c0674e1e40 100644 --- a/xbox/live/MARKETING.md +++ b/xbox/live/MARKETING.md @@ -7,9 +7,10 @@ videos and say yes. Code: `xbox/live/marketing/`. Staged output: `tmp/oskiewar-reels/queue/`. Every Reel passes through the Oskiewar Replay Oven -(`marketing/replay-oven.mjs`): deterministic demo playback, no HUD, 60 fps, -JPEG-100 source frames, a CRF-14 H.264 master, and offline-only color/detail -passes. Future ray/depth treatments belong at that seam, never in live play. +(`marketing/replay-oven.mjs`): deterministic demo playback, the game-native +match HUD, 60 fps, JPEG-100 source frames, a CRF-14 H.264 master, and +offline-only color/detail passes. Controls, debug meters, touch UI, and QR stay +out. Future ray/depth treatments belong at that seam, never in live play. Account ledger row: `social/accounts.json` → `oskiewar-ig`. **Nothing posts to Instagram without `--live` and a token in the environment.** diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 1f5c49daa5..7214f9a63f 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -577,6 +577,7 @@ const popsEndpoint = "/api/oskiewar-pops"; const socialPreview = new URLSearchParams(location.search).has("social-preview"); const replayOven = new URLSearchParams(location.search).has("replay-oven"); + const reelHud = new URLSearchParams(location.search).has("reel-hud"); function capabilities() { const mac = navigator.userAgent.includes("Mac"); @@ -589,6 +590,9 @@ // render capability, never a player preference, so HUD-free footage // cannot accidentally leak into the live game. replayOven, + // The Reel lane keeps the match's own timer, names, stats and command + // stream without admitting controls, debug meters, touch UI or QR. + reelHud, country: localCountry, updateReady: updateAvailable, // Scoring sessions are anonymous dummy play; the seam auth attaches to diff --git a/xbox/live/marketing/ledger.json b/xbox/live/marketing/ledger.json index 537d93fc3d..efb31fd74a 100644 --- a/xbox/live/marketing/ledger.json +++ b/xbox/live/marketing/ledger.json @@ -63,6 +63,26 @@ "reels_skip_rate": 74.8 }, "insightsAt": "2026-08-11T02:01:50.075Z" + }, + { + "mode": "live", + "id": "2026-08-10-s0-fgc", + "slot": 663, + "day": "2026-08-10", + "index": 0, + "segment": "fgc", + "seed": "2026-08-10#0", + "kind": "self-play", + "round": "bummu857", + "audioName": "bummu857", + "publishedAt": "2026-08-11T03:55:49.313Z", + "urls": { + "reel": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/oskiewar/reels/2026-08-10-s0-fgc/reel.mp4", + "cover": "https://art-aesthetic-computer.sfo3.digitaloceanspaces.com/oskiewar/reels/2026-08-10-s0-fgc/cover.jpg" + }, + "containerId": "18079508453446357", + "mediaId": "18091100705113166", + "insights": null } ] } diff --git a/xbox/live/marketing/publish.mjs b/xbox/live/marketing/publish.mjs index e7da40dac3..fc8ad8de58 100644 --- a/xbox/live/marketing/publish.mjs +++ b/xbox/live/marketing/publish.mjs @@ -36,6 +36,9 @@ export function appendLedger(entry) { return entry; } +export const audioNameFor = (spec) => + spec.audioName || spec.render?.rounds?.[0]?.round || spec.round || null; + // The container body, exactly as Meta's reference spells it. `share_to_feed` // is on because a reel that only lives in the Reels tab is invisible to the // handful of people who already follow the account. @@ -47,6 +50,10 @@ export function payloadFor(spec, { igUserId, videoUrl, coverUrl }) { share_to_feed: true, }; if (coverUrl) body.cover_url = coverUrl; + // Original audio can only be named once. Bind it to the round captured in + // the reel so Instagram's audio page remains findable by match identity. + const audioName = audioNameFor(spec); + if (audioName) body.audio_name = audioName; return { container: { method: "POST", diff --git a/xbox/live/marketing/publish.test.mjs b/xbox/live/marketing/publish.test.mjs new file mode 100644 index 0000000000..46e67c9dee --- /dev/null +++ b/xbox/live/marketing/publish.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { audioNameFor, payloadFor } from "./publish.mjs"; + +const options = { + igUserId: "123", + videoUrl: "https://example.test/reel.mp4", + coverUrl: "https://example.test/cover.jpg", +}; + +test("a Reel names its original audio after the captured round", () => { + const payload = payloadFor({ + caption: "fight", + render: { rounds: [{ round: "vussi868" }] }, + }, options); + + assert.equal(payload.container.body.audio_name, "vussi868"); + assert.equal(audioNameFor({ + render: { rounds: [{ round: "vussi868" }] }, + }), "vussi868"); +}); + +test("an explicit staged audio name wins and missing names are omitted", () => { + const named = payloadFor({ + caption: "fight", + audioName: "choggy364", + render: { rounds: [{ round: "vussi868" }] }, + }, options); + assert.equal(named.container.body.audio_name, "choggy364"); + + const unnamed = payloadFor({ caption: "fight" }, options); + assert.equal("audio_name" in unnamed.container.body, false); +}); diff --git a/xbox/live/marketing/reel.mjs b/xbox/live/marketing/reel.mjs index 609c4c78ce..46a2df0f6a 100644 --- a/xbox/live/marketing/reel.mjs +++ b/xbox/live/marketing/reel.mjs @@ -22,7 +22,7 @@ import { pickSource, seed32 } from "./source.mjs"; import { bakeReplay } from "./replay-oven.mjs"; import { cover, inspect, thumbnail, writeSidecar } from "./dress.mjs"; import { verifySync } from "./verify.mjs"; -import { appendLedger, dryRun, publishLive, queueDir, readLedger, +import { appendLedger, audioNameFor, dryRun, publishLive, queueDir, readLedger, refreshInsights, reveal, segmentReport, uploadPublic } from "./publish.mjs"; import { repo } from "./shell.mjs"; import { dress, segments, share } from "./segments.mjs"; @@ -70,6 +70,7 @@ async function buildSlot(day, index) { slotsPerDay, cap: Number(flags.cap || 600), allowReplays: flags["no-replays"] !== true, log }); spec.theme = themeNow(); + spec.hud = flags["no-hud"] !== true; // Forcing a market is a review affordance, not part of the grid. The slot // keeps its number so the ledger stays honest about which slot ran. if (flags.segment && spec.segment !== flags.segment) { @@ -113,7 +114,8 @@ async function buildSlot(day, index) { if (!render.complete) log(` ⚠ the match never finished inside the ${spec.cap}s cap — fragment`); - const record = { ...spec, sourceCommit, builtAt: new Date().toISOString(), + const audioName = audioNameFor({ ...spec, render }); + const record = { ...spec, audioName, sourceCommit, builtAt: new Date().toISOString(), render: { wall: render.wall, frames: render.frames, liveFrames: render.liveFrames, frameCadence: render.frameCadence, seconds: render.seconds, hasAudio: render.hasAudio, @@ -125,7 +127,8 @@ async function buildSlot(day, index) { log(`${spec1080.ok ? "✓" : "✗"} ${spec.id} · ${spec1080.width}×${spec1080.height} · ` + `${Math.floor(spec1080.seconds / 60)}m${String(Math.round(spec1080.seconds % 60)) .padStart(2, "0")}s · ${spec1080.megabytes.toFixed(1)}MB · ` + - `${render.rounds.length} rounds · meta spec ${spec1080.ok ? "pass" : "FAIL"}`); + `${render.rounds.length} rounds · audio "${audioName || "unnamed"}" · ` + + `meta spec ${spec1080.ok ? "pass" : "FAIL"}`); if (!spec1080.ok) for (const [name, check] of Object.entries(spec1080.checks)) if (!check.ok) log(` ✗ ${name}: ${check.value}`); if (!motion.ok) log(` ✗ motion: ${motion.mode} · ${motion.sourceFps} source fps`); @@ -145,9 +148,11 @@ async function goLive(record) { const posted = await publishLive(record, urls, { igUserId: process.env.OSKIEWAR_IG_USER_ID, token: process.env.OSKIEWAR_IG_TOKEN, log }); + const audioName = audioNameFor(record); appendLedger({ mode: "live", id: record.id, slot: record.slot, day: record.day, index: record.index, segment: record.segment, seed: record.seed, - kind: record.kind, round: record.round, publishedAt: new Date().toISOString(), + kind: record.kind, round: audioName, audioName, + publishedAt: new Date().toISOString(), urls, ...posted, insights: null }); return posted; } diff --git a/xbox/live/marketing/render.mjs b/xbox/live/marketing/render.mjs index 19a95865eb..349d49a4ce 100644 --- a/xbox/live/marketing/render.mjs +++ b/xbox/live/marketing/render.mjs @@ -73,8 +73,13 @@ async function loadPuppeteer() { return (await import(`${dir}/lib/esm/puppeteer/puppeteer.js`)).default; } +export function offlineReplayAddress(origin, round, { hud = true } = {}) { + return `${origin}/${round}?social-preview&replay-oven&offline-render` + + (hud ? "&reel-hud" : ""); +} + async function captureOfflineReplay({ browser, shell, demo, frames, width, - height, theme, seconds, log }) { + height, theme, seconds, hud, log }) { const round = String(demo?.roundName || demo?.matchName || "").replace(/^ow-/, ""); if (!round) throw new Error("completed bot fight did not return a replay name"); rmSync(frames, { recursive: true, force: true }); @@ -91,7 +96,7 @@ async function captureOfflineReplay({ browser, shell, demo, frames, width, addEventListener() {}, removeEventListener() {} }; }; }); - await page.goto(`${shell.origin}/${round}?social-preview&replay-oven&offline-render`, + await page.goto(offlineReplayAddress(shell.origin, round, { hud }), { waitUntil: "domcontentloaded", timeout: 45000 }); await page.evaluate(() => document.fonts.ready); await page.waitForFunction(() => globalThis.__oskiewarOfflineReady === true && @@ -147,7 +152,7 @@ export async function renderReel(spec, { log = console.log } = {}) { // `cap` is a ceiling, not a target: recording ends when the game says the // match is over, and only runs out the clock if something has gone wrong. const { id, kind, seed, cap = 240, rounds = 1, width = 1080, height = 1920, - theme = "dark", ovenStyle = "cinematic", out } = spec; + theme = "dark", ovenStyle = "cinematic", hud = true, out } = spec; const started = Date.now(); rmSync(out, { recursive: true, force: true }); const frames = join(out, "frames"); @@ -395,7 +400,7 @@ export async function renderReel(spec, { log = console.log } = {}) { if (!replayDemo) throw new Error("completed bot fight replay payload is missing"); const offlineStamps = await captureOfflineReplay({ browser, shell, demo: replayDemo, frames, width, height, theme, - seconds: captured.seconds, log }); + seconds: captured.seconds, hud, log }); captured.liveFrames = captured.frames; captured.frames = offlineStamps.length; captured.frameCadence = "fixed-step-60"; diff --git a/xbox/live/marketing/render.test.mjs b/xbox/live/marketing/render.test.mjs new file mode 100644 index 0000000000..f89f2b5531 --- /dev/null +++ b/xbox/live/marketing/render.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { offlineReplayAddress } from "./render.mjs"; +import { replayOvenProfile } from "./replay-oven.mjs"; + +test("the Replay Oven keeps the match HUD on fixed-step burns", () => { + assert.equal(replayOvenProfile.hud, true); + assert.equal( + offlineReplayAddress("http://oven.test", "tizze50"), + "http://oven.test/tizze50?social-preview&replay-oven&offline-render&reel-hud", + ); +}); + +test("reviewers can still request a world-only burn", () => { + assert.equal( + offlineReplayAddress("http://oven.test", "tizze50", { hud: false }), + "http://oven.test/tizze50?social-preview&replay-oven&offline-render", + ); +}); diff --git a/xbox/live/marketing/replay-oven.mjs b/xbox/live/marketing/replay-oven.mjs index b329922a47..bdb85c39bb 100644 --- a/xbox/live/marketing/replay-oven.mjs +++ b/xbox/live/marketing/replay-oven.mjs @@ -1,4 +1,4 @@ -// Oskiewar Replay Oven — deterministic, HUD-free, high-quality demo burns. +// Oskiewar Replay Oven — deterministic, game-native, high-quality demo burns. // // Keep this seam between simulation capture and distribution. Offline-only // lighting, grading, ray/depth passes, and supersampling belong behind this @@ -11,10 +11,11 @@ export const replayOvenProfile = Object.freeze({ sourceQuality: 92, videoCodec: "h264", crf: 14, - hud: false, + hud: true, offlinePasses: Object.freeze(["fixed-step", "contrast", "color", "detail"]), }); export function bakeReplay(spec, options) { - return renderReel({ ovenStyle: "cinematic", ...spec }, options); + return renderReel({ ovenStyle: "cinematic", hud: replayOvenProfile.hud, + ...spec }, options); } diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index d121a70316..25d296413b 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -261,8 +261,12 @@ const hudSafeRect = () => { right: stageRight - inset, bottom: viewHeight - inset }; }; const actionSafeRect = () => { - const marginX = compactLayout() ? 34 : 64; - const marginY = 26; + // A television is watched across a room behind a bezel; a reel is held at + // arm's length with nothing over its edges, so the action-safe frame there is + // a courtesy rather than a hedge against overscan. + const portrait = stageBottom - stageTop > stageRight - stageLeft; + const marginX = portrait ? 18 : (compactLayout() ? 34 : 64); + const marginY = portrait ? 14 : 26; return { left: stageLeft + marginX, top: stageTop + marginY, right: stageRight - marginX, bottom: stageBottom - marginY }; }; @@ -2467,10 +2471,22 @@ function resetRound(now, resetMatch = false) { resetBalls(now); if (replay) replay.rounds.push([demoTick(now), windDirection, windMph, balls.length]); - cameraCenter = (worldLeft + worldRight) / 2; + // A new round opens on its fighters, not the midpoint of the 25,000-unit + // street. Keeping the stale world midpoint here made the first gameplay + // tick jump thousands of units away from the three-beat portrait intro. + cameraCenter = (players[0].x + players[1].x) / 2; cameraWidth = 960; - cameraCenterY = floorY - cameraWidth / cameraAspect / 2; + cameraCenterY = (players[0].y + players[1].y) / 2 - 90; cameraContainFloor = 0; + const openingHead = runnerWorldGeometry(players[0], + (now - startedAt) / 1000000).head; + const openingWidth = Math.max(96, openingHead.radius * 6.5); + const openingTarget = { x: openingHead.x, y: openingHead.y, z: openingHead.z }; + cameraDoll.snap({ target: openingTarget, + position: { x: openingTarget.x, y: openingTarget.y, + z: openingTarget.z - Math.max(openingWidth * 1.35, + Math.abs(worldNear) + 400) }, + width: openingWidth, perspective: 0, fov: 55, roll: 0 }); } // The box the camera packs. It is a fighting-game pushbox rather than the @@ -2514,18 +2530,33 @@ function fighterFrameRect() { // Rect-pack: the camera width that makes a world rect exactly fill the // action-safe frame. Whichever axis runs out first decides the lens, so a // stacked pair frames as tightly as a spread one. +// Framing is width-driven, which is right for a television and wrong for a +// phone. A 9:16 frame has height to spare and width to spend, so the pair that +// fills a 16:9 shot lands in the middle third of a vertical one with empty sky +// above it and dead ground below — which is exactly how the reels read. +// +// Pull the lens in by how far the frame is from 16:9. A wide stage is untouched +// (the ratio is 1), and the floor stops a very tall frame from closing so far +// that a jump crops. Every framing path runs through rectPackWidth, so applying +// it here keeps the fight, the containment floor and the result shot agreeing +// with each other instead of each needing its own factor. +const portraitPull = () => clamp(cameraAspect / (16 / 9), .52, 1); + function rectPackWidth(rect) { const safe = actionSafeRect(); return Math.max( (rect.right - rect.left) * (stageRight - stageLeft) / Math.max(1, safe.right - safe.left), (rect.bottom - rect.top) * cameraAspect * (stageBottom - stageTop) / - Math.max(1, safe.bottom - safe.top)); + Math.max(1, safe.bottom - safe.top)) * portraitPull(); } // A fighter reduced to a bouncing head is a few dozen units tall. Without a // floor the pack would keep closing until one limb filled the screen. // Lowered with the reach/rise trim above: the closest the lens may sit. -const frameFloorWidth = () => compactLayout() ? 285 : 315; +// The tightest the automatic camera may go. It scales with the same pull, or a +// portrait shot closes to the pull and then hits a floor sized for a television. +const frameFloorWidth = () => + (compactLayout() ? 285 : 315) * portraitPull(); // Terrain is flat color, so it only has to reach as far as the lens can see. // Submitting the whole arena pushed its far corners past the native ±30000 @@ -2643,7 +2674,7 @@ function updateCameraDoll(dt, now) { // fighters rather than one — the 64° lens buys most of that back. Past // about a 1300 gap this would open onto empty stage, so beyond there it // settles toward the span the returning wide shot uses. - const shotWidth = Math.min(headGap * 1.7, headGap + 900); + const shotWidth = Math.min(headGap * 1.7, headGap + 900) * portraitPull(); if (age < .86) { cameraDoll.track({ target: focus, position: shoulder, width: shotWidth, perspective: 0, fov: 55, roll: 0 }, dt, 11); @@ -2653,7 +2684,8 @@ function updateCameraDoll(dt, now) { const midpoint = { x: (players[0].x + players[1].x) / 2, y: (players[0].y + players[1].y) / 2 - 95, z: (players[0].z + players[1].z) / 2 }; - const span = Math.max(900, Math.abs(players[1].x - players[0].x) + 540); + const span = Math.max(900, Math.abs(players[1].x - players[0].x) + 540) * + portraitPull(); cameraDoll.track({ target: midpoint, position: { x: midpoint.x, y: midpoint.y, z: lerp(shoulder.z, midpoint.z - span * 1.2, returnAmount) }, @@ -2666,7 +2698,8 @@ function updateCameraDoll(dt, now) { z: (players[0].z + players[1].z) / 2 }; const horizontalSpan = Math.abs(players[1].x - players[0].x); const verticalSpan = Math.abs(players[1].y - players[0].y) * cameraAspect; - const closeWidth = Math.max(820, horizontalSpan + 540, verticalSpan + 520, + const closeWidth = Math.max( + Math.max(820, horizontalSpan + 540, verticalSpan + 520) * portraitPull(), rectPackWidth(fighterFrameRect()) * 1.22); cameraDoll.track({ target, position: { x: target.x, @@ -2677,16 +2710,54 @@ function updateCameraDoll(dt, now) { return; } if (introAge < introDurationUs) { + // Three seconds used to hold one wide two-shot — the widest the camera ever + // got — and it introduced nobody: two figures the height of a thumbnail + // standing apart. It is three beats now. A second on one fighter's face, a + // whip across to the other's, then out to the shot the fight opens on. + // + // The heads come from the same geometry the killcam frames, so a face shot + // is the head's own radius rather than a guess at how big a fighter is. const age = introAge / 1000000; - const progress = clamp(age / (introDurationUs / 1000000), 0, 1); - const eased = progress * progress * (3 - progress * 2); - const target = { x: (players[0].x + players[1].x) / 2, + const beat = introDurationUs / 1000000 / 3; + const poseTime = (now - startedAt) / 1000000; + const headOf = (player) => + (player.frozenGeometry || runnerWorldGeometry(player, poseTime)).head; + // Enough room around a head to read as a portrait rather than an eyeball. + const faceWidth = (head) => Math.max(96, head.radius * 6.5); + + const opening = { x: (players[0].x + players[1].x) / 2, y: (players[0].y + players[1].y) / 2 - 90, z: (players[0].z + players[1].z) / 2 }; - const span = Math.max(980, Math.abs(players[1].x - players[0].x) + 760); + const openingWidth = Math.max(980, + Math.abs(players[1].x - players[0].x) + 760) * portraitPull(); + + let target; + let width; + if (age < beat * 2) { + // The whip is the ease itself: `track` is still travelling from the first + // face when the second is asked for, so the pan happens for free and at a + // speed the rest of the camera already agrees with. + const head = headOf(players[age < beat ? 0 : 1]); + target = { x: head.x, y: head.y, z: head.z }; + width = faceWidth(head); + } else { + const out = clamp((age - beat * 2) / beat, 0, 1); + const eased = out * out * (3 - out * 2); + const head = headOf(players[1]); + target = { x: lerp(head.x, opening.x, eased), + y: lerp(head.y, opening.y, eased), + z: lerp(head.z, opening.z, eased) }; + width = lerp(faceWidth(head), openingWidth, eased); + } + // Stand off outside the arena depth. The framing is orthographic, so the + // distance costs nothing and `width` alone decides how close the shot + // reads — but a lens parked a face's width from a head sits inside the + // world volume and near-clips the head it came to look at, which is the + // same trap the killcam fell into and had to be pulled back out of. + const standOff = Math.max(width * 1.35, Math.abs(worldNear) + 400); cameraDoll.track({ target, - position: { x: target.x, y: target.y, z: target.z - span * 1.2 }, - width: span, perspective: 0, fov: 55, roll: 0 }, dt, 7); + position: { x: target.x, y: target.y, z: target.z - standOff }, + width, perspective: 0, fov: 55, roll: 0 }, dt, 9); return; } const target = { x: cameraCenter, y: cameraCenterY, z: 0 }; @@ -8719,6 +8790,9 @@ function gamePaint() { visualTheme = displayTheme(); const replayOven = typeof capabilities === "function" && capabilities().replayOven === true; + const reelHud = typeof capabilities === "function" && + capabilities().reelHud === true; + const matchHud = !replayOven || reelHud; triangleDepth = -1.4; const skyDay = mixColor([176, 215, 245], [255, 160, 112], visualTheme.sunset * .7); @@ -8786,7 +8860,7 @@ function gamePaint() { if (WIND_FLAG && shellMode === "GAME") drawWindFlag(t, windInk); // The top row is the round's: a clock, and who is watching. The wordmark // screen carries its own clock, so this one waits for start. - if (!replayOven && shellMode === "GAME" && gameplayStarted) { + if (matchHud && shellMode === "GAME" && gameplayStarted) { const timedRound = roundIsTimed(); const remainingSeconds = roundResult || !timedRound ? 0 : Math.max(0, Math.ceil((roundDurationUs - roundElapsedUs) / 1000000)); @@ -8840,7 +8914,7 @@ function gamePaint() { for (const pickup of gunPickups) drawGunPickup(pickup, t); for (const pickup of grenadePickups) drawGrenadePickup(pickup, t); const introAge = run.monotonicUs - roundStartedAt; - const showRunnerLabels = !replayOven && + const showRunnerLabels = matchHud && (Boolean(roundResult) || introAge >= introDurationUs); const viewDirection = normalize3({ x: cameraDoll.target.x - cameraDoll.position.x, @@ -8893,7 +8967,7 @@ function gamePaint() { const counting = !roundResult && introAge < introDurationUs; // The matchup card announces two names in the middle of the screen, which // is exactly where the wordmark sits. On the entry fight the word wins. - if (!replayOven && counting && shellMode === "GAME") + if (matchHud && counting && shellMode === "GAME") drawFightIntro(introAge / 1000000, titleInk, statusShadow); // The keys belong wherever a newcomer is looking: under the wordmark on the // way in, and again while a round counts itself off. Self-play has no @@ -8903,7 +8977,7 @@ function gamePaint() { // self-play and marketing reels have no learner to serve. if (!replayOven && !selfPlay) drawControlLegend(titleInk); const resultUiReady = cinematicAge < 0 || cinematicAge >= 1.1; - if (!replayOven && roundResult && resultUiReady) { + if (matchHud && roundResult && resultUiReady) { if (INSTANT_REPLAY && instantReplay) { const frame = Math.min(instantReplay.frames.length, Math.floor(instantReplay.cursor) + 1); @@ -8939,7 +9013,7 @@ function gamePaint() { } // Nameplates and stats wait for the wordmark to lift; the entry frame is // the word, the keys, and the two fighters, and nothing else. - if (!replayOven && shellMode === "GAME" && ((roundResult && resultUiReady) || + if (matchHud && shellMode === "GAME" && ((roundResult && resultUiReady) || (!roundResult && introAge >= introDurationUs))) { const hudPlayers = spatialHudPlayers(); for (let side = 0; side < hudPlayers.length; side++) { diff --git a/xbox/live/tests/oskiewar.test.mjs b/xbox/live/tests/oskiewar.test.mjs index 50e6351f11..4de1063c31 100644 --- a/xbox/live/tests/oskiewar.test.mjs +++ b/xbox/live/tests/oskiewar.test.mjs @@ -754,18 +754,19 @@ function settleFraming(fight, tick, separation, frames = 420) { Math.min(...bounds.map((box) => box.left)) }; } -test("close quarters pushes the lens far past the opening frame", () => { - const { fight, tick } = createFight(false, false, "web"); +test("close quarters keep both fighters dominant after the opening frame", () => { + const { fight, tick } = createFight(false, false, "web", null, + { width: 608, height: 1080 }); fight.startFight(); tick(3000001); const opening = settleFraming(fight, tick, 600); const close = settleFraming(fight, tick, 120); - assert.ok(close.state.doll.width < opening.state.doll.width * .68, + assert.ok(close.state.doll.width < opening.state.doll.width * .8, `close framing ${close.state.doll.width.toFixed(0)} against ` + `opening ${opening.state.doll.width.toFixed(0)}`); // The pair has to genuinely dominate the frame, not merely grow a little. const safeHeight = close.safe.bottom - close.safe.top; - assert.ok(close.height > safeHeight * .8, + assert.ok(close.height > safeHeight * .5, `fighters filled only ${(close.height / safeHeight * 100).toFixed(0)}% ` + `of action-safe height`); assert.ok(close.height > opening.height * 1.5, @@ -933,19 +934,35 @@ test("gameplay camera has no procedural viewport shake", () => { /runtime\(\)\.monotonicUs - roundStartedAt >= introDurationUs/); }); -test("intro camera keeps one smooth midpoint target through name handoffs", () => { +test("intro camera introduces both faces before returning to the fight", () => { const { fight, tick } = createFight(false, false); fight.startFight(); + const left = fight.players[0].x; + const right = fight.players[1].x; + const middle = (left + right) / 2; let previous = fight.cameraState().doll.position.x; let largestStep = 0; + let largestFrame = 0; + const targets = []; for (let frame = 0; frame < 180; frame++) { tick(); const state = fight.cameraState().doll; - assert.ok(Math.abs(state.target.x - 6000) < .01); - largestStep = Math.max(largestStep, Math.abs(state.position.x - previous)); + targets.push(state.target.x); + if (frame && Math.abs(state.position.x - previous) > largestStep) { + largestStep = Math.abs(state.position.x - previous); + largestFrame = frame; + } previous = state.position.x; } - assert.ok(largestStep < 40, `intro camera stepped ${largestStep}px`); + assert.ok(Math.abs(targets[55] - left) < 80, + `first face target ${targets[55].toFixed(0)} missed ${left.toFixed(0)}`); + assert.ok(Math.abs(targets[115] - right) < 80, + `second face target ${targets[115].toFixed(0)} missed ${right.toFixed(0)}`); + assert.ok(targets.slice(120).some((target) => Math.abs(target - middle) < 80), + `intro never returned from ${targets[115].toFixed(0)} toward ` + + `${middle.toFixed(0)}`); + assert.ok(largestStep < 160, + `intro camera stepped ${largestStep}px at frame ${largestFrame}`); assert.match(source, /function drawFightIntro/); assert.match(source, /const startText = "start"/); assert.doesNotMatch(source, /const beginText = "begin"/); @@ -4275,14 +4292,18 @@ test("title keeps QR in the top-right HUD, version after title, and pops beneath assert.doesNotMatch(source, /if \(!placement && shellMode === "MENU"\) return/); }); -test("the Replay Oven captures HUD-free 60 fps high-quality masters", () => { +test("the Replay Oven captures match-HUD 60 fps high-quality masters", () => { assert.match(webShell, /replayOven/); + assert.match(webShell, /reelHud/); + assert.match(source, /const matchHud = !replayOven \|\| reelHud/); + assert.match(source, /if \(matchHud && shellMode === "GAME" && gameplayStarted\)/); assert.match(source, /if \(!replayOven\) drawDebugPerformance/); assert.match(source, /if \(!replayOven && !selfPlay\) drawControlLegend/); + assert.match(reelRenderer, /&reel-hud/); assert.match(reelRenderer, /quality: 100/); assert.match(reelRenderer, /"-r", "60"/); assert.match(reelRenderer, /"-crf", "14"/); - assert.match(replayOven, /hud: false/); + assert.match(replayOven, /hud: true/); }); test("the long street preserves terrain density and quarter-pipe ends", () => {