diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs new file mode 100644 index 0000000000..98bf421629 --- /dev/null +++ b/session-server/oskiewar-live-manager.mjs @@ -0,0 +1,200 @@ +// OSKIEWAR Live Manager, 26.08.04 +// Small, public, match-id-scoped relay for phone spectators. + +const MATCH_WORD = "[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]"; +const MATCH_NAME = new RegExp(`^${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}$`); +const PHASES = new Set(["select", "intro", "fight", "round", "match", "replay"]); +const MAX_MESSAGE_BYTES = 8192; +const MAX_VIEWERS = 64; +const MAX_ROOMS = 128; +const ROOM_TTL_MS = 10 * 60 * 1000; +const MIN_PUBLISH_INTERVAL_MS = 25; + +const finite = (value, limit = 1000000) => + Number.isFinite(value) && Math.abs(value) <= limit; +const integer = (value, low, high) => + Number.isInteger(value) && value >= low && value <= high; + +export function canonicalMatchId(value) { + const raw = String(value || "").toLowerCase(); + const name = raw.startsWith("ow-") ? raw.slice(3) : raw; + return MATCH_NAME.test(name) ? `ow-${name}` : null; +} + +function color(value) { + return Array.isArray(value) && value.length === 3 && + value.every((channel) => integer(channel, 0, 255)); +} + +function fighter(value) { + return value && typeof value === "object" && + typeof value.name === "string" && /^@?[A-Z0-9_-]{1,24}$/i.test(value.name) && + color(value.color) && finite(value.x) && finite(value.y) && finite(value.z) && + [-1, 1].includes(value.facing) && typeof value.alive === "boolean" && + typeof value.grounded === "boolean" && typeof value.ducking === "boolean" && + typeof value.blocking === "boolean" && integer(value.score, 0, 99) && + integer(value.roundWins, 0, 5) && + (value.attack === "" || /^[A-Z0-9 _-]{1,24}$/.test(value.attack)); +} + +export function validateOskiewarLiveState(value) { + if (!value || value.format !== "ac.oskiewar.live" || value.version !== 1) + return "Unsupported live state"; + if (!integer(value.seq, 0, 2147483647) || !finite(value.at, 10000000000000)) + return "Invalid sequence"; + if (!PHASES.has(value.phase)) return "Invalid phase"; + if (!Array.isArray(value.fighters) || value.fighters.length !== 2 || + value.fighters.some((entry) => !fighter(entry))) return "Invalid fighters"; + if (!value.ball || typeof value.ball !== "object" || + typeof value.ball.active !== "boolean" || !finite(value.ball.x) || + !finite(value.ball.y) || !finite(value.ball.z) || + !finite(value.ball.radius, 10000)) return "Invalid ball"; + if (!value.camera || typeof value.camera !== "object" || + !finite(value.camera.x) || !finite(value.camera.y) || + !finite(value.camera.width, 100000) || value.camera.width < 100) + return "Invalid camera"; + if (!value.round || typeof value.round !== "object" || + !integer(value.round.remainingMs, 0, 3600000) || + typeof value.round.result !== "string" || value.round.result.length > 80) + return "Invalid round"; + if (value.replayUrl !== undefined && + !/^\/api\/oskiewar-replays\?id=ow-[a-z-]+$/.test(value.replayUrl)) + return "Invalid replay URL"; + return null; +} + +function send(ws, type, content) { + if (ws?.readyState !== 1) return false; + try { ws.send(JSON.stringify({ type, content })); return true; } + catch { return false; } +} + +export class OskiewarLiveManager { + constructor({ now = () => Date.now() } = {}) { + this.rooms = new Map(); + this.now = now; + } + + accepts(url) { + try { return new URL(url, "http://session").pathname === "/oskiewar-live"; } + catch { return false; } + } + + handleConnection(ws, req) { + if (!this.accepts(req?.url)) return false; + // This route returns before the ordinary game-socket heartbeat setup. + ws.isAlive = true; + ws.on("pong", () => { ws.isAlive = true; }); + const url = new URL(req.url, "http://session"); + const matchId = canonicalMatchId(url.searchParams.get("match")); + const role = url.searchParams.get("role") === "publisher" ? "publisher" : "viewer"; + if (!matchId) { + send(ws, "oskiewar:error", { message: "Invalid match ID" }); + ws.close?.(4400, "Invalid match ID"); + return true; + } + this.prune(); + let room = this.rooms.get(matchId); + if (!room) { + if (this.rooms.size >= MAX_ROOMS) { + send(ws, "oskiewar:error", { message: "Live match capacity reached" }); + ws.close?.(4429, "Capacity reached"); + return true; + } + room = { matchId, publisher: null, viewers: new Set(), state: null, + updatedAt: this.now(), publishedAt: 0 }; + this.rooms.set(matchId, room); + } + if (role === "publisher") this.addPublisher(room, ws); + else this.addViewer(room, ws); + return true; + } + + addPublisher(room, ws) { + if (room.publisher?.readyState === 1) { + send(ws, "oskiewar:error", { message: "This match already has a publisher" }); + ws.close?.(4409, "Publisher already connected"); + return; + } + room.publisher = ws; + room.updatedAt = this.now(); + send(ws, "oskiewar:ready", { matchId: room.matchId, + viewers: room.viewers.size, maxHz: Math.floor(1000 / MIN_PUBLISH_INTERVAL_MS) }); + this.broadcastStatus(room); + ws.on("message", (data) => this.publish(room, ws, data)); + ws.on("close", () => { + if (room.publisher !== ws) return; + room.publisher = null; + room.updatedAt = this.now(); + this.broadcastStatus(room); + }); + ws.on("error", () => {}); + } + + addViewer(room, ws) { + if (room.viewers.size >= MAX_VIEWERS) { + send(ws, "oskiewar:error", { message: "This match has reached 64 viewers" }); + ws.close?.(4429, "Viewer capacity reached"); + return; + } + room.viewers.add(ws); + room.updatedAt = this.now(); + send(ws, "oskiewar:status", this.status(room)); + if (room.state) send(ws, "oskiewar:state", room.state); + send(room.publisher, "oskiewar:viewers", { count: room.viewers.size }); + const remove = () => { + room.viewers.delete(ws); + room.updatedAt = this.now(); + send(room.publisher, "oskiewar:viewers", { count: room.viewers.size }); + }; + ws.on("close", remove); + ws.on("error", remove); + } + + publish(room, ws, data) { + if (room.publisher !== ws) return; + if (Buffer.byteLength(data) > MAX_MESSAGE_BYTES) { + send(ws, "oskiewar:error", { message: "Live state exceeds 8 KiB" }); + return; + } + let message; + try { message = JSON.parse(data.toString()); } + catch { return send(ws, "oskiewar:error", { message: "Invalid JSON" }); } + if (message.type !== "oskiewar:state") return; + let state = message.content; + if (typeof state === "string") { + try { state = JSON.parse(state); } catch { state = null; } + } + const invalid = validateOskiewarLiveState(state); + if (invalid) return send(ws, "oskiewar:error", { message: invalid }); + const now = this.now(); + if (now - room.publishedAt < MIN_PUBLISH_INTERVAL_MS) return; + if (room.state && state.seq <= room.state.seq) return; + room.state = state; + room.publishedAt = now; + room.updatedAt = now; + for (const viewer of room.viewers) send(viewer, "oskiewar:state", state); + } + + status(room) { + return { matchId: room.matchId, live: room.publisher?.readyState === 1, + viewers: room.viewers.size, hasState: Boolean(room.state), + updatedAt: room.updatedAt }; + } + + broadcastStatus(room) { + const status = this.status(room); + for (const viewer of room.viewers) send(viewer, "oskiewar:status", status); + } + + prune() { + const oldest = this.now() - ROOM_TTL_MS; + for (const [matchId, room] of this.rooms) { + if (!room.publisher && room.viewers.size === 0 && room.updatedAt < oldest) + this.rooms.delete(matchId); + } + } +} + +export const OSKIEWAR_LIVE_LIMITS = Object.freeze({ MAX_MESSAGE_BYTES, + MAX_VIEWERS, MAX_ROOMS, ROOM_TTL_MS, MIN_PUBLISH_INTERVAL_MS }); diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs new file mode 100644 index 0000000000..870428b829 --- /dev/null +++ b/session-server/oskiewar-live-manager.test.mjs @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { canonicalMatchId, validateOskiewarLiveState, + OskiewarLiveManager } from "./oskiewar-live-manager.mjs"; + +class FakeSocket extends EventEmitter { + constructor() { super(); this.readyState = 1; this.sent = []; this.closed = null; } + send(value) { this.sent.push(JSON.parse(value)); } + close(code, reason) { this.readyState = 3; this.closed = { code, reason }; this.emit("close"); } +} + +const state = (seq = 1) => ({ + format: "ac.oskiewar.live", version: 1, seq, at: 1000 + seq, phase: "fight", + fighters: [ + { name: "@JEFFREY", color: [190, 42, 58], x: 2000, y: 12000, z: 0, + facing: 1, alive: true, grounded: true, ducking: false, blocking: false, + score: 2, roundWins: 1, attack: "" }, + { name: "@OSKIE", color: [38, 82, 176], x: 10000, y: 12000, z: 0, + facing: -1, alive: true, grounded: true, ducking: false, blocking: true, + score: 1, roundWins: 0, attack: "KICK" }, + ], + ball: { active: true, x: 6000, y: 11945, z: 0, radius: 55 }, + camera: { x: 6000, y: 9140, width: 12000 }, + round: { remainingMs: 24000, result: "" }, +}); + +test("canonical match IDs accept the QR name or ow-prefixed ID", () => { + const name = "bafegu-dorimi-kunapo"; + assert.equal(canonicalMatchId(name), `ow-${name}`); + assert.equal(canonicalMatchId(`ow-${name}`), `ow-${name}`); + assert.equal(canonicalMatchId("short-name"), null); +}); + +test("live state validation rejects unbounded or malformed values", () => { + assert.equal(validateOskiewarLiveState(state()), null); + assert.equal(validateOskiewarLiveState({ ...state(), fighters: [] }), "Invalid fighters"); + assert.equal(validateOskiewarLiveState({ ...state(), replayUrl: "https://bad" }), + "Invalid replay URL"); +}); + +test("one publisher fans state only to viewers in its match", () => { + let now = 100; + const manager = new OskiewarLiveManager({ now: () => now }); + const host = new FakeSocket(), viewer = new FakeSocket(), other = new FakeSocket(); + manager.handleConnection(host, { url: "/oskiewar-live?match=bafegu-dorimi-kunapo&role=publisher" }); + manager.handleConnection(viewer, { url: "/oskiewar-live?match=bafegu-dorimi-kunapo" }); + manager.handleConnection(other, { url: "/oskiewar-live?match=lafegu-dorimi-kunapo" }); + now += 30; + host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state() }))); + assert.equal(viewer.sent.at(-1).type, "oskiewar:state"); + assert.equal(viewer.sent.at(-1).content.seq, 1); + assert.notEqual(other.sent.at(-1)?.type, "oskiewar:state"); +}); + +test("duplicate and over-rate publisher frames are dropped", () => { + let now = 100; + const manager = new OskiewarLiveManager({ now: () => now }); + const host = new FakeSocket(), viewer = new FakeSocket(); + const url = "/oskiewar-live?match=bafegu-dorimi-kunapo"; + manager.handleConnection(host, { url: `${url}&role=publisher` }); + manager.handleConnection(viewer, { url }); + now += 30; + host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(1) }))); + const count = viewer.sent.length; + now += 1; + host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(2) }))); + now += 30; + host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(1) }))); + assert.equal(viewer.sent.length, count); +}); + +test("a second live publisher cannot take over a match", () => { + const manager = new OskiewarLiveManager(); + const first = new FakeSocket(), second = new FakeSocket(); + const url = "/oskiewar-live?match=bafegu-dorimi-kunapo&role=publisher"; + manager.handleConnection(first, { url }); + manager.handleConnection(second, { url }); + assert.equal(second.closed?.code, 4409); +}); diff --git a/session-server/session.mjs b/session-server/session.mjs index 34e2d9c768..71ee88048c 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -154,6 +154,7 @@ chatManager.setPresenceResolver(getHandlesOnPiece); // ๐ŸŽฏ Duel Manager โ€” server-authoritative game for dumduel piece const duelManager = new DuelManager(); const fightManager = new FightManager(); +const oskiewarLiveManager = new OskiewarLiveManager(); // ๐ŸŒ World Managers โ€” Q3-style server-authoritative multiplayer, one // instance per 3D world. Wire protocol is shared; only the event prefix // differs (`arena:*`, `land:*`). Routing below is generic over this map โ€” @@ -167,6 +168,7 @@ import { filter } from "./filter.mjs"; // Profanity filtering. import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. import { FightManager } from "./fight-manager.mjs"; // Fight presence + seat queue. +import { OskiewarLiveManager } from "./oskiewar-live-manager.mjs"; import { WorldManager, ARENA_CFG, ARENA_SPAWNS } from "./world-manager.mjs"; // Server-authoritative 3D worlds. import { LAND_CFG, LAND_SPAWNS } from "../system/public/aesthetic.computer/lib/land-world.mjs"; @@ -1692,6 +1694,9 @@ wss.on("connection", async (ws, req) => { log('๐Ÿ”Œ WebSocket connection received:', JSON.stringify(connectionInfo, null, 2)); log('๐Ÿ”Œ Total wss.clients.size:', wss.clients.size); log('๐Ÿ”Œ Current connections count:', Object.keys(connections).length); + + // Public, pronounceable-ID-scoped OSKIEWAR phone spectator rooms. + if (oskiewarLiveManager.handleConnection(ws, req)) return; // Route status dashboard WebSocket connections separately if (req.url === '/status-stream') { diff --git a/system/public/aesthetic.computer/disks/oskiewar.mjs b/system/public/aesthetic.computer/disks/oskiewar.mjs new file mode 100644 index 0000000000..d1d774ee3c --- /dev/null +++ b/system/public/aesthetic.computer/disks/oskiewar.mjs @@ -0,0 +1,225 @@ +// OSKIEWAR, 26.08.04 +// Phone-sized live spectator view for a pronounceable OSKIEWAR match ID. + +const MATCH_WORD = "[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]"; +const MATCH_NAME = new RegExp(`^${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}$`); +const CAMERA_ASPECT = 1920 / 920; +const WORLD_FLOOR = 12000; + +let matchId = null; +let matchName = ""; +let ws = null; +let reconnectTimer = null; +let reconnectMs = 500; +let alive = false; +let status = "waiting for match"; +let live = false; +let viewers = 0; +let snapshot = null; +let shown = null; +let lastFrameAt = 0; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function canonicalMatch(value) { + const raw = String(value || "").toLowerCase().replace(/^#/, ""); + const name = raw.startsWith("ow-") ? raw.slice(3) : raw; + return MATCH_NAME.test(name) ? { id: `ow-${name}`, name } : null; +} + +function sessionUrl() { + const local = location.hostname === "localhost" || location.hostname === "127.0.0.1"; + const base = local ? `ws://${location.hostname}:8889` + : "wss://session-server.aesthetic.computer"; + return `${base}/oskiewar-live?match=${encodeURIComponent(matchId)}`; +} + +function connect() { + if (!alive || !matchId || typeof WebSocket === "undefined") return; + status = "connecting"; + try { ws = new WebSocket(sessionUrl()); } + catch { return scheduleReconnect(); } + ws.onopen = () => { status = "waiting for console"; reconnectMs = 500; }; + ws.onmessage = ({ data }) => { + let message; + try { message = JSON.parse(data); } catch { return; } + if (message.type === "oskiewar:status") { + live = Boolean(message.content?.live); + viewers = Number(message.content?.viewers) || 0; + status = live ? "live" : message.content?.hasState ? "match paused" : "waiting for console"; + } else if (message.type === "oskiewar:state") { + if (!message.content || message.content.seq <= (snapshot?.seq ?? -1)) return; + snapshot = message.content; + lastFrameAt = Date.now(); + live = true; + status = "live"; + if (!shown) shown = clone(snapshot); + } else if (message.type === "oskiewar:error") { + status = message.content?.message || "spectator unavailable"; + } + }; + ws.onclose = () => { ws = null; live = false; scheduleReconnect(); }; + ws.onerror = () => ws?.close(); +} + +function scheduleReconnect() { + if (!alive || reconnectTimer) return; + status = snapshot ? "reconnecting" : "waiting for match"; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, reconnectMs); + reconnectMs = Math.min(8000, reconnectMs * 2); +} + +function boot({ params = [], colon = [], hash = "", wipe }) { + const parsed = canonicalMatch(colon[0] || params[0] || hash); + alive = true; + matchId = parsed?.id || null; + matchName = parsed?.name || ""; + status = parsed ? "connecting" : "use oskiewar:match-name"; + snapshot = null; + shown = null; + live = false; + viewers = 0; + wipe(12, 15, 24); + if (matchId) connect(); +} + +function sim() { + if (!snapshot) return; + if (!shown) shown = clone(snapshot); + for (let index = 0; index < 2; index++) { + const target = snapshot.fighters[index]; + const display = shown.fighters[index]; + for (const key of ["x", "y", "z"]) + display[key] += (target[key] - display[key]) * .24; + for (const key of ["name", "color", "facing", "alive", "grounded", + "ducking", "blocking", "score", "roundWins", "attack"]) + display[key] = target[key]; + } + for (const key of ["x", "y", "z", "radius"]) + shown.ball[key] += (snapshot.ball[key] - shown.ball[key]) * .3; + shown.ball.active = snapshot.ball.active; + shown.camera = { ...snapshot.camera }; + shown.round = { ...snapshot.round }; + shown.phase = snapshot.phase; + shown.seq = snapshot.seq; + shown.replayUrl = snapshot.replayUrl; + if (Date.now() - lastFrameAt > 5000 && live) { + live = false; + status = "match paused"; + } +} + +function textWidth(text) { return String(text).length * 6; } + +function drawFighter({ ink, line, circle }, fighter, project, tick) { + const p = project(fighter.x, fighter.y); + const size = Math.max(5, Math.min(11, project.scale * 320)); + const top = p.y - size * (fighter.ducking ? .8 : 1.4); + const waist = p.y - size * .55; + const facing = fighter.facing || 1; + const swing = fighter.grounded && !fighter.blocking + ? Math.sin(tick * .13 + fighter.x * .002) * size * .22 : 0; + const [r, g, b] = fighter.color; + ink(r, g, b, fighter.alive ? 255 : 80); + if (!fighter.alive) { + line(p.x - size, p.y, p.x + size, p.y); + line(p.x + size * .6, p.y, p.x + size * 1.3, p.y - size * .25); + return; + } + circle(Math.round(p.x), Math.round(top), Math.max(2, size * .25), true); + line(p.x, top + size * .25, p.x, waist); + const reach = fighter.attack ? size * 1.15 : size * .62; + const armY = fighter.blocking ? top + size * .35 : top + size * .55; + line(p.x, armY, p.x + facing * reach, armY + (fighter.attack === "KICK" ? size * .2 : 0)); + line(p.x, armY, p.x - facing * size * .42, armY + size * .28); + line(p.x, waist, p.x - size * .45 + swing, p.y); + line(p.x, waist, p.x + size * .45 - swing, p.y); + if (fighter.attack === "KICK") + line(p.x, waist, p.x + facing * size * 1.2, waist + size * .18); +} + +function paint({ wipe, ink, line, circle, screen }) { + wipe(12, 15, 24); + const sw = screen.width, sh = screen.height; + ink(218, 224, 238).write("OSKIEWAR", { x: 5, y: 4 }); + const badge = live ? "LIVE" : status.toUpperCase(); + ink(live ? 255 : 130, live ? 70 : 138, live ? 80 : 155) + .write(badge, { x: Math.max(5, sw - textWidth(badge) - 5), y: 4 }); + + if (!shown) { + ink(120, 132, 155).write(status, { + x: Math.max(4, Math.floor((sw - textWidth(status)) / 2)), + y: Math.floor(sh / 2) - 4, + }); + if (matchName) ink(75, 86, 108).write(matchName, { + x: Math.max(4, Math.floor((sw - textWidth(matchName)) / 2)), + y: Math.floor(sh / 2) + 8, + }); + return; + } + + const top = 25, bottom = sh - 20; + const camera = shown.camera; + const worldHeight = camera.width / CAMERA_ASPECT; + const scale = Math.min((sw - 8) / camera.width, (bottom - top) / worldHeight); + const usedW = camera.width * scale, usedH = worldHeight * scale; + const left = (sw - usedW) / 2; + const stageTop = top + Math.max(0, (bottom - top - usedH) / 2); + const project = (x, y) => ({ x: left + (x - (camera.x - camera.width / 2)) * scale, + y: stageTop + (y - (camera.y - worldHeight / 2)) * scale }); + project.scale = scale; + + const floor = project(camera.x, WORLD_FLOOR).y; + ink(24, 30, 45).box(Math.round(left), Math.round(stageTop), + Math.round(usedW), Math.round(usedH)); + ink(70, 78, 96).line(Math.round(left), Math.round(floor), + Math.round(left + usedW), Math.round(floor)); + const platformA = project(4500, 10400), platformB = project(7500, 10400); + ink(54, 63, 82).line(platformA.x, platformA.y, platformB.x, platformB.y); + + if (shown.ball.active) { + const ball = project(shown.ball.x, shown.ball.y); + ink(250, 225, 105).circle(ball.x, ball.y, + Math.max(2, shown.ball.radius * scale), true); + } + const api = { ink, line, circle }; + shown.fighters.forEach((fighter) => drawFighter(api, fighter, project, shown.seq || 0)); + + const [a, b] = shown.fighters; + ink(...a.color).write(`${a.name} ${a.score}`, { x: 5, y: 15 }); + const rightScore = `${b.score} ${b.name}`; + ink(...b.color).write(rightScore, { x: Math.max(5, sw - textWidth(rightScore) - 5), y: 15 }); + const clock = Math.ceil(shown.round.remainingMs / 1000).toString(); + ink(225, 226, 220).write(clock, + { x: Math.floor((sw - textWidth(clock)) / 2), y: 15 }); + if (shown.round.result) { + const result = shown.round.result.slice(0, Math.max(1, Math.floor((sw - 8) / 6))); + ink(255, 236, 145).write(result, { + x: Math.max(4, Math.floor((sw - textWidth(result)) / 2)), + y: Math.max(28, Math.floor(sh / 2)), + }); + } + const footer = `${matchName} ยท ${viewers} watching`; + ink(82, 94, 116).write(footer, + { x: Math.max(4, Math.floor((sw - textWidth(footer)) / 2)), y: sh - 10 }); +} + +function leave() { + alive = false; + clearTimeout(reconnectTimer); + reconnectTimer = null; + ws?.close(); + ws = null; +} + +function meta({ params = [], colon = [] } = {}) { + const parsed = canonicalMatch(colon[0] || params[0]); + return { title: parsed ? `OSKIEWAR ${parsed.name}` : "OSKIEWAR live", + desc: parsed ? `Watch OSKIEWAR match ${parsed.name} live.` + : "Watch a live OSKIEWAR match." }; +} + +export { boot, sim, paint, leave, meta }; diff --git a/system/tests/oskiewar-live-viewer.test.mjs b/system/tests/oskiewar-live-viewer.test.mjs new file mode 100644 index 0000000000..40f47ab536 --- /dev/null +++ b/system/tests/oskiewar-live-viewer.test.mjs @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const sockets = []; +class FakeWebSocket { + constructor(url) { this.url = url; sockets.push(this); } + close() { this.onclose?.(); } +} +globalThis.location = { hostname: "aesthetic.computer" }; +globalThis.WebSocket = FakeWebSocket; + +const viewer = await import("../public/aesthetic.computer/disks/oskiewar.mjs"); + +const state = { + format: "ac.oskiewar.live", version: 1, seq: 1, at: Date.now(), phase: "fight", + fighters: [ + { name: "@JEFFREY", color: [190, 42, 58], x: 2000, y: 12000, z: 0, + facing: 1, alive: true, grounded: true, ducking: false, blocking: false, + score: 2, roundWins: 1, attack: "" }, + { name: "@OSKIE", color: [38, 82, 176], x: 10000, y: 12000, z: 0, + facing: -1, alive: true, grounded: true, ducking: false, blocking: false, + score: 1, roundWins: 0, attack: "KICK" }, + ], + ball: { active: true, x: 6000, y: 11945, z: 0, radius: 55 }, + camera: { x: 6000, y: 9125, width: 12000 }, + round: { remainingMs: 24000, result: "" }, +}; + +test("colon URL joins the match room and paints both contained fighters", () => { + const draws = []; + viewer.boot({ colon: ["bafegu-dorimi-kunapo"], wipe() {} }); + const socket = sockets.at(-1); + assert.equal(socket.url, + "wss://session-server.aesthetic.computer/oskiewar-live?match=ow-bafegu-dorimi-kunapo"); + socket.onopen(); + socket.onmessage({ data: JSON.stringify({ type: "oskiewar:state", content: state }) }); + viewer.sim(); + const chain = { + write(text, position) { draws.push(["text", text, position.x, position.y]); return chain; }, + box(x, y, width, height) { draws.push(["box", x, y, width, height]); return chain; }, + line(x1, y1, x2, y2) { draws.push(["line", x1, y1, x2, y2]); return chain; }, + circle(x, y, radius) { draws.push(["circle", x, y, radius]); return chain; }, + }; + viewer.paint({ wipe() {}, ink() { return chain; }, + line(x1, y1, x2, y2) { draws.push(["line", x1, y1, x2, y2]); }, + circle(x, y, radius) { draws.push(["circle", x, y, radius]); }, + screen: { width: 256, height: 480 } }); + const geometry = draws.filter(([type]) => type === "line" || type === "circle"); + assert.ok(geometry.length >= 10); + assert.ok(geometry.every((entry) => entry[0] === "line" + ? entry[1] >= -1 && entry[1] <= 257 && entry[3] >= -1 && entry[3] <= 257 + : entry[1] >= -1 && entry[1] <= 257)); + viewer.leave(); +}); diff --git a/xbox/live/hello.js b/xbox/live/hello.js index 3b3dcc9127..ca63ad5fad 100644 --- a/xbox/live/hello.js +++ b/xbox/live/hello.js @@ -1,3 +1,4 @@ +// @bundle-qr const floorY = 12000; const ceilingY = 0; const wallThickness = 80; @@ -21,6 +22,8 @@ const matchResultUs = 5000000; const introDurationUs = 3000000; const matchWins = 5; const pickupRespawnUs = 7000000; +const grenadeBlastDuration = .68; +const grenadeBlastRadius = 620; const replayTickUs = 16667; const replayCheckpointUs = 1000000; const instantReplayStepUs = 33333; @@ -168,7 +171,7 @@ const players = [ lastTap: {}, lastRelease: {}, dashUntil: 0, dashVx: 0, roundWins: 0, attackKind: "", attackStartedAt: 0, attackUntil: 0, attackHit: false, blocking: false, blockFlash: 0, - windVx: 0, knockVx: 0, gunAmmo: 0 }, + windVx: 0, knockVx: 0, gunAmmo: 0, grenadeAmmo: 0, stance: "NEUTRAL" }, { name: "@OSKIE", rosterIndex: 2, handleColors: fighterRoster[2].colors, npc: false, pad: 1, spawnX: 6300, x: 6300, y: floorY, z: 0, vx: 0, vy: 0, vz: 0, facing: -1, @@ -179,18 +182,21 @@ const players = [ lastTap: {}, lastRelease: {}, dashUntil: 0, dashVx: 0, roundWins: 0, attackKind: "", attackStartedAt: 0, attackUntil: 0, attackHit: false, blocking: false, blockFlash: 0, - windVx: 0, knockVx: 0, gunAmmo: 0 }, + windVx: 0, knockVx: 0, gunAmmo: 0, grenadeAmmo: 0, stance: "NEUTRAL" }, ]; const impacts = []; const bullets = []; +const grenades = []; const gunPickups = [ - { amount: 6, x: 3000, y: floorY - 70, z: 0 }, - { amount: 6, x: 9000, y: floorY - 70, z: 0 }, + { amount: 6, x: 5450, y: floorY - 70, z: 0 }, + { amount: 6, x: 6550, y: floorY - 70, z: 0 }, { amount: 4, x: 6000, y: platformY - 70, z: 0 }, - { amount: 4, x: 3500, y: 7200, z: 0 }, - { amount: 4, x: 8500, y: 4400, z: 0 }, ]; -for (const pickup of gunPickups) { +const grenadePickups = [ + { amount: 2, x: 5520, y: floorY - 70, z: 0 }, + { amount: 2, x: 6480, y: floorY - 70, z: 0 }, +]; +for (const pickup of [...gunPickups, ...grenadePickups]) { pickup.active = true; pickup.respawnAt = 0; } @@ -222,6 +228,20 @@ let roundReplayFrames = []; let roundReplayLastAt = 0; let instantReplay = null; let replayOfferPrevious = []; +let shellMode = "MENU"; +let shellChoice = 1; +let shellPrevious = []; +const labPlayers = [ + { x: 480, y: 560, color: [190, 42, 58] }, + { x: 1440, y: 560, color: [38, 82, 176] }, +]; +// Temporary live combat inspector. Keep this explicit so the production view +// can return to a clean presentation without changing combat geometry. +let debugHitboxes = true; +let nextInputDebugAt = 0; +let liveSequence = 0; +let liveNextAt = 0; +let spectatorQr = null; function pronounceableMatchName() { const consonants = "bdfgklmnprstvz"; @@ -255,6 +275,38 @@ function startReplay(now) { }; replayLastCommand = [-1, -1]; replayNextCheckpointAt = now; + liveSequence = 0; + liveNextAt = now; + spectatorQr = typeof qrcode === "function" + ? qrcode("https://aesthetic.computer/oskiewar:" + matchName, + { errorCorrectLevel: 1 }) : null; +} + +function publishSpectator(now) { + if (!matchName || typeof publishLive !== "function" || now < liveNextAt) return; + liveNextAt = now + 50000; + const introAge = now - roundStartedAt; + const phase = instantReplay ? "replay" : matchOver ? "match" + : roundResult ? "round" : introAge < introDurationUs ? "intro" : "fight"; + const remainingMs = roundResult ? 0 : Math.max(0, + Math.round((roundDurationUs - roundElapsedUs) / 1000)); + const state = { + format: "ac.oskiewar.live", version: 1, seq: liveSequence++, + at: runtime().unixMs || 0, phase, + fighters: players.map((player) => ({ + name: player.name, color: player.color, x: player.x, y: player.y, + z: player.z, facing: player.facing, alive: player.alive, + grounded: player.grounded, ducking: player.ducking, + blocking: player.blocking, score: player.score, + roundWins: player.roundWins, attack: player.attackKind || "", + })), + ball: { active: ball.active, x: ball.x, y: ball.y, + z: ball.z, radius: ball.radius }, + camera: { x: cameraCenter, y: cameraCenterY, width: cameraWidth }, + round: { remainingMs, result: roundResult || "" }, + replayUrl: "/api/oskiewar-replays?id=ow-" + matchName, + }; + publishLive("ow-" + matchName, JSON.stringify(state)); } function inputCommand(pad) { @@ -471,6 +523,54 @@ function beginSelect(now) { } } +function enterShellMode(mode, now) { + shellMode = mode; + shellPrevious = padSnapshots[0]?.down?.slice() || []; + if (mode === "GAME") beginSelect(now); + if (mode === "LAB") { + labPlayers[0].x = 480; + labPlayers[0].y = 560; + labPlayers[1].x = 1440; + labPlayers[1].y = 560; + } +} + +function updateShell(now) { + const down = padSnapshots[0]?.down || []; + const pressed = (button) => down.includes(button) && !shellPrevious.includes(button); + if (pressed("ArrowLeft") || pressed("ArrowRight")) { + shellChoice = shellChoice ? 0 : 1; + drum("hat", .82, 0); + } + if (pressed("A")) { + drum("clap", 1, 0); + enterShellMode(shellChoice === 0 ? "LAB" : "GAME", now); + } + shellPrevious = down.slice(); +} + +function updateLab(dt, now) { + for (let index = 0; index < labPlayers.length; index++) { + const pad = padSnapshots[index] || + { connected: false, down: [], leftX: 0, leftY: 0 }; + const input = quantizedInput(pad); + const bounds = index === 0 ? [90, 870] : [1050, 1830]; + labPlayers[index].x = clamp(labPlayers[index].x + input.horizontal * 620 * dt, + bounds[0], bounds[1]); + labPlayers[index].y = clamp(labPlayers[index].y - input.vertical * 620 * dt, + 300, 880); + } + const down = padSnapshots[0]?.down || []; + if (down.includes("View") && !shellPrevious.includes("View")) { + shellMode = "MENU"; + shellPrevious = down.slice(); + drum("block", .8, 0); + telemetry("SHELL", "lab->menu " + now); + return; + } + shellPrevious = down.slice(); +} + function updateSelect(now) { for (const player of players) { const down = padSnapshots[player.pad]?.down || []; @@ -546,12 +646,16 @@ function boot() { lastSimAt = startedAt; roundElapsedUs = 0; emitSignal("hello", -1, 1, 0); + shellMode = "MENU"; + shellChoice = 1; + shellPrevious = []; beginSelect(startedAt); } function resetRound(now, resetMatch = false) { impacts.length = 0; bullets.length = 0; + grenades.length = 0; roundReplayFrames = []; roundReplayLastAt = 0; instantReplay = null; @@ -584,13 +688,15 @@ function resetRound(now, resetMatch = false) { player.windVx = 0; player.knockVx = 0; player.gunAmmo = 0; + player.grenadeAmmo = 0; + player.stance = "NEUTRAL"; player.previous = padSnapshots[player.pad]?.down?.slice() || []; player.suppressedDirections = player.previous.filter((button) => button.startsWith("Arrow")); player.lastButton = "NONE"; player.lastButtonAt = -10000000; } - for (const pickup of gunPickups) { + for (const pickup of [...gunPickups, ...grenadePickups]) { pickup.active = true; pickup.respawnAt = 0; } @@ -778,6 +884,18 @@ function fireGun(player, input) { emitSignal("bullet", player.pad, aimX, aimY); } +function throwGrenade(player) { + grenades.push({ x: player.x + player.facing * 150, + y: player.y - (player.ducking ? 80 : 145), z: player.z, + vx: player.facing * 1850, vy: -720, owner: player.pad, + fuse: 1.15, alive: true, exploding: false, blastAge: 0, blastRadius: 0 }); + while (grenades.length > 12) grenades.shift(); + player.grenadeAmmo -= 1; + player.pendingMoveLabel = "GRENADE " + player.grenadeAmmo; + drum("kick", .95, panPlayer(player)); + emitSignal("grenade", player.pad, player.facing, player.ducking ? 1 : 0); +} + function updateGunPickups(now) { const poseTime = (now - startedAt) / 1000000; for (const pickup of gunPickups) { @@ -799,6 +917,27 @@ function updateGunPickups(now) { } } +function updateGrenadePickups(now) { + const poseTime = (now - startedAt) / 1000000; + for (const pickup of grenadePickups) { + if (!pickup.active) { + if (now >= pickup.respawnAt) pickup.active = true; + else continue; + } + for (const player of players) { + if (!player.alive || runnerDistanceToPoint(player, poseTime, + pickup.x, pickup.y, pickup.z) > 90) continue; + player.grenadeAmmo = Math.min(4, player.grenadeAmmo + pickup.amount); + pickup.active = false; + pickup.respawnAt = now + pickupRespawnUs; + remember(player, "GRENADE +" + pickup.amount); + drum("clap", 1.1, panPlayer(player)); + emitSignal("pickup", player.pad, 2, pickup.amount); + break; + } + } +} + function updateBullets(dt, now) { for (const bullet of bullets) { if (bullet.life <= 0) continue; @@ -845,11 +984,72 @@ function updateBullets(dt, now) { if (bullets[index].life <= 0) bullets.splice(index, 1); } +function updateGrenades(dt, now) { + for (const grenade of grenades) { + if (!grenade.alive) continue; + if (grenade.exploding) { + grenade.blastAge += dt; + grenade.blastRadius = grenadeBlastRadius * + Math.min(1, grenade.blastAge / grenadeBlastDuration); + const poseTime = (now - startedAt) / 1000000; + for (const player of players) { + if (player.alive && runnerDistanceToPoint(player, poseTime, + grenade.x, grenade.y, grenade.z) <= grenade.blastRadius) + killPlayer(player, grenade.owner, now, "BLASTED"); + } + if (grenade.blastAge >= grenadeBlastDuration) grenade.alive = false; + continue; + } + const previousY = grenade.y; + grenade.vx += windAcceleration * .45 * dt; + grenade.vy += 1800 * dt; + grenade.x += grenade.vx * dt; + grenade.y += grenade.vy * dt; + grenade.fuse -= dt; + const inset = wallThickness + 35; + if (grenade.x < worldLeft + inset) { + grenade.x = worldLeft + inset; + grenade.vx = Math.abs(grenade.vx) * .65; + } else if (grenade.x > worldRight - inset) { + grenade.x = worldRight - inset; + grenade.vx = -Math.abs(grenade.vx) * .65; + } + if (grenade.y < ceilingY + inset) { + grenade.y = ceilingY + inset; + grenade.vy = Math.abs(grenade.vy) * .65; + } + if (grenade.vy >= 0 && previousY <= platformY - 30 && + grenade.y >= platformY - 30 && grenade.x >= platformLeft && + grenade.x <= platformRight) { + grenade.y = platformY - 30; + grenade.vy = -Math.abs(grenade.vy) * .55; + grenade.vx *= .82; + } else if (grenade.y >= floorY - 30) { + grenade.y = floorY - 30; + grenade.vy = -Math.abs(grenade.vy) * .55; + grenade.vx *= .82; + } + if (grenade.fuse <= 0) { + grenade.exploding = true; + grenade.blastAge = 0; + grenade.blastRadius = 0; + grenade.vx = 0; + grenade.vy = 0; + drum("kick", 1.25, panPlayer(players[grenade.owner])); + emitSignal("blast", grenade.owner, + grenade.x / worldRight, grenade.y / floorY); + } + } + for (let index = grenades.length - 1; index >= 0; index--) + if (!grenades[index].alive) grenades.splice(index, 1); +} + function startMelee(player, kind, now) { player.attackKind = kind; player.attackStartedAt = now; player.attackUntil = now + 220000; player.attackHit = false; + player.stance = "ATTACK"; player.pendingMoveLabel = kind; const pan = panPlayer(player); drum(kind === "KICK" ? "kick" : "snare", 1.05, pan); @@ -956,6 +1156,24 @@ function bootBall(player, now) { emitSignal("boot", player.pad, direction, Math.round(speed)); } +function bounceBallOffBody(player, now) { + const direction = Math.sign(ball.x - player.x) || -Math.sign(ball.vx) || + player.facing || 1; + const incomingSpeed = Math.hypot(ball.vx, ball.vy); + const speed = Math.max(760, Math.min(3600, incomingSpeed * .86)); + ball.vx = direction * speed; + ball.vy = -Math.max(260, Math.abs(ball.vy) * .42 + speed * .12); + ball.x += direction * (ball.radius + 18); + ball.lastHitBy = player.pad === 0 ? 1 : 0; + ball.safeUntil = now + 160000; + ball.safePlayers = 1 << player.pad; + player.hit = Math.max(player.hit, .28); + impacts.push({ x: ball.x, y: ball.y, z: ball.z, + life: .16, duration: .16, death: false, explosion: false }); + drum("block", .82, panAt(ball.x, ball.z)); + emitSignal("bodybounce", player.pad, direction, Math.round(speed)); +} + function updateBall(dt, now) { if (!ball.active || now < ball.serveAt) return; const grounded = ball.y >= floorY - ball.radius - 1 && Math.abs(ball.vy) < 180; @@ -1016,8 +1234,13 @@ function updateBall(dt, now) { if (!player.alive || ((ball.safePlayers & (1 << player.pad)) && now < ball.safeUntil)) continue; - if (runnerDistanceToPoint(player, poseTime, - ball.x, ball.y, ball.z) > ball.radius) continue; + const geometry = runnerWorldGeometry(player, poseTime); + const headDistance = Math.max(0, Math.hypot( + ball.x - geometry.head.x, ball.y - geometry.head.y, + ball.z - geometry.head.z) - geometry.head.radius); + const bodyDistance = runnerBodyDistanceToPoint(geometry, + ball.x, ball.y, ball.z); + if (Math.min(headDistance, bodyDistance) > ball.radius) continue; if (onFloor) { bootBall(player, now); return; @@ -1026,13 +1249,15 @@ function updateBall(dt, now) { returnBall(player, now, true); return; } - else { + if (headDistance <= ball.radius) { ball.active = false; const scorer = ball.lastHitBy >= 0 && ball.lastHitBy !== player.pad ? ball.lastHitBy : player.pad === 0 ? 1 : 0; killPlayer(player, scorer, now, "BALLED"); return; } + bounceBallOffBody(player, now); + return; } } @@ -1068,6 +1293,7 @@ function killPlayer(target, killerPad, now, cause = "KO") { target.respawnAt = now + 1200000; target.vx = 0; target.vy = 0; + target.stance = "HIT"; target.lastButton = cause; target.lastButtonAt = now; if (killerPad !== target.pad) players[killerPad].score += 1; @@ -1081,6 +1307,7 @@ function killPlayer(target, killerPad, now, cause = "KO") { function resolveMelee(now) { const poseTime = (now - startedAt) / 1000000; + const contacts = []; for (const attacker of players) { if (!attacker.alive || attacker.attackHit || now >= attacker.attackUntil) continue; const target = players[attacker.pad === 0 ? 1 : 0]; @@ -1089,25 +1316,41 @@ function resolveMelee(now) { if (runnerDistanceToPoint(target, poseTime, strike.x, strike.y, strike.z) <= strike.radius) { attacker.attackHit = true; - impacts.push({ x: strike.x, y: strike.y, z: strike.z, - life: .2, duration: .2, death: false, explosion: false }); - const away = Math.sign(target.x - attacker.x) || -attacker.facing; - const backBlocking = target.inputX === away; - if (target.blocking || backBlocking) { - target.blockFlash = 1; - target.lastButton = target.blocking ? "BLOCK" : "BACK BLOCK"; - target.lastButtonAt = now; - target.vx = 0; - attacker.vx = -attacker.facing * 420; - drum("block", 1.2, panPlayer(target)); - emitSignal("block", target.pad, attacker.pad, target.blocking ? 1 : 2); - } else killPlayer(target, attacker.pad, now); + contacts.push({ attacker, target, strike }); } } + for (const { attacker, target, strike } of contacts) { + if (!target.alive && contacts.length < 2) continue; + impacts.push({ x: strike.x, y: strike.y, z: strike.z, + life: .2, duration: .2, death: false, explosion: false }); + const away = Math.sign(target.x - attacker.x) || -attacker.facing; + const backBlocking = target.inputX === away; + if (target.blocking || backBlocking) { + target.stance = "DEFEND"; + target.blockFlash = 1; + target.lastButton = target.blocking ? "BLOCK" : "BACK BLOCK"; + target.lastButtonAt = now; + target.vx = 0; + attacker.vx = -attacker.facing * 420; + drum("block", 1.2, panPlayer(target)); + emitSignal("block", target.pad, attacker.pad, target.blocking ? 1 : 2); + } else killPlayer(target, attacker.pad, now, + contacts.length >= 2 ? "TRADE" : "KO"); + } } function resolvePlayerPushboxes() { if (!players[0].alive || !players[1].alive) return; + const poseTime = (runtime().monotonicUs - startedAt) / 1000000; + const firstBounds = runnerBounds(players[0], poseTime); + const secondBounds = runnerBounds(players[1], poseTime); + const verticalOverlap = Math.min(firstBounds.bottom, secondBounds.bottom) - + Math.max(firstBounds.top, secondBounds.top); + // Grounded fighters nudge one another. Once a jumper is clearly above the + // other fighter, the pushboxes separate so cross-over jumps are possible. + if (verticalOverlap <= 18 || + ((!players[0].grounded || !players[1].grounded) && + Math.abs(players[0].y - players[1].y) > 58)) return; const left = players[0].x <= players[1].x ? players[0] : players[1]; const right = left === players[0] ? players[1] : players[0]; const minimumGap = 138; @@ -1133,6 +1376,20 @@ function resolvePlayerPushboxes() { resolveRunnerBounds(right, 0); } +function updateStance(player, input, now) { + const opponent = players[player.pad === 0 ? 1 : 0]; + const toward = Math.sign(opponent.x - player.x) || player.facing || 1; + player.stance = !player.alive ? "HIT" + : player.blocking ? "DEFEND" + : player.attackKind ? "ATTACK" + : player.ducking ? "CROUCH" + : !player.grounded ? "AIR" + : now < player.dashUntil ? "DASH" + : input.horizontal === toward ? "ADVANCE" + : input.horizontal === -toward ? "RETREAT" + : "NEUTRAL"; +} + function updatePlayer(player, pad, dt, now) { if (!player.alive) { player.previous = pad.down.slice(); @@ -1150,6 +1407,7 @@ function updatePlayer(player, pad, dt, now) { player.inputX = 0; player.inputY = 0; player.dashUntil = 0; + player.stance = "NEUTRAL"; player.alive = true; } return; @@ -1166,7 +1424,10 @@ function updatePlayer(player, pad, dt, now) { const upPressed = input.vertical > 0 && !player.previous.includes("MOVE_UP"); player.ducking = input.vertical < 0 && player.grounded; player.blocking = pad.down.includes("X"); - + if (player.attackKind && now >= player.attackUntil) { + player.attackKind = ""; + player.attackHit = false; + } if (player.inputX && input.horizontal !== player.inputX) player.lastRelease[player.inputX > 0 ? "RIGHT" : "LEFT"] = now; if (player.inputY && input.vertical !== player.inputY) @@ -1224,7 +1485,10 @@ function updatePlayer(player, pad, dt, now) { if (player.gunAmmo > 0) fireGun(player, input); else startMelee(player, "KICK", now); } - else if (!player.blocking && button === "B") startMelee(player, "PUNCH", now); + else if (!player.blocking && button === "B") { + if (player.grenadeAmmo > 0) throwGrenade(player); + else startMelee(player, "PUNCH", now); + } } } if (player.pendingMoveLabel) remember(player, player.pendingMoveLabel); @@ -1251,6 +1515,7 @@ function updatePlayer(player, pad, dt, now) { if (input.vertical > 0) player.previous.push("MOVE_UP"); player.inputX = input.horizontal; player.inputY = input.vertical; + updateStance(player, input, now); } function sim() { @@ -1259,7 +1524,29 @@ function sim() { lastSimAt = now; padSnapshots[0] = gamepad(0); padSnapshots[1] = gamepad(1); + if (debugHitboxes && now >= nextInputDebugAt) { + nextInputDebugAt = now + 500000; + const values = players.map((player) => { + const pad = padSnapshots[player.pad]; + const input = quantizedInput(pad, player.suppressedDirections); + return "P" + (player.pad + 1) + " down=" + + (pad.down.join("+") || "NONE") + " stick=" + + pad.leftX.toFixed(2) + "," + pad.leftY.toFixed(2) + " q=" + + input.horizontal + "," + input.vertical + " vx=" + + Math.round(player.vx); + }); + telemetry("FIGHT_INPUT", values.join(" | ")); + } + if (shellMode === "MENU") { + updateShell(now); + return; + } + if (shellMode === "LAB") { + updateLab(dt, now); + return; + } recordReplayCommands(now); + publishSpectator(now); if (roundResult) { if (instantReplay) { updateInstantReplay(now, dt); @@ -1292,9 +1579,11 @@ function sim() { : padSnapshots[1], dt, now); resolvePlayerPushboxes(); updateGunPickups(now); + updateGrenadePickups(now); updateBullets(dt, now); - updateBall(dt, now); + updateGrenades(dt, now); resolveMelee(now); + updateBall(dt, now); updateCamera(dt); updateCameraDoll(dt, now); captureRoundReplay(now); @@ -1471,6 +1760,14 @@ function runnerDistanceToPoint(player, t, px, py, pz = 0) { return distance; } +function runnerBodyDistanceToPoint(geometry, px, py, pz = 0) { + let distance = Infinity; + for (const segment of geometry.segments) + distance = Math.min(distance, + Math.max(0, pointSegmentDistance(px, py, pz, segment) - segment.width / 2)); + return distance; +} + function handleWidth(handle, size) { return handle.length ? size * (handle[0] === "@" ? .88 : .58) + (handle.length - 1) * size * .58 : 0; @@ -1563,21 +1860,72 @@ function drawRunner(player, t, showLabel = true) { } +function drawDebugHitboxes(player, t) { + if (!debugHitboxes || (!player.alive && !roundResult)) return; + const world = player.replayGeometry || runnerWorldGeometry(player, t); + const geometry = projectRunnerWorldGeometry(world); + const bodyColor = [58, 222, 255]; + const headColor = [255, 62, 82]; + const pushColor = [105, 255, 118]; + const attackColor = [255, 86, 220]; + + for (const segment of geometry.segments) + line(segment.x1, segment.y1, segment.x2, segment.y2, + Math.max(2, segment.width * .22), ...bodyColor); + circle(geometry.head.x, geometry.head.y, geometry.head.radius, + Math.max(2, geometry.head.radius * .12), headColor); + + const halfWidth = player.ducking ? 76 : 62; + const top = player.y - (player.ducking ? 132 : 174); + const corners = [ + projectPoint(player.x - halfWidth, top, player.z), + projectPoint(player.x + halfWidth, top, player.z), + projectPoint(player.x + halfWidth, player.y, player.z), + projectPoint(player.x - halfWidth, player.y, player.z), + ]; + for (let index = 0; index < corners.length; index++) { + const next = corners[(index + 1) % corners.length]; + line(corners[index].x, corners[index].y, next.x, next.y, 2, ...pushColor); + } + + if (player.attackKind && runtime().monotonicUs < player.attackUntil) { + const strike = meleeStrike(player, runtime().monotonicUs); + const point = projectPoint(strike.x, strike.y, strike.z); + circle(point.x, point.y, Math.max(2, strike.radius * cameraScale()), + 3, attackColor); + line(geometry.head.x, geometry.head.y, point.x, point.y, 1, ...attackColor); + } + + const mode = "P" + (player.pad + 1) + " " + player.stance + + (player.attackKind ? "/" + player.attackKind : ""); + const labelY = Math.max(112, geometry.head.y - geometry.head.radius - 58); + const labelWidth = Math.max(162, mode.length * 15); + const pad = padSnapshots[player.pad] || + { connected: false, down: [], leftX: 0, leftY: 0 }; + const input = quantizedInput(pad, player.suppressedDirections); + const inputLabel = "IN " + input.horizontal + "," + input.vertical + + " STK " + pad.leftX.toFixed(2) + " VX " + Math.round(player.vx); + const inputWidth = inputLabel.length * 10; + const panelWidth = Math.max(labelWidth, inputWidth + 16); + box(geometry.head.x - panelWidth / 2, labelY - 5, + panelWidth, 54, 3, 5, 12); + write(mode, geometry.head.x - labelWidth / 2 + 8, + labelY, 21, ...player.color); + write(inputLabel, geometry.head.x - panelWidth / 2 + 8, + labelY + 28, 14, 215, 224, 240); + write("HEAD", geometry.head.x - geometry.head.radius - 36, + geometry.head.y - 9, 13, ...headColor); +} + function drawPlayerHud(player, x, pad) { - pad = pad || { connected: false, down: [] }; const color = visualTheme.light > .55 ? player.pad === 0 ? [155, 34, 108] : [105, 78, 0] : player.color; - typeWrite("P" + (player.pad + 1) + " " + - player.roundWins + "/" + matchWins + " PTS " + player.score, - x, 14, 24, ...color); - const held = pad.down.length ? pad.down.map(buttonLabel).join(" ") : "NONE"; - const secondary = mixColor([195, 210, 230], [48, 58, 78], visualTheme.light); - const status = player.npc ? "DUMMY" : pad.connected ? "READY" : "CONNECT"; - typeWrite(player.lastButton + " ยท " + held + " ยท " + status, - x, 48, 16, ...(status === "CONNECT" ? [255, 105, 105] : secondary)); - if (player.gunAmmo > 0) - typeWrite("GUN " + player.gunAmmo, x + 430, 14, 20, ...color); + let label = "P" + (player.pad + 1) + " " + player.roundWins + "/" + + matchWins + " PTS " + player.score; + if (player.gunAmmo > 0) label += " GUN " + player.gunAmmo; + if (player.grenadeAmmo > 0) label += " GREN " + player.grenadeAmmo; + typeWrite(label, x, 14, 22, ...color); } function drawFighterData(player, x) { @@ -1597,9 +1945,20 @@ function drawCornerHandle(player, right = false) { box(barX + (right ? -7 : 7), 1037, barWidth, 43, 0, 0, 0); box(barX, 1032, barWidth, 43, 5, 7, 14); box(barX, 1032, barWidth, 5, ...player.color); - for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) - drawHandle(player.name, x + dx, 1038 + dy, size, - player.handleColors, player.color); + const drawGlyphs = (dx, dy, colors, fallback) => { + let cursor = x + dx; + for (let index = 0; index < player.name.length; index++) { + const character = player.name[index]; + const color = colors?.[index] || fallback; + write(character, cursor, 1038 + dy, size, ...color); + cursor += size * (character === "@" ? .88 : .58); + } + }; + // Each glyph owns a hard font_1 shadow. The strap remains the final overlay + // layer so world geometry and post effects never composite over either name. + drawGlyphs(4, 4, null, [0, 0, 0]); + drawGlyphs(0, 0, player.handleColors, player.color); + drawGlyphs(1, 0, player.handleColors, player.color); } function worldLine(x1, y1, z1, x2, y2, z2, width, color) { @@ -1634,6 +1993,37 @@ function drawBullet(bullet) { Math.max(2, 6 * cameraScale()), color); } +function drawGrenadePickup(pickup, t) { + if (!pickup.active) return; + const bobY = pickup.y + Math.sin(t * 3.2 + pickup.x * .001) * 24; + const point = projectPoint(pickup.x, bobY, pickup.z); + const scale = cameraScale(); + const color = [255, 105, 105]; + circle(point.x, point.y, Math.max(5, 34 * scale), + Math.max(2, 10 * scale), color); + worldLine(pickup.x, bobY - 34, pickup.z, + pickup.x + 28, bobY - 62, pickup.z, Math.max(2, 8 * scale), color); + typeWrite("GREN", point.x - 26, point.y - Math.max(20, 68 * scale), + Math.max(10, Math.min(18, Math.round(42 * scale))), ...color); +} + +function drawGrenade(grenade) { + const point = projectPoint(grenade.x, grenade.y, grenade.z); + if (grenade.exploding) { + const radius = grenade.blastRadius * cameraScale(); + circle(point.x, point.y, radius, Math.max(3, 10 * cameraScale()), + [255, 105, 105]); + return; + } + const blink = grenade.fuse < .45 && Math.floor(grenade.fuse * 20) % 2 === 0; + const color = blink ? [255, 255, 255] : players[grenade.owner].color; + circle(point.x, point.y, Math.max(4, 28 * cameraScale()), + Math.max(2, 9 * cameraScale()), color); + const tail = projectPoint(grenade.x - Math.sign(grenade.vx) * 90, + grenade.y - 14, grenade.z); + line(tail.x, tail.y, point.x, point.y, Math.max(2, 6 * cameraScale()), ...color); +} + function drawWindFlag(t, color) { const poleX = 820; const poleTop = 18; @@ -1652,6 +2042,31 @@ function drawWindFlag(t, color) { typeWrite(windMph + " MPH", textX, 38, 17, ...color); } +function drawWindLines(t, color) { + const count = 7 + Math.floor(windMph / 3); + const span = cameraWidth * 1.18; + const speed = .045 + windMph * .0045; + const length = 45 + windMph * 7; + for (let index = 0; index < count; index++) { + const zSpan = worldFar - worldNear; + const z = worldNear + 180 + ((index * 487) % Math.max(1, zSpan - 360)); + const nearAmount = 1 - (z - worldNear) / zSpan; + const depthScale = .62 + nearAmount * .82; + const cycle = ((index * .173 + t * speed * depthScale * windDirection) % 1 + 1) % 1; + const x = cameraCenter - span / 2 + cycle * span; + const row = ((index * 47) % 101) / 100; + const y = cameraCenterY - cameraWidth * .27 + row * cameraWidth * .54 + + Math.sin(t * 1.7 + index * 2.3) * (6 + windMph * .5); + const tailX = x - windDirection * length * depthScale; + const bend = Math.sin(t * 2.1 + index) * (3 + windMph * .28); + worldLine(tailX, y - bend, z, + x - windDirection * length * depthScale * .38, y + bend, z, + nearAmount > .62 && windMph > 12 ? 2 : 1, color); + worldLine(x - windDirection * length * depthScale * .38, y + bend, z, + x, y, z, nearAmount > .62 && windMph > 12 ? 2 : 1, color); + } +} + function drawSelectPortrait(player, x, y, scale, t) { const color = player.color; const head = { x, y: y - 130 * scale, radius: 34 * scale }; @@ -1691,6 +2106,94 @@ function drawSelectionScreen(t, ink, panel) { 290, 965, 26, ...ink); } +function drawShellMenu(t, ink, panel) { + const choices = [ + { name: "NEW GAME", x: 120, color: [88, 210, 224] }, + { name: "OSKIEWAR", x: 990, color: [214, 45, 72] }, + ]; + typeWrite("OSKIEWAR", 824, 118, 42, ...ink); + for (let index = 0; index < choices.length; index++) { + const choice = choices[index]; + const active = shellChoice === index; + box(choice.x, 280, 810, 560, ...(active ? panel : [9, 12, 26])); + box(choice.x, 280, 810, active ? 9 : 3, ...choice.color); + typeWrite(choice.name, choice.x + 70, 350, active ? 50 : 40, + ...(active ? choice.color : ink)); + if (index === 0) { + for (let pad = 0; pad < 2; pad++) { + const cx = choice.x + 245 + pad * 330; + const cy = 600; + circle(cx, cy, 82, 7, choice.color); + line(cx - 44, cy, cx + 44, cy, 9, ...choice.color); + line(cx, cy - 44, cx, cy + 44, 9, ...choice.color); + } + typeWrite("2-PAD INPUT LAB", choice.x + 70, 760, 27, ...ink); + } else { + drawSelectPortrait(players[0], choice.x + 280, 650, 1, t); + drawSelectPortrait(players[1], choice.x + 545, 650, 1, t); + typeWrite("FIGHT", choice.x + 70, 760, 27, ...ink); + } + } + typeWrite("LEFT RIGHT A OPEN", 785, 930, 24, ...ink); +} + +function drawInputLab(run, ink, panel) { + const caps = typeof capabilities === "function" ? capabilities() : {}; + const controllerList = typeof controllers === "function" ? controllers() : []; + typeWrite("NEW GAME", 52, 38, 34, ...ink); + const status = (caps.productName || "XBOX") + " BIOS " + + (caps.version || "DEV") + " PADS " + controllerList.length + + " AUDIO " + Number(run.audioLatencyMs || 0).toFixed(1) + "MS"; + typeWrite(status, 430, 44, 19, ...ink); + line(960, 118, 960, 1030, 3, 65, 78, 108); + for (let index = 0; index < 2; index++) { + const pad = padSnapshots[index] || + { connected: false, down: [], leftX: 0, leftY: 0, + rightX: 0, rightY: 0, leftTrigger: 0, rightTrigger: 0 }; + const input = quantizedInput(pad); + const left = index === 0 ? 40 : 1000; + const color = labPlayers[index].color; + box(left, 135, 880, 855, ...panel); + box(left, 135, 880, 7, ...color); + typeWrite("P" + (index + 1) + " " + + (pad.connected ? "CONNECTED" : "NO CONTROLLER"), + left + 34, 175, 28, ...color); + const down = pad.down.length ? pad.down.map(buttonLabel).join(" ") : "NONE"; + typeWrite("DOWN " + down, left + 34, 235, 22, ...ink); + typeWrite("LEFT " + pad.leftX.toFixed(3) + " " + pad.leftY.toFixed(3) + + "\nRIGHT " + pad.rightX.toFixed(3) + " " + pad.rightY.toFixed(3) + + "\nTRIG " + pad.leftTrigger.toFixed(3) + " " + pad.rightTrigger.toFixed(3) + + "\nGATE " + input.horizontal + " " + input.vertical, + left + 34, 295, 21, ...ink); + const point = labPlayers[index]; + circle(point.x, point.y, 42, 6, color); + line(point.x - 70, point.y, point.x + 70, point.y, 3, ...color); + line(point.x, point.y - 70, point.x, point.y + 70, 3, ...color); + typeWrite("DPAD / LEFT STICK MOVES THIS MARKER", left + 110, 920, + 20, ...ink); + } + typeWrite("VIEW BACK TO SELECTOR", 795, 1010, 18, ...ink); +} + +function drawSpectatorQr(ink) { + if (!spectatorQr || typeof spectatorQr.getModuleCount !== "function") return; + const count = spectatorQr.getModuleCount(); + const quiet = 4; + const cell = Math.max(2, Math.floor(176 / (count + quiet * 2))); + const size = (count + quiet * 2) * cell; + const left = 1920 - size - 16; + const top = 104; + box(left, top, size, size, 250, 250, 247); + for (let row = 0; row < count; row++) { + for (let column = 0; column < count; column++) { + if (spectatorQr.isDark(row, column)) + box(left + (column + quiet) * cell, top + (row + quiet) * cell, + cell, cell, 7, 8, 14); + } + } + typeWrite("WATCH", left, top + size + 5, 14, ...ink); +} + function paint() { const run = runtime(); const t = (run.monotonicUs - startedAt) / 1000000; @@ -1706,6 +2209,14 @@ function paint() { const titleInk = mixColor([245, 248, 255], [24, 35, 72], visualTheme.light); wipe(...sky); box(0, 0, 1920, 1080, ...arena); + if (shellMode === "MENU") { + drawShellMenu(t, titleInk, titlePanel); + return; + } + if (shellMode === "LAB") { + drawInputLab(run, titleInk, titlePanel); + return; + } if (selecting) { drawSelectionScreen(t, titleInk, titlePanel); return; @@ -1733,14 +2244,14 @@ function paint() { platformLeft, platformY, platformFar, 5, worldInk); worldLine(platformRight, platformY, platformNear, platformRight, platformY, platformFar, 5, worldInk); - const remaining = roundResult ? 0 : Math.max(0, - Math.ceil((roundDurationUs - roundElapsedUs) / 1000000)); - const clockInk = remaining <= 10 ? [255, 105, 190] : titleInk; - typeWrite(String(remaining).padStart(2, "0"), 928, 5, 48, ...clockInk); + const windInk = mixColor([125, 155, 205], [238, 246, 255], visualTheme.light); + drawWindLines(t, windInk); drawWindFlag(t, titleInk); typeWrite(matchName.toUpperCase(), 806, 82, 14, ...titleInk); for (const pickup of gunPickups) drawGunPickup(pickup, t); + for (const pickup of grenadePickups) drawGrenadePickup(pickup, t); for (const bullet of bullets) drawBullet(bullet); + for (const grenade of grenades) drawGrenade(grenade); if (ball.active) { const point = projectPoint(ball.x, ball.y, ball.z); const radius = Math.max(8, ball.radius * cameraScale()); @@ -1783,6 +2294,8 @@ function paint() { const showRunnerLabels = Boolean(roundResult) || introAge >= introDurationUs; drawRunner(players[0], t, showRunnerLabels); drawRunner(players[1], t, showRunnerLabels); + drawDebugHitboxes(players[0], t); + drawDebugHitboxes(players[1], t); if (!roundResult && introAge < introDurationUs) { const introSeconds = introAge / 1000000; const introText = introSeconds < .95 ? players[0].name @@ -1818,8 +2331,7 @@ function paint() { } drawPlayerHud(players[0], 20, padSnapshots[0]); drawPlayerHud(players[1], 1275, padSnapshots[1]); - drawFighterData(players[0], 20); - drawFighterData(players[1], 1035); + drawSpectatorQr(titleInk); drawCornerHandle(players[0]); drawCornerHandle(players[1], true); } diff --git a/xbox/live/tests/hello.test.mjs b/xbox/live/tests/hello.test.mjs index d6042919fd..259957c653 100644 --- a/xbox/live/tests/hello.test.mjs +++ b/xbox/live/tests/hello.test.mjs @@ -4,10 +4,11 @@ import test from "node:test"; const source = await readFile(new URL("../hello.js", import.meta.url), "utf8"); -function createFight(startImmediately = true) { +function createFight(startImmediately = true, enterGame = true) { let now = 0; const signals = []; const replays = []; + const liveFrames = []; const triangles = []; const pads = [0, 1].map(() => ({ connected: true, down: [], leftX: 0, leftY: 0 })); const noOp = () => {}; @@ -17,16 +18,18 @@ function createFight(startImmediately = true) { triangles.push(values); }; const fight = new Function( - "runtime", "gamepad", "telemetry", "gameSignal", "saveReplay", "drum", "wipe", "box", "line", "triangle", "write", "systemWrite", - `${source}\nreturn { boot, sim, paint, players, ball, bullets, gunPickups, runnerWorldGeometry, runnerDistanceToPoint, disableBall: () => { ballEnabled = false; ball.active = false; }, enableBall: () => { ballEnabled = true; ball.active = true; ball.serveAt = 0; ball.safeUntil = 0; ball.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), wackBall: () => { players[0].attackKind = "KICK"; returnBall(players[0], runtime().monotonicUs, false); }, crossWackBall: (contact = 1) => crossWackBall(players.map((player) => ({ player, contact })), runtime().monotonicUs), startFight: () => { selecting = false; startReplay(runtime().monotonicUs); resetRound(runtime().monotonicUs, true); }, selectionState: () => ({ selecting, ready: selectionReady.slice() }), cameraState: () => ({ cameraWidth, cameraCenter, cameraCenterY }), roundState: () => ({ roundResult, roundElapsedUs, matchOver }), instantReplayState: () => instantReplay ? { active: true, paused: instantReplay.paused, cursor: instantReplay.cursor, frames: instantReplay.frames.length } : { active: false }, replayFrameCount: () => roundReplayFrames.length };` + "runtime", "gamepad", "telemetry", "gameSignal", "saveReplay", "publishLive", "drum", "wipe", "box", "line", "triangle", "write", "systemWrite", + `${source}\nreturn { boot, sim, paint, players, ball, bullets, grenades, gunPickups, grenadePickups, runnerWorldGeometry, runnerDistanceToPoint, disableBall: () => { ballEnabled = false; ball.active = false; }, enableBall: () => { ballEnabled = true; ball.active = true; ball.serveAt = 0; ball.safeUntil = 0; ball.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), wackBall: () => { players[0].attackKind = "KICK"; returnBall(players[0], runtime().monotonicUs, false); }, crossWackBall: (contact = 1) => crossWackBall(players.map((player) => ({ player, contact })), runtime().monotonicUs), enterGame: () => enterShellMode("GAME", runtime().monotonicUs), shellState: () => ({ mode: shellMode, choice: shellChoice, lab: labPlayers.map((player) => ({ ...player })) }), startFight: () => { shellMode = "GAME"; selecting = false; startReplay(runtime().monotonicUs); resetRound(runtime().monotonicUs, true); }, selectionState: () => ({ selecting, ready: selectionReady.slice() }), cameraState: () => ({ cameraWidth, cameraCenter, cameraCenterY }), roundState: () => ({ roundResult, roundElapsedUs, matchOver }), instantReplayState: () => instantReplay ? { active: true, paused: instantReplay.paused, cursor: instantReplay.cursor, frames: instantReplay.frames.length } : { active: false }, replayFrameCount: () => roundReplayFrames.length };` )( () => ({ monotonicUs: now, unixMs: 1785870000000 + Math.floor(now / 1000) }), (index = 0) => ({ ...pads[index], down: pads[index].down.slice() }), noOp, (...signal) => signals.push(signal), (payload) => replays.push(payload), + (matchId, payload) => liveFrames.push([matchId, JSON.parse(payload)]), noOp, noOp, noOp, noOp, drawTriangle, noOp, noOp ); fight.boot(); + if (enterGame) fight.enterGame(); const tick = (elapsedUs = 16667) => { now += elapsedUs; @@ -44,9 +47,39 @@ function createFight(startImmediately = true) { fight.startFight(); tick(3000001); } - return { fight, pads, signals, replays, triangles, tick, tap, now: () => now }; + return { fight, pads, signals, replays, liveFrames, triangles, + tick, tap, now: () => now }; } +test("boot selector opens the blank two-pad NEW GAME input lab", () => { + const { fight, pads, tick } = createFight(false, false); + assert.equal(fight.shellState().mode, "MENU"); + assert.equal(fight.shellState().choice, 1); + pads[0].down = ["ArrowLeft"]; + tick(); + pads[0].down = []; + tick(); + pads[0].down = ["A"]; + tick(); + assert.equal(fight.shellState().mode, "LAB"); + const before = fight.shellState().lab[0].x; + pads[0].down = ["ArrowRight"]; + tick(100000); + assert.ok(fight.shellState().lab[0].x > before); +}); + +test("active matches publish bounded phone spectator snapshots", () => { + const { liveFrames, tick } = createFight(); + tick(50000); + assert.ok(liveFrames.length > 0); + const [matchId, frame] = liveFrames.at(-1); + assert.match(matchId, /^ow-[a-z]{6}-[a-z]{6}-[a-z]{6}$/); + assert.equal(frame.format, "ac.oskiewar.live"); + assert.equal(frame.fighters.length, 2); + assert.ok(frame.camera.width >= 100); + assert.ok(JSON.stringify(frame).length < 7168); +}); + test("character select offers the four AC fighters and waits for both pads", () => { const { fight, pads, tick } = createFight(false); pads[0].down = ["ArrowRight"]; @@ -119,6 +152,21 @@ test("gun drops grant ammo and A fires in the quantized aim direction", () => { assert.ok(signals.some(([event, pad]) => event === "bullet" && pad === 0)); }); +test("grenade drops grant ammo and B throws an expanding grenade", () => { + const { fight, pads, signals, tick } = createFight(); + const player = fight.players[0]; + const pickup = fight.grenadePickups[0]; + pickup.x = player.x; + pickup.y = player.y - 70; + tick(); + assert.equal(player.grenadeAmmo, pickup.amount); + pads[0].down = ["B"]; + tick(); + assert.equal(player.grenadeAmmo, pickup.amount - 1); + assert.equal(fight.grenades.length, 1); + assert.ok(signals.some(([event, pad]) => event === "grenade" && pad === 0)); +}); + test("opposing bullets cancel one another", () => { const { fight, tick } = createFight(); fight.bullets.push( @@ -224,6 +272,44 @@ test("fighters walking into one another push apart without ending the round", () assert.ok(Math.abs(fight.players[1].x - fight.players[0].x) >= 137); }); +test("an airborne fighter can cross over the other fighter", () => { + const { fight, pads, tick } = createFight(); + const jumper = fight.players[0]; + const standing = fight.players[1]; + jumper.x = 5940; + jumper.y = standing.y - 190; + jumper.vy = 0; + jumper.grounded = false; + standing.x = 6060; + pads[0].down = ["ArrowRight"]; + for (let frame = 0; frame < 8; frame++) tick(16667); + assert.ok(jumper.x > standing.x); + assert.equal(jumper.alive, true); + assert.equal(standing.alive, true); +}); + +test("a neutral fighter cannot defeat an attacking fighter by contact", () => { + const { fight, pads, tick } = createFight(); + fight.players[0].x = 5940; + fight.players[1].x = 6060; + pads[0].down = ["B"]; + for (let frame = 0; frame < 5; frame++) tick(16667); + assert.equal(fight.players[0].alive, true); + assert.equal(fight.players[1].alive, false); +}); + +test("simultaneous active strikes trade without player-order bias", () => { + const { fight, pads, tick } = createFight(); + fight.players[0].x = 5940; + fight.players[1].x = 6060; + pads[0].down = ["B"]; + pads[1].down = ["B"]; + for (let frame = 0; frame < 5; frame++) tick(16667); + assert.equal(fight.players[0].alive, false); + assert.equal(fight.players[1].alive, false); + assert.equal(fight.roundState().roundResult, "TIE"); +}); + test("player lands on the center platform", () => { const { fight, tick } = createFight(); const player = fight.players[0]; @@ -293,6 +379,38 @@ test("running into a grounded ball boots it instead of killing the player", () = assert.ok(signals.some(([event, pad]) => event === "boot" && pad === 0)); }); +test("an airborne ball only BALLS on the head", () => { + const { fight, tick, now } = createFight(); + const player = fight.players[0]; + const head = fight.runnerWorldGeometry(player, now() / 1000000).head; + fight.enableBall(); + fight.ball.x = head.x; + fight.ball.y = head.y; + fight.ball.z = head.z; + fight.ball.vx = 900; + fight.ball.vy = 0; + tick(1000); + assert.equal(player.alive, false); + assert.equal(player.lastButton, "BALLED"); +}); + +test("an airborne ball bounces off non-head body geometry", () => { + const { fight, signals, tick, now } = createFight(); + const player = fight.players[0]; + const geometry = fight.runnerWorldGeometry(player, now() / 1000000); + const arm = geometry.segments.at(-1); + fight.enableBall(); + fight.ball.x = arm.x2 + player.facing * 22; + fight.ball.y = arm.y2; + fight.ball.z = arm.z2; + fight.ball.vx = -1100; + fight.ball.vy = -200; + tick(1000); + assert.equal(player.alive, true); + assert.equal(fight.ball.active, true); + assert.ok(signals.some(([event, pad]) => event === "bodybounce" && pad === 0)); +}); + test("melee returns label and signal the ball as WACK", () => { const { fight, signals } = createFight(); const player = fight.players[0]; diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index e6ccca2e2b..4f4384ed3d 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -1,6 +1,7 @@ #include "pch.h" #include "QuickJsEngine.hpp" #include "PhotoDiscService.hpp" +#include "OskiewarLivePublisher.hpp" #include "../runtime/include/ac/image_effects.hpp" using Microsoft::WRL::ComPtr; @@ -348,7 +349,7 @@ public: }; m_sound->get_rate = [this]() { return static_cast(m_sampleRate); }; m_api = std::make_unique(Api{{1920, 1080, 1}, {}, {}, {}, *m_graphics, *m_sound, {}}); - m_api->system.version = "1.0.0.34"; + m_api->system.version = "1.0.0.35"; m_api->telemetry = [](std::string_view line) { std::string safe(line); for (auto& character : safe) if (character == '\n' || character == '\r') character = ' '; @@ -386,6 +387,12 @@ public: LogTelemetry("AC_NATIVE_REPLAY queued=1 bytes=" + std::to_string(payload.size())); }; + m_oskiewarLive = std::make_unique( + [](const std::string& line) { LogTelemetry(line); }); + m_api->live_publish = [this](std::string_view matchId, + std::string_view payload) { + m_oskiewarLive->publish(matchId, payload); + }; m_photoDisc = std::make_unique(*m_api, [this](std::shared_ptr image) { std::lock_guard lock(m_imageMutex); @@ -412,6 +419,8 @@ public: virtual void Load(String^) {} virtual void Uninitialize() { + if (m_api) m_api->live_publish = {}; + if (m_oskiewarLive) m_oskiewarLive->shutdown(); DestroyReplayUploads(); DestroyGameSignals(); DestroyNetworkMidi(); DestroyMidi(); DestroyAudio(); } @@ -1696,7 +1705,7 @@ private: if (!m_networkClockRequestInFlight.compare_exchange_strong(expected, true)) return; const auto sentAt = SystemUnixMs(); auto client = ref new HttpClient(); - client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.34 Xbox ClockSync"); + client->DefaultRequestHeaders->UserAgent->ParseAdd("OSKIEWAR/1.0.0.35 Xbox ClockSync"); create_task(client->GetStringAsync( ref new Uri(L"https://aesthetic.computer/api/clock"))) .then([this, client, sentAt](task completed) { @@ -1803,7 +1812,7 @@ private: if (!m_acRequestInFlight.compare_exchange_strong(expected, true)) return; auto client = ref new HttpClient(); - client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.34 Xbox"); + client->DefaultRequestHeaders->UserAgent->ParseAdd("OSKIEWAR/1.0.0.35 Xbox"); std::vector> requests; const auto safeGet = [client](const std::wstring& url) { return create_task(client->GetStringAsync(ref new Uri(ref new String(url.c_str())))) @@ -2397,7 +2406,7 @@ private: unsigned m_frameWidth = 0; unsigned m_frameHeight = 0; unsigned m_frameBlurRadius = 0; - static constexpr std::size_t kMaxSystemDraws = 24; + static constexpr std::size_t kMaxSystemDraws = 128; static constexpr std::size_t kMaxTriangles = 8192; static constexpr std::size_t kMaxTexturedTriangles = 2048; static constexpr std::size_t kMaxSprites = 512; @@ -2515,6 +2524,7 @@ private: std::unique_ptr m_engine; std::unique_ptr m_supervisor; std::unique_ptr m_photoDisc; + std::unique_ptr m_oskiewarLive; }; ref class AppSource sealed : public IFrameworkViewSource { diff --git a/xbox/native-bios/NativeBios.vcxproj b/xbox/native-bios/NativeBios.vcxproj index 1b07268080..b6e6c3f4e3 100644 --- a/xbox/native-bios/NativeBios.vcxproj +++ b/xbox/native-bios/NativeBios.vcxproj @@ -44,6 +44,7 @@ Create + diff --git a/xbox/native-bios/OskiewarLivePublisher.cpp b/xbox/native-bios/OskiewarLivePublisher.cpp new file mode 100644 index 0000000000..f491f1d6e2 --- /dev/null +++ b/xbox/native-bios/OskiewarLivePublisher.cpp @@ -0,0 +1,230 @@ +#include "pch.h" +#include "OskiewarLivePublisher.hpp" + +using namespace Platform; +using namespace Windows::Foundation; +using namespace Windows::Networking::Sockets; +using namespace Windows::Storage::Streams; +using namespace concurrency; + +namespace ac::xbox { +namespace { + +std::wstring Wide(std::string_view value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0); + std::wstring result(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), + result.data(), size); + return result; +} + +bool ValidMatchId(std::string_view value) { + if (value.size() != 23 || value.substr(0, 3) != "ow-") return false; + for (std::size_t index = 3; index < value.size(); ++index) { + const auto c = value[index]; + if (!((c >= 'a' && c <= 'z') || c == '-')) return false; + } + return value[9] == '-' && value[16] == '-'; +} + +} // namespace + +struct OskiewarLivePublisher::State { + std::mutex mutex; + Logger logger; + MessageWebSocket^ socket = nullptr; + DataWriter^ writer = nullptr; + EventRegistrationToken message_token{}; + EventRegistrationToken closed_token{}; + bool subscribed = false; + bool connecting = false; + bool connected = false; + bool writing = false; + bool stopped = false; + std::uint64_t generation = 0; + std::string match_id; + std::string pending; +}; + +namespace { + +void Log(const std::shared_ptr& state, + const std::string& line) { + if (state->logger) state->logger(line); +} + +void Flush(const std::shared_ptr& state) { + std::string payload; + DataWriter^ writer = nullptr; + std::uint64_t generation = 0; + { + std::lock_guard lock(state->mutex); + if (state->stopped || !state->connected || state->writing || + state->pending.empty() || !state->writer) return; + payload = std::move(state->pending); + state->pending.clear(); + state->writing = true; + writer = state->writer; + generation = state->generation; + } + try { + const auto envelope = std::string("{\"type\":\"oskiewar:state\",\"content\":") + + payload + "}"; + writer->WriteString(ref new String(Wide(envelope).c_str())); + std::weak_ptr weak = state; + create_task(writer->StoreAsync()).then([weak, generation](task completed) { + const auto state = weak.lock(); + if (!state) return; + try { completed.get(); } + catch (Exception^ error) { + Log(state, "AC_NATIVE_OSKIEWAR_LIVE_SEND_ERROR"); + } + { + std::lock_guard lock(state->mutex); + if (generation != state->generation) return; + state->writing = false; + } + Flush(state); + }); + } catch (Exception^) { + std::lock_guard lock(state->mutex); + if (generation == state->generation) state->writing = false; + Log(state, "AC_NATIVE_OSKIEWAR_LIVE_SEND_ERROR"); + } +} + +void Connect(const std::shared_ptr& state) { + std::string match_id; + std::uint64_t generation = 0; + MessageWebSocket^ socket = nullptr; + { + std::lock_guard lock(state->mutex); + if (state->stopped || state->connecting || state->connected || + state->match_id.empty()) return; + state->connecting = true; + generation = state->generation; + match_id = state->match_id; + socket = ref new MessageWebSocket(); + socket->Control->MessageType = SocketMessageType::Utf8; + state->socket = socket; + } + std::weak_ptr weak = state; + const auto message_token = socket->MessageReceived += + ref new TypedEventHandler( + [weak](MessageWebSocket^, MessageWebSocketMessageReceivedEventArgs^ args) { + try { + auto reader = args->GetDataReader(); + reader->UnicodeEncoding = UnicodeEncoding::Utf8; + if (reader->UnconsumedBufferLength) + reader->ReadString(reader->UnconsumedBufferLength); + } catch (Exception^) {} + }); + const auto closed_token = socket->Closed += + ref new TypedEventHandler( + [weak, generation](IWebSocket^, WebSocketClosedEventArgs^) { + const auto state = weak.lock(); + if (!state) return; + std::lock_guard lock(state->mutex); + if (generation != state->generation) return; + state->connected = false; + state->connecting = false; + state->writing = false; + state->writer = nullptr; + state->socket = nullptr; + state->subscribed = false; + }); + { + std::lock_guard lock(state->mutex); + if (generation != state->generation) return; + state->message_token = message_token; + state->closed_token = closed_token; + state->subscribed = true; + } + const auto url = L"wss://session-server.aesthetic.computer/oskiewar-live?match=" + + Wide(match_id) + L"&role=publisher"; + create_task(socket->ConnectAsync(ref new Uri(ref new String(url.c_str())))).then( + [weak, generation, socket](task completed) { + const auto state = weak.lock(); + if (!state) return; + try { completed.get(); } + catch (Exception^) { + std::lock_guard lock(state->mutex); + if (generation == state->generation) { + state->connecting = false; + state->connected = false; + state->socket = nullptr; + } + Log(state, "AC_NATIVE_OSKIEWAR_LIVE_CONNECT_ERROR"); + return; + } + { + std::lock_guard lock(state->mutex); + if (state->stopped || generation != state->generation) return; + state->writer = ref new DataWriter(socket->OutputStream); + state->connecting = false; + state->connected = true; + } + Log(state, "AC_NATIVE_OSKIEWAR_LIVE_CONNECTED match=" + state->match_id); + Flush(state); + }); +} + +} // namespace + +OskiewarLivePublisher::OskiewarLivePublisher(Logger logger) + : state_(std::make_shared()) { + state_->logger = std::move(logger); +} + +OskiewarLivePublisher::~OskiewarLivePublisher() { shutdown(); } + +void OskiewarLivePublisher::publish(std::string_view match_id, + std::string_view state_json) { + if (!ValidMatchId(match_id) || state_json.size() < 2 || + state_json.size() > 7168 || state_json.front() != '{') return; + bool should_connect = false; + MessageWebSocket^ old_socket = nullptr; + { + std::lock_guard lock(state_->mutex); + if (state_->stopped) return; + if (state_->match_id != match_id) { + old_socket = state_->socket; + ++state_->generation; + state_->match_id.assign(match_id); + state_->writer = nullptr; + state_->socket = nullptr; + state_->connecting = false; + state_->connected = false; + state_->writing = false; + } + // Latest-only queue: a slow network never builds latency or memory. + state_->pending.assign(state_json); + should_connect = !state_->connecting && !state_->connected; + } + if (old_socket) try { + old_socket->Close(1000, ref new String(L"new match")); + } catch (Exception^) {} + if (should_connect) Connect(state_); + else Flush(state_); +} + +void OskiewarLivePublisher::shutdown() { + MessageWebSocket^ socket = nullptr; + { + std::lock_guard lock(state_->mutex); + if (state_->stopped) return; + state_->stopped = true; + ++state_->generation; + state_->pending.clear(); + socket = state_->socket; + state_->socket = nullptr; + state_->writer = nullptr; + } + if (socket) try { + socket->Close(1000, ref new String(L"leaving")); + } catch (Exception^) {} +} + +} // namespace ac::xbox diff --git a/xbox/native-bios/OskiewarLivePublisher.hpp b/xbox/native-bios/OskiewarLivePublisher.hpp new file mode 100644 index 0000000000..85d574f79b --- /dev/null +++ b/xbox/native-bios/OskiewarLivePublisher.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +namespace ac::xbox { + +// Fixed-destination, latest-only WebSocket transport for OSKIEWAR live state. +// This deliberately does not expose a general socket to downloaded pieces. +class OskiewarLivePublisher final { + public: + using Logger = std::function; + struct State; + + explicit OskiewarLivePublisher(Logger logger = {}); + ~OskiewarLivePublisher(); + OskiewarLivePublisher(const OskiewarLivePublisher&) = delete; + OskiewarLivePublisher& operator=(const OskiewarLivePublisher&) = delete; + + void publish(std::string_view match_id, std::string_view state_json); + void shutdown(); + + private: + std::shared_ptr state_; +}; + +} // namespace ac::xbox diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index 8e57910372..1259962897 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -5,11 +5,11 @@ IgnorableNamespaces="uap mp"> + Version="1.0.0.35" /> - AC Native BIOS + OSKIEWAR Aesthetic Computer Assets\StoreLogo.png @@ -21,8 +21,8 @@ - diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index 929fc8459a..170525fbe0 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -494,6 +494,32 @@ JSValue SaveReplay(JSContext* context, JSValueConst, int argc, JSValueConst* arg return JS_UNDEFINED; } +JSValue PublishLive(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api || !scope->api->live_publish || argc < 2) + return JS_UNDEFINED; + size_t matchLength = 0, stateLength = 0; + const char* rawMatch = JS_ToCStringLen(context, &matchLength, argv[0]); + if (!rawMatch) return JS_EXCEPTION; + const char* rawState = JS_ToCStringLen(context, &stateLength, argv[1]); + if (!rawState) { JS_FreeCString(context, rawMatch); return JS_EXCEPTION; } + const bool valid = matchLength == 23 && stateLength >= 2 && stateLength <= 7168 && + std::string_view(rawMatch, 3) == "ow-" && rawState[0] == '{' && + std::all_of(rawMatch + 3, rawMatch + matchLength, [](unsigned char character) { + return (character >= 'a' && character <= 'z') || character == '-'; + }); + if (!valid) { + JS_FreeCString(context, rawState); + JS_FreeCString(context, rawMatch); + return JS_ThrowRangeError(context, "invalid OSKIEWAR live payload"); + } + scope->api->live_publish(std::string_view(rawMatch, matchLength), + std::string_view(rawState, stateLength)); + JS_FreeCString(context, rawState); + JS_FreeCString(context, rawMatch); + return JS_UNDEFINED; +} + JSValue RuntimeInfo(JSContext* context, JSValueConst, int, JSValueConst*) { auto* scope = static_cast(JS_GetContextOpaque(context)); if (!scope || !scope->api) return JS_EXCEPTION; @@ -748,6 +774,7 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "telemetry", JS_NewCFunction(context_, Telemetry, "telemetry", 2)); JS_SetPropertyStr(context_, global, "gameSignal", JS_NewCFunction(context_, GameSignal, "gameSignal", 4)); JS_SetPropertyStr(context_, global, "saveReplay", JS_NewCFunction(context_, SaveReplay, "saveReplay", 1)); + JS_SetPropertyStr(context_, global, "publishLive", JS_NewCFunction(context_, PublishLive, "publishLive", 2)); JS_SetPropertyStr(context_, global, "runtime", JS_NewCFunction(context_, RuntimeInfo, "runtime", 0)); JS_SetPropertyStr(context_, global, "gamepad", JS_NewCFunction(context_, GamepadState, "gamepad", 1)); JS_SetPropertyStr(context_, global, "controllers", JS_NewCFunction(context_, Controllers, "controllers", 0)); diff --git a/xbox/native-bios/README.md b/xbox/native-bios/README.md index 2499bfab3f..19c09a0763 100644 --- a/xbox/native-bios/README.md +++ b/xbox/native-bios/README.md @@ -1,4 +1,4 @@ -# AC Native BIOS for Xbox +# OSKIEWAR for Xbox This is the no-WebView Aesthetic Computer host. It is a C++/CX UWP DirectX application with native Direct3D 11 rendering, XAudio2 sound, @@ -92,6 +92,11 @@ Revision 34 starts each OSKIEWAR OSC datagram immediately, preserves the three-copy reliability group without a queue race, and reports native enqueue-to-send latency for live Ableton diagnosis. +Revision 35 presents the installed Xbox app as OSKIEWAR while retaining the +stable `AestheticComputer.NativeBios` identity for in-place upgrades. It adds +the OSKIEWAR / NEW GAME boot selector, a two-pad input lab, and live match +spectator publishing. + Revision 13 also exposes bounded `stampPainting` and `blur` primitives to the trusted host-side KidLisp compiler. See [`../KIDLISP-NATIVE.md`](../KIDLISP-NATIVE.md) for the supported subset and `$obk` wire-deploy flow. diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index f3253e5012..3d355fd566 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -10,6 +10,7 @@ int main() { int telemetryCalls = 0; int gameSignalCalls = 0; int replayCalls = 0; + int liveCalls = 0; int discScans = 0, discShows = 0, discCopies = 0; api.telemetry = [&](std::string_view) { ++telemetryCalls; }; api.game_signal = [&](std::string_view event, int player, float value, float value2) { @@ -19,6 +20,9 @@ int main() { api.replay_save = [&](std::string_view replay) { if (replay == "{\"format\":\"ac.oskiedemo\"}") ++replayCalls; }; + api.live_publish = [&](std::string_view match, std::string_view state) { + if (match == "ow-bafegu-dorimi-kunapo" && state == "{\"seq\":1}") ++liveCalls; + }; auto disc = std::make_shared(); disc->status = "ready"; disc->volume = "D:"; disc->name = "PHOTO.JPG"; disc->count = 3; disc->index = 1; disc->width = 1600; disc->height = 1200; @@ -34,14 +38,14 @@ int main() { api.clock.network_synced = true; api.clock.network_offset_ms = 3; api.clock.network_rtt_ms = 21; api.audio.output_latency_ms = 11.5; api.audio.midi_status = "no-input"; api.audio.midi_gate = true; api.audio.midi_pitch_bend = 9000; - auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');gameSignal('bullet',1,.5,.25);saveReplay('{\"format\":\"ac.oskiedemo\"}');ac();if(!discScan())throw Error('disc scan')} function sim(){if(!gamepad().connected||!gamepad(0).connected||!gamepad(1).connected||gamepad(2).connected||gamepad(1).leftX!==-1)throw Error('indexed gamepads');drum('kick',1,0);const r=runtime();if(!r.clockSynced||r.clockOffsetMs!==3||r.audioLatencyMs!==11.5||r.midiStatus!=='no-input'||!r.midiGate||r.midiPitchBend!==9000)throw Error('runtime telemetry');const d=disc();if(d.status!=='ready'||d.volume!=='D:'||d.name!=='PHOTO.JPG'||d.count!==3||d.index!==1||d.width!==1600||d.height!==1200||!d.currentReady)throw Error('disc state');if(!discShow(-1))throw Error('disc show');capabilities();controllers();oscillator(220,.1)} function paint(){wipe(1,2,3);box(1,2,3,4,5,6,7);line(1,2,3,4,2,5,6,7);triangle(1,2,3,4,5,6,7,8,9);const batch=new Float32Array([1,2,.1,3,4,.1,5,6,.1,7,8,9,10,20,.2,30,40,.2,50,60,.2,70,80,90]);if(triangles3d(batch)!==2)throw Error('triangle batch');const textured=new Float32Array([1,2,.1,0,0,3,4,.1,1,0,5,6,.1,0,1,255,255,255]);if(texturedTriangles3d(textured,1)!==1)throw Error('texture batch');const sprites=new Float32Array([100,200,.3,16,255,80,90,1]);if(sprites3d(sprites,1)!==1)throw Error('sprite batch');write('OK',8,9,10,11,12,13);systemWrite('HI',20,30,40);ywftWrite('YWFT',20,70,40);systemGlyph('ButtonA',50,60,70);painting(80,90,100,110);stampPainting('#j8t',200,300,1);discPhoto(0,0,1920,1080);blur(4)} function act(b){if(b==='A')synth(440,.01);if(b==='B'){oscillatorStop();if(!discCopy())throw Error('disc copy')}}", "test"}, {}, error); + auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');gameSignal('bullet',1,.5,.25);saveReplay('{\"format\":\"ac.oskiedemo\"}');publishLive('ow-bafegu-dorimi-kunapo','{\"seq\":1}');ac();if(!discScan())throw Error('disc scan')} function sim(){if(!gamepad().connected||!gamepad(0).connected||!gamepad(1).connected||gamepad(2).connected||gamepad(1).leftX!==-1)throw Error('indexed gamepads');drum('kick',1,0);const r=runtime();if(!r.clockSynced||r.clockOffsetMs!==3||r.audioLatencyMs!==11.5||r.midiStatus!=='no-input'||!r.midiGate||r.midiPitchBend!==9000)throw Error('runtime telemetry');const d=disc();if(d.status!=='ready'||d.volume!=='D:'||d.name!=='PHOTO.JPG'||d.count!==3||d.index!==1||d.width!==1600||d.height!==1200||!d.currentReady)throw Error('disc state');if(!discShow(-1))throw Error('disc show');capabilities();controllers();oscillator(220,.1)} function paint(){wipe(1,2,3);box(1,2,3,4,5,6,7);line(1,2,3,4,2,5,6,7);triangle(1,2,3,4,5,6,7,8,9);const batch=new Float32Array([1,2,.1,3,4,.1,5,6,.1,7,8,9,10,20,.2,30,40,.2,50,60,.2,70,80,90]);if(triangles3d(batch)!==2)throw Error('triangle batch');const textured=new Float32Array([1,2,.1,0,0,3,4,.1,1,0,5,6,.1,0,1,255,255,255]);if(texturedTriangles3d(textured,1)!==1)throw Error('texture batch');const sprites=new Float32Array([100,200,.3,16,255,80,90,1]);if(sprites3d(sprites,1)!==1)throw Error('sprite batch');write('OK',8,9,10,11,12,13);systemWrite('HI',20,30,40);ywftWrite('YWFT',20,70,40);systemGlyph('ButtonA',50,60,70);painting(80,90,100,110);stampPainting('#j8t',200,300,1);discPhoto(0,0,1920,1080);blur(4)} function act(b){if(b==='A')synth(440,.01);if(b==='B'){oscillatorStop();if(!discCopy())throw Error('disc copy')}}", "test"}, {}, error); assert(piece && error.empty()); piece->boot(api); piece->paint(api); assert(graphics.color.r == 1 && graphics.color.g == 2 && graphics.color.b == 3); assert(graphics.boxes == 1 && graphics.lines == 1 && graphics.triangles == 3 && graphics.textured == 1 && graphics.sprites == 1 && graphics.writes == 1 && graphics.systemWrites == 2 && graphics.glyphs == 1 && graphics.images == 3 && graphics.blurs == 1 && graphics.lastImage.source == "disc-photo" && !graphics.lastImage.centered && telemetryCalls == 1 && gameSignalCalls == 1 && - replayCalls == 1 && discScans == 1); + replayCalls == 1 && liveCalls == 1 && discScans == 1); piece->sim(api); assert(sound.oscillators == 1 && sound.drums == 1 && discShows == 1); piece->act(api, {"A"}); assert(sound.calls == 1); piece->act(api, {"B"}); assert(sound.stops == 1 && discCopies == 1); diff --git a/xbox/runtime/include/ac/runtime.hpp b/xbox/runtime/include/ac/runtime.hpp index c139ba89fa..2176ca795c 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -227,6 +227,9 @@ struct Api { // A bounded, host-routed replay artifact. The host validates and uploads the // complete demo; pieces do not receive a URL, credential, or response body. std::function replay_save = {}; + // Latest-only live match state. The native host owns the fixed session-server + // destination; pieces receive no socket, URL, credential, or response data. + std::function live_publish = {}; }; class Piece { diff --git a/xbox/tools/live.mjs b/xbox/tools/live.mjs index cd59e69a9d..94ae1f40d2 100644 --- a/xbox/tools/live.mjs +++ b/xbox/tools/live.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node -// Device Portal control surface for the AC Native BIOS live JavaScript loop. +// Device Portal control surface for the OSKIEWAR native live JavaScript loop. // Designed for blueberry, where the Xbox vault credentials already live. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { basename, resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { compilePublishedKidLisp } from "./kidlisp-native.mjs"; const defaultEnv = resolve(homedir(), @@ -59,7 +60,7 @@ function packages() { function installed() { const item = packages()[0]; - if (!item) throw new Error("AC Native BIOS is not installed"); + if (!item) throw new Error("OSKIEWAR is not installed"); return item; } @@ -135,6 +136,15 @@ function publish(sourcePath) { if (!sourcePath) throw new Error("usage: xbox-live publish "); const absolute = resolve(sourcePath); if (!existsSync(absolute)) throw new Error(`piece not found: ${absolute}`); + let source = readFileSync(absolute, "utf8"); + if (source.startsWith("// @bundle-qr")) { + const qrPath = resolve(dirname(fileURLToPath(import.meta.url)), + "../../system/public/aesthetic.computer/dep/@akamfoad/qr/qr.mjs"); + const qrSource = readFileSync(qrPath, "utf8").replace( + /\nexport\s*\{[\s\S]*?\};\s*$/, "\n"); + source = qrSource + "\n" + source; + return publishSource(source, absolute + " + qr"); + } const item = installed(); curl(["-u", autoAuth, "-X", "POST", "-F", `file=@${absolute};filename=live-piece.js`, appFileUrl(item)]);