diff --git a/lith/Caddyfile b/lith/Caddyfile index 0274022b5e..911a505b44 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -1057,7 +1057,7 @@ oskiewar.com, midi.oskiewar.com { # answers 304 against its ETag and loads from disk. Without the header the # browser's heuristic kept whole modules stale across deploys — a returning # visitor once ran a months-old round-room.mjs against the current relay. - @oskiewarmodules path /oskiewar.js /oskiewar-sfx.mjs /oskiewar-voice.mjs /oskiewar-midi.mjs + @oskiewarmodules path /oskiewar.js /oskiewar-sfx.mjs /oskiewar-voice.mjs /oskiewar-midi.mjs /oskiewar-wizard.mjs handle @oskiewarmodules { root * /opt/ac/xbox/live header Cache-Control no-cache diff --git a/system/netlify/functions/oskiewar-consent.mjs b/system/netlify/functions/oskiewar-consent.mjs new file mode 100644 index 0000000000..c1cb864e49 --- /dev/null +++ b/system/netlify/functions/oskiewar-consent.mjs @@ -0,0 +1,223 @@ +// The consent wall behind oskiewar's "add yourself" door. +// +// A player who wants to become a fighter hands over material — their face, +// how they move, their voice — and the thing that makes that a transaction +// rather than a surrender is that they say in advance which parts the game +// may use, for what, and for how long. This endpoint is where that answer is +// asked and recorded. It is the only path to a generation capability: no +// receipt, no capability, no job. +// +// REGARDE itself is not in this repository and must not be. The prover is +// client code under NDA, so what lives here is a bridge: it shapes the +// player's answer into the DATA_OPERATION × GRANT_CONSENT request the desk +// expects, forwards it, and returns the outcome. Every substantive decision +// is made on the other side of REGARDE_GATEWAY_URL. +// +// Three properties this file exists to hold: +// +// 1. Fail closed. An unset gateway URL, a timeout, a malformed answer, a +// non-allow outcome — every one of them returns no capability. There is +// no branch where a generation token is issued on a guess. +// 2. No raw identity crosses the wire. The desk keys grants by pseudonymous +// sub, and its gateway refuses payloads carrying names, handles, or +// emails outright. The player's Auth0 subject is hashed with a +// purpose-scoped salt before it leaves this process, so REGARDE learns a +// stable oskiewar pseudonym and never the person. +// 3. Nothing defaults on. Voice, movement, biography, marketing, +// merchandise, and model training are each their own line in the scope. +// A player who ticks "appearance" has not thereby agreed to be heard, +// advertised, or trained on. +// +// See the working proposal (vault: regarde/proposals/oskiewar-regarde) for +// the pipeline this gates and the fighter bundle it eventually produces. + +import { createHash } from "node:crypto"; +import { authorize } from "../../backend/authorization.mjs"; + +const GATEWAY_TIMEOUT_MS = 6000; +const SCHEMA_VERSION = 1; +const PURPOSE = "oskiewar_fighter_generation"; + +// The scope vocabulary, split the way the player experiences it. These are +// closed sets on purpose: an unknown output or distribution is a refusal, not +// a pass-through, because a term REGARDE has not registered is a term nobody +// has decided the meaning of. +export const SOURCES = ["appearance", "movement", "voice", "biography"]; +export const OUTPUTS = ["portrait", "fighter_mesh", "fighter_animation", "match_audio"]; +export const DISTRIBUTION = ["private_preview", "local_gameplay", "online_play", + "tournament_display"]; +// Kept out of the lists above because they are not degrees of the same thing. +// A player can want to be playable online and still never want to be an +// advertisement, and the UI has to be able to say so in those words. +export const SEPARATE = ["marketing", "merchandise", "model_training"]; + +// Retention is chosen, never inherited. The proposal is explicit that neither +// source nor derived media may default to indefinite, so the absence of a +// choice here is an error rather than a fallback. +const RETENTION = ["bound_to_purpose_scope", "pilot_deadline"]; + +function fail(statusCode, message) { + return { + statusCode, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outcome: "refuse", capability: null, message }), + }; +} + +// A subset check that treats "not a list" and "contains something unregistered" +// as the same failure, because both mean the caller and the schema disagree. +function subsetOf(values, vocabulary) { + if (!Array.isArray(values)) return null; + const picked = [...new Set(values.map((value) => String(value)))]; + return picked.every((value) => vocabulary.includes(value)) ? picked : null; +} + +// The pseudonym. Salted so the same person is a different subject to a +// different purpose — an oskiewar grant cannot be correlated against any other +// REGARDE operation AC might one day run — and hashed so this side of the wire +// is the last place the Auth0 subject exists. +function pseudonym(sub, salt) { + return "sub_" + createHash("sha256").update(`${salt}:${PURPOSE}:${sub}`) + .digest("hex").slice(0, 32); +} + +// The frozen fields are the grant. Once this object is hashed into a receipt +// it is what the player actually agreed to, so it is built in one place and +// never edited downstream — a generation worker reads it, it does not amend it. +export function frozenFields(scope) { + return { + data_class: "digital_replica_source", + operation_kind: "GRANT_CONSENT", + purpose_scope: { + purpose: PURPOSE, + source: scope.source, + outputs: scope.outputs, + distribution: scope.distribution, + transform: "stylized", + ...Object.fromEntries(SEPARATE.map((key) => [key, scope[key] === true])), + }, + retention_constraint: scope.retention, + schema_version: SCHEMA_VERSION, + }; +} + +// Reading the player's answer. Everything is required and nothing is inferred: +// a missing field is a malformed ask, not an empty permission. +export function readScope(body) { + const source = subsetOf(body?.source, SOURCES); + if (!source?.length) return { error: "pick at least one kind of material" }; + const outputs = subsetOf(body?.outputs, OUTPUTS); + if (!outputs?.length) return { error: "pick at least one thing to make" }; + const distribution = subsetOf(body?.distribution, DISTRIBUTION); + if (!distribution?.length) return { error: "pick where it may appear" }; + if (!RETENTION.includes(body?.retention)) + return { error: "choose how long the material may be kept" }; + // The two couplings the proposal names by hand, because they are the ones a + // player would reasonably assume and be wrong about. Appearance does not + // imply voice; a voice output without the voice material is an ask for + // something nobody offered. + if (outputs.includes("match_audio") && !source.includes("voice")) + return { error: "match audio needs a voice sample you have offered" }; + if ((outputs.includes("fighter_mesh") || outputs.includes("fighter_animation")) && + !source.includes("appearance")) + return { error: "a body needs appearance references" }; + const scope = { source, outputs, distribution, retention: body.retention }; + for (const key of SEPARATE) scope[key] = body?.[key] === true; + return { scope }; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") + return { statusCode: 204, headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS" } }; + if (event.httpMethod !== "POST") return fail(405, "POST only."); + + // Anonymous play is the front door of this game and always will be, but it + // is not a door into a grant: a receipt has to belong to somebody who can + // later come back and withdraw it. + const user = await authorize(event.headers); + if (!user?.sub) return fail(401, "Sign in before you give anything away."); + + let body; + try { body = JSON.parse(event.body || "{}"); } + catch { return fail(400, "Unreadable answer."); } + + const { scope, error } = readScope(body); + if (error) return fail(400, error); + + const gateway = process.env.REGARDE_GATEWAY_URL; + const salt = process.env.REGARDE_SUBJECT_SALT; + // Unconfigured is refused rather than waved through. This is the branch that + // would otherwise quietly become "generation works, the wall is decorative." + if (!gateway || !salt) + return fail(503, "The consent desk is not reachable right now."); + + const request = { + venue: "oskiewar", + subject: pseudonym(user.sub, salt), + operation_type: "DATA_OPERATION", + frozen_fields: frozenFields(scope), + // Idempotent per subject per exact scope: asking the same question twice + // returns the same receipt instead of littering the chain with duplicates, + // while any change to the scope is a genuinely new ask. + idempotency_key: createHash("sha256") + .update(JSON.stringify([pseudonym(user.sub, salt), frozenFields(scope)])) + .digest("hex").slice(0, 32), + operation_descriptor: { + purpose: PURPOSE, + description: "Generate an oskiewar fighter from player-supplied material.", + }, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS); + let answer; + try { + const upstream = await fetch(gateway, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal: controller.signal, + }); + if (!upstream.ok) { + console.log(`🚪 regarde gateway ${upstream.status}`); + return fail(502, "The consent desk refused the ask."); + } + answer = await upstream.json(); + } catch (err) { + console.log(`🚪 regarde gateway unreachable: ${err.message}`); + return fail(504, "The consent desk did not answer."); + } finally { clearTimeout(timer); } + + // "allow" is the only outcome that opens anything. "edit" means the desk + // countered with narrower terms the player would have to accept as a fresh + // ask, "deny" and "refuse" mean no. None of the three produces a capability + // here, and the client is expected to show the counter rather than retry. + const outcome = String(answer?.outcome || "refuse"); + const receipt = answer?.receipt + ? { + decision: answer.receipt.decision, + hash: answer.receipt.hash ?? answer.receipt.jti ?? null, + issued_at: answer.receipt.iat ?? null, + expires_at: answer.receipt.exp ?? null, + } + : null; + + return { + statusCode: 200, + headers: { "Content-Type": "application/json", + "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ + outcome, + receipt, + scope: request.frozen_fields.purpose_scope, + counter: answer?.counter?.frozen_fields?.purpose_scope ?? null, + // Slice 1 proves the wall. The generation capability is the next thing + // to land behind it, and it stays null until there is a worker whose + // reach this grant can actually bound. + capability: null, + }), + }; +} diff --git a/system/tests/oskiewar-consent.test.mjs b/system/tests/oskiewar-consent.test.mjs new file mode 100644 index 0000000000..38b31e37bf --- /dev/null +++ b/system/tests/oskiewar-consent.test.mjs @@ -0,0 +1,97 @@ +// The consent wall's reading of a player's answer. +// +// The wall is the only door to a generation capability, so the interesting +// tests are the refusals: what a player did NOT tick must never survive into +// the frozen fields, and an ask the desk has no registered term for must fail +// rather than pass through. See the working proposal (vault: +// regarde/proposals/oskiewar-regarde) for the grant these fields become. +import assert from "node:assert/strict"; +import test from "node:test"; +import { readScope, frozenFields, SOURCES, OUTPUTS, DISTRIBUTION, SEPARATE } + from "../netlify/functions/oskiewar-consent.mjs"; + +// The narrowest answer the wall can send: look at me, make a picture, show +// nobody. Every test below is this with one thing changed. +const minimal = { + source: ["appearance"], + outputs: ["portrait"], + distribution: ["private_preview"], + retention: "bound_to_purpose_scope", +}; + +test("the minimal answer is a grant", () => { + const { scope, error } = readScope(minimal); + assert.equal(error, undefined); + assert.deepEqual(scope.source, ["appearance"]); +}); + +test("nothing defaults on", () => { + const { scope } = readScope(minimal); + // Marketing, merchandise and model training are the three a player is most + // likely to agree to by momentum. Silence is not agreement. + for (const key of SEPARATE) assert.equal(scope[key], false, key); + const frozen = frozenFields(scope).purpose_scope; + for (const key of SEPARATE) assert.equal(frozen[key], false, key); +}); + +test("an empty category is refused rather than read as permission", () => { + for (const key of ["source", "outputs", "distribution"]) + assert.ok(readScope({ ...minimal, [key]: [] }).error, key); +}); + +test("a term the schema has not registered fails the ask", () => { + // Not merely dropped: a word nobody has decided the meaning of must not + // travel to the desk under a grant that appears to cover it. + assert.ok(readScope({ ...minimal, outputs: ["portrait", "hologram"] }).error); + assert.ok(readScope({ ...minimal, source: ["dna"] }).error); + assert.ok(readScope({ ...minimal, retention: "forever" }).error); +}); + +test("retention is chosen, never inherited", () => { + assert.ok(readScope({ ...minimal, retention: undefined }).error); +}); + +test("appearance does not imply voice", () => { + // The coupling the proposal names by hand. A player who offered a face has + // not offered a throat, so match audio without a voice sample is an ask for + // something nobody put on the table. + const { error } = readScope({ ...minimal, outputs: ["portrait", "match_audio"] }); + assert.match(error, /voice/); + const allowed = readScope({ ...minimal, source: ["appearance", "voice"], + outputs: ["portrait", "match_audio"] }); + assert.equal(allowed.error, undefined); +}); + +test("a body needs appearance references", () => { + assert.ok(readScope({ ...minimal, source: ["biography"], + outputs: ["fighter_mesh"] }).error); +}); + +test("the frozen fields are the grant the proposal describes", () => { + const { scope } = readScope({ ...minimal, source: ["appearance", "movement"], + outputs: ["portrait", "fighter_animation"], + distribution: ["private_preview", "local_gameplay"] }); + const frozen = frozenFields(scope); + assert.equal(frozen.data_class, "digital_replica_source"); + assert.equal(frozen.operation_kind, "GRANT_CONSENT"); + assert.equal(frozen.purpose_scope.purpose, "oskiewar_fighter_generation"); + assert.equal(frozen.purpose_scope.transform, "stylized"); + assert.equal(frozen.retention_constraint, "bound_to_purpose_scope"); + assert.equal(frozen.schema_version, 1); +}); + +test("duplicate ticks cannot inflate a category", () => { + const { scope } = readScope({ ...minimal, source: ["appearance", "appearance"] }); + assert.deepEqual(scope.source, ["appearance"]); +}); + +test("the wall and the endpoint agree on the vocabulary", async () => { + // The card the player reads is built from its own copy of these lists. If + // the two drift, a box a player can tick becomes an unregistered term and + // the ask fails at the desk with nothing on screen to explain why. + const { readFile } = await import("node:fs/promises"); + const card = await readFile( + new URL("../../xbox/live/oskiewar-wizard.mjs", import.meta.url), "utf8"); + for (const value of [...SOURCES, ...OUTPUTS, ...DISTRIBUTION, ...SEPARATE]) + assert.ok(card.includes(`"${value}"`), `wall is missing ${value}`); +}); diff --git a/xbox/live/account.mjs b/xbox/live/account.mjs index 851f2d7f06..2b0f7fa1d9 100644 --- a/xbox/live/account.mjs +++ b/xbox/live/account.mjs @@ -290,6 +290,10 @@ export default function mountAccount({ sfx = () => {}, probe = true } = {}) { if (source === "otp") return await otp.token(); try { return await (await spaClient())?.getTokenSilently(); } catch { return null; } } + // Published on the account record because the consent wall needs it too: a + // REGARDE grant has to be keyed to somebody who can come back and withdraw + // it, and the wall is a separate module with no other way to ask who is here. + account.bearer = bearer; async function submit() { if (busy) return; diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 3c25978f37..d61161261c 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -281,6 +281,9 @@ // that nobody is signed in. globalThis.__oskiewarAccount = { ready: false, signedIn: false, handle: "", colors: [], + // Replaced by `account.mjs` with the real token accessor. Answering null + // until then is the honest state: nobody is signed in yet. + bearer: async () => null, }; // The game's way of asking for a panel: "login", "handle", or "" to shut // it. `account.mjs` replaces this with an accessor so a press lands on the @@ -292,6 +295,14 @@ // below need the fact, not the request. globalThis.__oskiewarAccountOpen = false; + // 🚪 The "add yourself" wall. Declared here, empty, because the game reads + // it every frame to decide whether to draw the invitation on the fighter's + // head — a ring nobody can open would be worse than no ring. `null` until + // `oskiewar-wizard.mjs` mounts, which is exactly what the Xbox and the reel + // renderer leave it as. + globalThis.__oskiewarWizard = null; + globalThis.__oskiewarWizardOpen = false; + // Keep a stable 1080-unit vertical coordinate system while allowing the // horizontal game surface to follow the browser's actual aspect ratio. // 16:9 therefore remains exactly 1920x1080, while 9:16 becomes 608x1080 @@ -435,6 +446,16 @@ oscillator.stop(now + .14); } + // Is a DOM panel standing in front of the canvas? Sign-in and the consent + // wall both take the whole keyboard and the whole pointer while they are + // up — they own text fields, checkboxes, Enter and Escape — so the game + // beneath them must not also receive the press. One predicate rather than + // a growing list at each guard. + function panelOpen() { + return globalThis.__oskiewarAccountOpen === true || + globalThis.__oskiewarWizardOpen === true; + } + function dismissTitle() { if (!titleOpen) return false; titleOpen = false; @@ -442,6 +463,24 @@ return true; } + // The head is carved out of the anywhere-button above. `tapTitle` makes the + // whole title frame START, which is right for a game somebody wants to + // play and wrong for the one place a player who wants to BE the fighter is + // already pointing. The circle comes from the game, which is the only + // thing that knows where the face went this frame; if no wizard is mounted + // the game publishes nothing and the face starts a fight like the rest of + // the screen. + function tapTitleHead(point) { + if (!titleOpen || !point) return false; + const head = globalThis.__oskiewarTouch?.titleHead; + const wizard = globalThis.__oskiewarWizard; + if (!head || !wizard) return false; + if (Math.hypot(point.x - head.x, point.y - head.y) > head.radius) return false; + unlockAudio(); + wizard.open(); + return true; + } + function tapTitle(point = null) { if (!titleOpen) return false; // Anywhere is the button. The bounded start target left the rest of @@ -1201,11 +1240,11 @@ addEventListener("keydown", (event) => { if (event.target instanceof HTMLButtonElement) return; - // Typing an email address must not also throw punches and start the - // game. Every unmapped key is "start" down here, so an open account - // panel has to take the whole keyboard, not just the mapped half — the - // panel handles its own Enter and Escape. - if (globalThis.__oskiewarAccountOpen) return; + // Typing an email address — or ticking a scope box — must not also throw + // punches and start the game. Every unmapped key is "start" down here, so + // an open panel has to take the whole keyboard, not just the mapped half; + // each panel handles its own Enter and Escape. + if (panelOpen()) return; const mapped = keyMap.get(event.code); if (!mapped) { if (tapTitle()) event.preventDefault(); @@ -1295,6 +1334,7 @@ // The wheel dollies; a trackpad pinch arrives as a ctrl-wheel and // dollies too; sideways scroll, or shift, turns the lens instead. canvas.addEventListener("wheel", (event) => { + if (panelOpen()) return; event.preventDefault(); const orbit = globalThis.__oskiewarTouch.orbit; const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1; @@ -1313,18 +1353,19 @@ }); let pinchScale = 1; canvas.addEventListener("gesturestart", (event) => { - if (globalThis.__oskiewarAccountOpen) return; + if (panelOpen()) return; event.preventDefault(); pinchScale = event.scale || 1; }, { passive: false }); canvas.addEventListener("gesturechange", (event) => { - if (globalThis.__oskiewarAccountOpen) return; + if (panelOpen()) return; event.preventDefault(); const scale = event.scale || 1; globalThis.__oskiewarTouch.orbit.zoom -= Math.log(scale / pinchScale); pinchScale = scale; }, { passive: false }); canvas.addEventListener("pointerdown", (event) => { + if (globalThis.__oskiewarWizardOpen) return; const point = canvasPoint(event); const touchKey = touchKeyAt(point); if (touchKey) { @@ -1336,7 +1377,7 @@ unlockAudio(); return; } - if (tapTitle(point)) { + if (tapTitleHead(point) || tapTitle(point)) { event.preventDefault(); return; } @@ -1397,6 +1438,7 @@ import createOskiewarMidi from "/oskiewar-midi.mjs"; import createFrameDriver from "/frame-driver.mjs"; import mountAccount from "/account.mjs"; + import mountWizard from "/oskiewar-wizard.mjs"; import { qrcode } from "/aesthetic.computer/dep/@akamfoad/qr/qr.mjs"; import { captureOskiewarAction, @@ -1419,6 +1461,16 @@ probe: !socialPreview, }); + // 🚪 The consent wall, mounted after the door it depends on. Kept out of a + // social-preview render for the same reason the account probe is: that pass + // draws a picture of the title and must not grow furniture a viewer of the + // picture could never press. + if (!socialPreview) + globalThis.__oskiewarWizard = mountWizard({ + sfx: (name, amount, pan) => oskiewarSfx.drum(name, amount, pan), + bearer: () => globalThis.__oskiewarAccount.bearer(), + }); + // The fighters' throats and the announcer. Same context, same signal bus, // separate bank — the drums are the room and the voice is the person in // it, and they want different limiters. `?voice=off` for a silent cast, diff --git a/xbox/live/oskiewar-wizard.mjs b/xbox/live/oskiewar-wizard.mjs new file mode 100644 index 0000000000..24c8e82d1f --- /dev/null +++ b/xbox/live/oskiewar-wizard.mjs @@ -0,0 +1,269 @@ +// The "add yourself" wall — the popover behind the head on the title screen. +// +// Every other door in this shell starts a fight. This one asks a question +// first, and the question is the product: which parts of yourself the game may +// use, to make what, to be seen where. The player answers before any material +// is collected and long before any model is invoked, because a scope offered +// after the upload is a formality and a scope offered before it is a decision. +// +// DOM rather than canvas, for the same reason the sign-in door is DOM: these +// are checkboxes and they have to be reachable by a screen reader, a thumb, a +// gamepad's on-screen cursor, and a keyboard. A hand-rolled canvas checkbox +// would be none of those. +// +// This module holds no policy. It renders the ask, collects the answer, and +// sends it to /api/oskiewar-consent, which is the only thing here that can +// reach REGARDE. An outcome other than "allow" ends the flow — there is no +// retry loop, because asking the same desk the same question until it says yes +// is how a consent wall becomes a nag screen. + +const ENDPOINT = "/api/oskiewar-consent"; + +// The scope card, in the order a person thinks about it: what of mine, to +// make what, seen where, kept how long. `note` is the sentence that stops a +// reasonable wrong assumption before it is made. +const SECTIONS = [ + { key: "source", legend: "what of yours", kind: "check", rows: [ + { value: "appearance", label: "how you look", + note: "photos or a description you write", on: true }, + { value: "movement", label: "how you move" }, + { value: "voice", label: "your voice", + note: "a recording, used only for what you tick below" }, + { value: "biography", label: "something about you" }, + ] }, + { key: "outputs", legend: "to make", kind: "check", rows: [ + { value: "portrait", label: "a portrait", on: true }, + { value: "fighter_mesh", label: "a body to fight in" }, + { value: "fighter_animation", label: "the way that body moves" }, + { value: "match_audio", label: "a voice in the match", + note: "needs the voice sample above" }, + ] }, + { key: "distribution", legend: "seen where", kind: "check", rows: [ + { value: "private_preview", label: "only you, as a preview", on: true }, + { value: "local_gameplay", label: "fights on your own machine" }, + { value: "online_play", label: "fights against other people" }, + { value: "tournament_display", label: "shown at a tournament" }, + ] }, + { key: "retention", legend: "kept how long", kind: "radio", rows: [ + { value: "bound_to_purpose_scope", label: "only while this grant stands", + on: true }, + { value: "pilot_deadline", label: "until the pilot ends, then purged" }, + ] }, +]; + +// Held apart from the card above, and worded as refusals, because these are +// the three a player is most likely to agree to by momentum. Nothing here is +// a degree of playing the game; each is a different thing being done with a +// person. +const SEPARATE = [ + { value: "marketing", label: "may advertise the game" }, + { value: "merchandise", label: "may be printed on something sold" }, + { value: "model_training", label: "may train a model" }, +]; + +const STYLE = ` +#wizard-panel { position: fixed; inset: 0; z-index: 21; + display: grid; place-items: center; padding: 20px; + background: rgba(4, 7, 18, .86); backdrop-filter: blur(3px); } +#wizard-panel[hidden] { display: none; } +#wizard-card { width: 100%; max-width: 31rem; max-height: 88vh; overflow: auto; + display: flex; flex-direction: column; gap: 14px; + padding: 22px; border: 3px solid #6e768d; border-radius: 14px; + background: #171b28; box-shadow: 5px 7px 0 rgba(4, 7, 18, .55); + font: 400 16px/1.4 "Comic Relief", "Comic Sans MS", Arial, sans-serif; + color: #f3f6ff; } +#wizard-card h2 { margin: 0; font-size: 26px; font-weight: 700; } +#wizard-bargain { margin: 0; font-size: 15px; color: #b0b8ca; } +#wizard-card fieldset { margin: 0; padding: 12px 13px; border: 2px solid #3b4256; + border-radius: 9px; display: flex; flex-direction: column; gap: 9px; } +#wizard-card legend { padding: 0 6px; font-size: 14px; color: #b0b8ca; + text-transform: lowercase; } +#wizard-card fieldset.separate { border-color: #7a5560; } +#wizard-card label.row { display: flex; gap: 10px; align-items: flex-start; + cursor: pointer; } +#wizard-card label.row input { margin: 3px 0 0; width: 20px; height: 20px; + accent-color: #e6cd5c; flex: 0 0 auto; } +#wizard-card .row span { display: block; } +#wizard-card .row .note { font-size: 13px; color: #b0b8ca; } +#wizard-note { margin: 0; min-height: 1.4em; font-size: 15px; color: #b0b8ca; } +#wizard-note.trouble { color: #ff9a8a; } +#wizard-note.settled { color: #9ce6a8; } +#wizard-receipt { margin: 0; font-size: 13px; color: #b0b8ca; + word-break: break-all; } +#wizard-actions { display: flex; flex-wrap: wrap; gap: 10px; } +#wizard-panel button { appearance: none; flex: 1 1 auto; + padding: 11px 14px; border: 2px solid #6e768d; border-radius: 9px; + background: #b0b8ca; color: #171b28; + font: 700 17px/1 "Comic Relief", "Comic Sans MS", Arial, sans-serif; + box-shadow: 2px 3px 0 rgba(4, 7, 18, .45); cursor: pointer; } +#wizard-panel button:hover { background: #d9dfee; } +#wizard-panel button:focus-visible { outline: 3px solid #e6cd5c; + outline-offset: 2px; } +#wizard-panel button[disabled] { opacity: .55; cursor: default; } +#wizard-panel #wizard-back { flex: 0 1 auto; background: #3b4256; + color: #e8ecf8; } +#wizard-panel #wizard-back:hover { background: #4d566f; } +#wizard-panel.working #wizard-card { opacity: .7; } +`; + +export default function mountWizard({ sfx = () => {}, bearer = async () => null } = {}) { + const style = document.createElement("style"); + style.textContent = STYLE; + document.head.append(style); + + const panel = document.createElement("div"); + panel.id = "wizard-panel"; + panel.hidden = true; + panel.innerHTML = ` +
`; + document.body.append(panel); + + const card = panel.querySelector("#wizard-card"); + const sections = panel.querySelector("#wizard-sections"); + const note = panel.querySelector("#wizard-note"); + const receiptLine = panel.querySelector("#wizard-receipt"); + const go = panel.querySelector("#wizard-go"); + const back = panel.querySelector("#wizard-back"); + let busy = false; + + const row = (name, kind, { value, label, note: hint, on }) => { + const wrap = document.createElement("label"); + wrap.className = "row"; + const input = document.createElement("input"); + input.type = kind === "radio" ? "radio" : "checkbox"; + input.name = name; + input.value = value; + input.checked = on === true; + const text = document.createElement("span"); + text.innerHTML = `${label}` + + (hint ? `${hint}` : ""); + wrap.append(input, text); + return wrap; + }; + + for (const section of SECTIONS) { + const set = document.createElement("fieldset"); + const legend = document.createElement("legend"); + legend.textContent = section.legend; + set.append(legend, ...section.rows.map((r) => row(section.key, section.kind, r))); + sections.append(set); + } + const separate = document.createElement("fieldset"); + separate.className = "separate"; + const separateLegend = document.createElement("legend"); + separateLegend.textContent = "asked separately, off unless you say so"; + separate.append(separateLegend, + ...SEPARATE.map((r) => row("separate", "check", r))); + sections.append(separate); + + const picked = (name) => [...card.querySelectorAll(`input[name="${name}"]`)] + .filter((input) => input.checked).map((input) => input.value); + + function working(state) { + busy = state; + panel.classList.toggle("working", state); + go.disabled = state; + for (const input of card.querySelectorAll("input")) input.disabled = state; + } + + function say(message, tone = "") { + note.textContent = message; + note.className = tone; + } + + function open() { + if (!panel.hidden) return; + panel.hidden = false; + globalThis.__oskiewarWizardOpen = true; + say(""); + receiptLine.textContent = ""; + sfx("block", .9, 0); + card.querySelector("input")?.focus(); + } + + function close() { + if (panel.hidden) return; + panel.hidden = true; + globalThis.__oskiewarWizardOpen = false; + working(false); + } + + back.addEventListener("click", close); + // Escape is the only way out that does not touch the answer, which matters + // on a panel whose accidental submit would be a grant. + addEventListener("keydown", (event) => { + if (event.key === "Escape" && !panel.hidden) { event.preventDefault(); close(); } + }); + + card.addEventListener("submit", async (event) => { + event.preventDefault(); + if (busy) return; + const token = await bearer(); + // Said plainly rather than by bouncing them to the sign-in door: a grant + // has to belong to somebody who can come back and withdraw it, and that is + // a reason worth reading. + if (!token) { + say("Sign in first — a grant has to belong to someone who can withdraw it.", + "trouble"); + return; + } + const answer = { + source: picked("source"), + outputs: picked("outputs"), + distribution: picked("distribution"), + retention: picked("retention")[0], + }; + for (const { value } of SEPARATE) answer[value] = picked("separate").includes(value); + + working(true); + say("Asking the consent desk…"); + let result; + try { + const response = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json", + authorization: "Bearer " + token }, + body: JSON.stringify(answer), + }); + result = await response.json(); + } catch { + working(false); + say("The consent desk did not answer. Nothing was collected.", "trouble"); + return; + } + working(false); + + if (result?.outcome === "allow") { + sfx("hit", .9, 0); + say("Allowed, and recorded. Nothing has been generated yet.", "settled"); + receiptLine.textContent = result.receipt?.hash + ? `receipt ${result.receipt.hash}` : ""; + return; + } + if (result?.outcome === "edit") { + // The desk countered. Shown, never auto-accepted: narrower terms are + // still terms, and they are the player's to take or leave. + say("The desk offered narrower terms instead. Adjust and ask again.", + "trouble"); + receiptLine.textContent = result.counter + ? "offered: " + JSON.stringify(result.counter) : ""; + return; + } + say(result?.message || "Not granted. No material was taken and nothing was made.", + "trouble"); + receiptLine.textContent = ""; + }); + + return { open, close, get isOpen() { return !panel.hidden; } }; +} diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index dcba3b9a94..5f65f8646d 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -14586,6 +14586,66 @@ function drawSafeZones() { drawCornerCrops(actionSafeRect(), 34, 2, [105, 255, 118]); } +// The head is the second door on the title screen. START is still anywhere — +// `tapTitle` in the shell makes the whole frame the button — so the one place +// that must NOT start a fight is the face, because that is where a player who +// wants to BE the fighter is already looking. The game publishes the head's +// screen circle and the shell hit-tests it ahead of the anywhere-button; the +// ring is drawn here rather than in the shell because only the game knows +// where the head went this frame. +// +// Nothing is drawn unless a wizard is actually mounted (`__oskiewarWizard`), +// so the Xbox and the reel renderer — which have no DOM to open — never show +// an affordance that would dud. +function drawTitleHeadDoor(t, ink, suppressed) { + const touch = globalThis.__oskiewarTouch; + if (!touch) return; + const onTitle = shellMode === "MENU" && titleTransitionAt === null; + if (!onTitle || suppressed || !globalThis.__oskiewarWizard) { + if (touch.titleHead) touch.titleHead = null; + return; + } + // Only over a seat that is actually yours. The attract loop's "action" + // variant dresses BOTH fighters as bots (`beginTraining`), and a ring that + // says "add yourself" on a bot's head invites a player to replace somebody + // who was never there. No local body on screen, no door. + const seat = players[0]; + if (!seat || !seat.alive || seat.npc || seat.bot) { touch.titleHead = null; return; } + const head = runnerWorldGeometry(seat, t).head; + if (!head) { touch.titleHead = null; return; } + // Radius in screen units is measured, not guessed: the title camera pushes + // in and out across the attract loop, and a fixed pixel radius would drift + // off the face. Same idiom the killcam tears use. + const point = projectPoint(head.x, head.y, head.z); + const edge = projectPoint(head.x + head.radius, head.y, head.z); + const radius = Math.max(9, Math.hypot(edge.x - point.x, edge.y - point.y)); + const reach = radius * 1.22; + touch.titleHead = { x: point.x, y: point.y, radius: reach }; + // On the screen-UI lane, so a fighter standing in front of their own face + // cannot draw through the invitation. + triangleDepth = -1.42; + const pulse = (Math.sin(t * 2.4) + 1) / 2; + // Gold on its own rather than mixed toward the title ink: ink is near-white + // on the night sky and near-black at noon, so mixing gave a different — and + // on the light theme, muddy — colour every hour. The dark under-ring is what + // carries it across both, the same trick the type beside it uses. + const glow = mixColor([230, 205, 92], [255, 250, 226], pulse * .55); + const stroke = Math.max(1.5, radius * .085); + circle(point.x, point.y, reach, stroke * 2.1, [16, 20, 34]); + circle(point.x, point.y, reach, stroke, glow); + // The words ride a plate. A meditating fighter fills the whole column under + // their own chin, so unplated type landed on shoulders and arms and read as + // part of the body; the plate is dark on both themes because the gold is. + const label = "add yourself"; + const size = Math.max(11, Math.round(radius * .44)); + const width = handleWidth(label, size); + const padX = Math.round(size * .55), padY = Math.round(size * .34); + const left = point.x - width / 2; + const top = point.y + reach + size * .45; + box(left - padX, top - padY, width + padX * 2, size + padY * 2, 16, 20, 34); + typeWrite(label, left, top, size, ...glow); +} + function gamePaint() { syncGameView(); const run = runtime(); @@ -14886,6 +14946,7 @@ function gamePaint() { // piece of world could have drawn straight through it. drawFrameMeter(); drawImpacts(); + drawTitleHeadDoor(t, titleInk, reelMinimal); const counting = !roundResult && introAge < roundIntroDurationUs(); // 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. diff --git a/xbox/live/render-social-preview.mjs b/xbox/live/render-social-preview.mjs index f2bb8d1906..748740f633 100644 --- a/xbox/live/render-social-preview.mjs +++ b/xbox/live/render-social-preview.mjs @@ -44,6 +44,7 @@ function fileFor(pathname) { // and the capture comes out empty rather than loudly wrong. if (["/oskiewar.js", "/oskiewar-sfx.mjs", "/oskiewar-voice.mjs", "/oskiewar-midi.mjs", "/frame-driver.mjs", "/account.mjs", + "/oskiewar-wizard.mjs", "/round-room.mjs"].includes(pathname)) return join(live, pathname.slice(1)); if (pathname === "/aesthetic.computer/lib/auth0-otp.mjs") return join(repo, "system/public/aesthetic.computer/lib/auth0-otp.mjs"); diff --git a/xbox/live/social/manifest.json b/xbox/live/social/manifest.json index 1761ee61f4..91dc889b52 100644 --- a/xbox/live/social/manifest.json +++ b/xbox/live/social/manifest.json @@ -1,7 +1,7 @@ { "format": "ac.oskiewar.social-preview", "version": 2, - "build": "108964412d2cd9bb", + "build": "322bd944fde1ebc2", "theme": "light", "imageWidth": 1200, "imageHeight": 630, diff --git a/xbox/live/social/oskiewar-title.jpg b/xbox/live/social/oskiewar-title.jpg index 3f15dce1bd061a14d20ecd5ffff246b80128f1b1..831995ecd24e8b26f9388650a2931f7cd447637e 100644 GIT binary patch literal 62829 zcmex=y
zQ&XOc{@(Rk-sNYd$sX=o57zhDMEB;%Eqa*V=k5FK)|1p@=Yx&qZM9Y@E$I!pJx?_>
z#QoFu(yT>`w4_&jE?iu-z4~I`MLX$r+oVpec(vMCs@m7T?CqXqee0G@o3}{3YU=tv
zclCJIn>B&_9?#@zo0hj64_WB_Zgup%$-n08F25P{ih;!uGJefA^V+`)w#pBmYt?pZ
zoL>3zy}=@#DuS~+{p
z#ay>VAD%gUE1z+>d3Dq5%~O}kTgRHdKKaL6lHs52OX<*aA=y#0SFL)}(YCKtN$cB%
z=Mxj>Fg@AQUma=Z9_^abT>9#H`P;cC=KtOy7ZhmnJI&(u_CnQF-~QN(uSw0*ZMF0J
zv`JF%!kyJ|9sSj!R+baDT1vf-+U4qdF)&O_>rLCuX@Mm#bl>F%x4fN}%#)U??s?C^
zOY`g2+kc`>+wA@`=s$K16t7p>yh{J!fxT&egNnTRo-+?Pxo*i9m=GOY;sF`|zF5
zi4*3Z;a?W2uN}EPHDlYGE#cj9j~TDT2u%*!aO$^d(%y;J+&8Nxt+Jct5_?lS)a=WQ
zC+_Mmz6r11^t$bwv~}=~oOwrARk^nZzt)@ODLY$h+NI++v);Yv@mJgZ-cu!SqE^K0
zz2`p8oLr@GFZSWnQ^y0di@x`S8(;bMHUB?@^V4NAPAM|H*?+sEK6+$j-S0O)Y1Hj%
zeX)DuO_?W=4`TnyeX`yC;%beo{QBDI*6GGw#@qR_0_Lmjk$*kY^wzf%7jMZdO^*x?
zUhQiB=JkHQJgbW1?^Vyg*N$Cu