diff --git a/lith/Caddyfile b/lith/Caddyfile index ebb3aeb4a..80e736b81 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -866,7 +866,7 @@ oskiewar.com { redir @oskiewarwatchmatch https://oskiewar.com/{re.oskiewarwatchmatch.1} 308 @oskiewarwatchroot path /watch /watch/ redir @oskiewarwatchroot https://oskiewar.com/ 308 - @oskiewarround path_regexp oskiewarround ^/([bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]-[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]-[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou])/?$ + @oskiewarround path_regexp oskiewarround ^/(([a-z]{4,7}[0-9]{1,3})|([bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]-[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]-[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]))/?$ handle @oskiewarround { root * /opt/ac/xbox/live rewrite * /mac-test.html diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs index ffad2afc9..d0cd5e2dc 100644 --- a/session-server/oskiewar-live-manager.mjs +++ b/session-server/oskiewar-live-manager.mjs @@ -2,8 +2,10 @@ // 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 MATCH_ID = new RegExp(`^ow-${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}$`); +const MATCH_NAME = new RegExp( + `^(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); +const MATCH_ID = new RegExp( + `^ow-(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); const PHASES = new Set(["select", "intro", "fight", "round", "match", "replay"]); const MAX_MESSAGE_BYTES = 8192; const MAX_VIEWERS = 64; @@ -76,7 +78,7 @@ export function validateOskiewarLiveState(value) { 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)) + !/^\/api\/oskiewar-replays\?id=ow-[a-z0-9-]+$/.test(value.replayUrl)) return "Invalid replay URL"; return null; } diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs index 3bc9bb34d..0f2f7b5c8 100644 --- a/session-server/oskiewar-live-manager.test.mjs +++ b/session-server/oskiewar-live-manager.test.mjs @@ -29,6 +29,8 @@ 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("sezzi7"), "ow-sezzi7"); + assert.equal(canonicalMatchId("ow-shuppy652"), "ow-shuppy652"); assert.equal(canonicalMatchId("short-name"), null); }); diff --git a/system/backend/tests/oskiewar-replays.test.mjs b/system/backend/tests/oskiewar-replays.test.mjs index bf9568685..db6f66d55 100644 --- a/system/backend/tests/oskiewar-replays.test.mjs +++ b/system/backend/tests/oskiewar-replays.test.mjs @@ -15,6 +15,8 @@ const demo = { test("accepts a bounded versioned demo stream", () => { assert.equal(validateDemo(demo), null); + assert.equal(validateDemo({ ...demo, matchName: "shuppy652", + matchId: "ow-shuppy652" }), null); }); test("rejects unknown formats and malformed command streams", () => { diff --git a/system/netlify/functions/oskiewar-replays.mjs b/system/netlify/functions/oskiewar-replays.mjs index 7ea458434..2370cfde4 100644 --- a/system/netlify/functions/oskiewar-replays.mjs +++ b/system/netlify/functions/oskiewar-replays.mjs @@ -8,8 +8,10 @@ import { respond } from "../../backend/http.mjs"; const COLLECTION = "oskiewar-replays"; const MAX_BYTES = 524288; const MATCH_WORD = "[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]"; -const MATCH_NAME = new RegExp(`^${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}$`); -const MATCH_ID = new RegExp(`^ow-${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}$`); +const MATCH_NAME = new RegExp( + `^(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); +const MATCH_ID = new RegExp( + `^ow-(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); const finite = (value, limit = 1000000000) => Number.isFinite(value) && Math.abs(value) <= limit; diff --git a/system/public/aesthetic.computer/disks/oskiewar.mjs b/system/public/aesthetic.computer/disks/oskiewar.mjs index 1edfb16b8..976d58e47 100644 --- a/system/public/aesthetic.computer/disks/oskiewar.mjs +++ b/system/public/aesthetic.computer/disks/oskiewar.mjs @@ -2,7 +2,7 @@ // Opens the standalone OSKIEWAR game, live room, or replay suite. const WORD = "(?:[bdfgklmnprstvz][aeiou]){3}"; -const ROUND_NAME = new RegExp(`^${WORD}-${WORD}-${WORD}$`); +const ROUND_NAME = new RegExp(`^(?:${WORD}-${WORD}-${WORD}|[a-z]{4,7}[0-9]{1,3})$`); function target({ colon = [], params = [] } = {}) { const candidate = String(colon[0] || params[0] || "").toLowerCase() diff --git a/system/tests/oskiewar-live-viewer.test.mjs b/system/tests/oskiewar-live-viewer.test.mjs index 750296ccb..88ee4601b 100644 --- a/system/tests/oskiewar-live-viewer.test.mjs +++ b/system/tests/oskiewar-live-viewer.test.mjs @@ -12,5 +12,7 @@ test("a colon round opens the same raw room URL", () => { const name = "bafegu-dorimi-kunapo"; assert.equal(target({ colon: [name] }), `https://oskiewar.com/${name}`); assert.equal(target({ params: [`ow-${name}`] }), `https://oskiewar.com/${name}`); + assert.equal(target({ colon: ["sezzi7"] }), "https://oskiewar.com/sezzi7"); + assert.equal(target({ colon: ["shuppy652"] }), "https://oskiewar.com/shuppy652"); assert.equal(target({ colon: ["not-a-round"] }), "https://oskiewar.com/"); }); diff --git a/xbox/live/hello.js b/xbox/live/hello.js index 23039d2e0..54d5adb92 100644 --- a/xbox/live/hello.js +++ b/xbox/live/hello.js @@ -261,17 +261,16 @@ let roundViewerDemo = null; let roundViewerDemoStartedAt = 0; function pronounceableMatchName() { + const onsets = ["b", "d", "f", "g", "k", "l", "m", "n", "p", "r", + "s", "t", "v", "z", "ch", "sh", "th"]; const consonants = "bdfgklmnprstvz"; const vowels = "aeiou"; - const word = () => { - let result = ""; - for (let index = 0; index < 6; index++) { - const alphabet = index % 2 === 0 ? consonants : vowels; - result += alphabet[Math.floor(Math.random() * alphabet.length)]; - } - return result; - }; - return word() + "-" + word() + "-" + word(); + const onset = onsets[Math.floor(Math.random() * onsets.length)]; + const vowel = vowels[Math.floor(Math.random() * vowels.length)]; + const middle = consonants[Math.floor(Math.random() * consonants.length)]; + const ending = (vowels + "y")[Math.floor(Math.random() * (vowels.length + 1))]; + return onset + vowel + middle + middle + ending + + Math.floor(Math.random() * 1000); } function demoTick(now) { diff --git a/xbox/live/round-room.mjs b/xbox/live/round-room.mjs index 3686c8498..06ab51064 100644 --- a/xbox/live/round-room.mjs +++ b/xbox/live/round-room.mjs @@ -2,7 +2,7 @@ // Keeps live and stored-demo delivery outside the shared game/render engine. const WORD = "(?:[bdfgklmnprstvz][aeiou]){3}"; -const ROUND_NAME = new RegExp(`^${WORD}-${WORD}-${WORD}$`); +const ROUND_NAME = new RegExp(`^(?:${WORD}-${WORD}-${WORD}|[a-z]{4,7}[0-9]{1,3})$`); export function roundNameFromPath(pathname) { const name = String(pathname || "").replace(/^\/+|\/+$/g, "").toLowerCase(); diff --git a/xbox/live/tests/hello.test.mjs b/xbox/live/tests/hello.test.mjs index 85af57fc7..3312a5194 100644 --- a/xbox/live/tests/hello.test.mjs +++ b/xbox/live/tests/hello.test.mjs @@ -131,7 +131,7 @@ test("active matches publish bounded phone spectator snapshots", () => { 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.match(matchId, /^ow-[a-z]{5,6}[0-9]{1,3}$/); assert.equal(frame.format, "ac.oskiewar.live"); assert.equal(frame.fighters.length, 2); assert.ok(frame.camera.width >= 100); @@ -713,8 +713,7 @@ test("first to five round wins takes the match", () => { assert.equal(demo.format, "ac.oskiedemo"); assert.equal(demo.version, 1); assert.equal(demo.winner, "@JEFFREY"); - assert.match(demo.matchName, - /^(?:[bdfgklmnprstvz][aeiou]){3}-(?:[bdfgklmnprstvz][aeiou]){3}-(?:[bdfgklmnprstvz][aeiou]){3}$/); + assert.match(demo.matchName, /^[a-z]{5,6}[0-9]{1,3}$/); assert.equal(demo.matchId, "ow-" + demo.matchName); assert.equal(demo.roundId, demo.matchId); assert.equal(demo.roundIndex, 4); diff --git a/xbox/live/tests/round-room.test.mjs b/xbox/live/tests/round-room.test.mjs index d60624674..0be93c2af 100644 --- a/xbox/live/tests/round-room.test.mjs +++ b/xbox/live/tests/round-room.test.mjs @@ -12,6 +12,8 @@ class Socket { Socket.all = []; test("raw paths recognize only pronounceable round IDs", () => { + assert.equal(roundNameFromPath("/sezzi7"), "sezzi7"); + assert.equal(roundNameFromPath("/shuppy652"), "shuppy652"); assert.equal(roundNameFromPath("/bafegu-dorimi-kunapo"), "bafegu-dorimi-kunapo"); assert.equal(roundNameFromPath("/bafegu-dorimi-kunapo/"), @@ -21,7 +23,7 @@ test("raw paths recognize only pronounceable round IDs", () => { test("a live room changes its URL and socket when the next round arrives", async () => { const paths = [], messages = []; - const room = new RoundRoom("bafegu-dorimi-kunapo", { + const room = new RoundRoom("sezzi7", { WebSocketImpl: Socket, fetchImpl: async () => ({ ok: false }), historyImpl: { replaceState(_state, _title, path) { paths.push(path); } }, @@ -29,10 +31,10 @@ test("a live room changes its URL and socket when the next round arrives", async room.start((message) => messages.push(message)); const first = Socket.all.at(-1); first.event("message", JSON.stringify({ type: "oskiewar:state", content: { - nextRoundId: "ow-fagori-buneta-kovisu", + nextRoundId: "ow-shuppy652", } })); - assert.deepEqual(paths, ["/fagori-buneta-kovisu"]); - assert.match(Socket.all.at(-1).url, /match=ow-fagori-buneta-kovisu$/); + assert.deepEqual(paths, ["/shuppy652"]); + assert.match(Socket.all.at(-1).url, /match=ow-shuppy652$/); assert.equal(messages.at(-1).type, "round"); room.stop(); }); diff --git a/xbox/native-bios/OskiewarLivePublisher.cpp b/xbox/native-bios/OskiewarLivePublisher.cpp index 9428d6bb7..e59734353 100644 --- a/xbox/native-bios/OskiewarLivePublisher.cpp +++ b/xbox/native-bios/OskiewarLivePublisher.cpp @@ -21,12 +21,24 @@ std::wstring Wide(std::string_view value) { } 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; + if (value.substr(0, 3) != "ow-") return false; + // Legacy three-word IDs remain durable. + if (value.size() == 23) { + 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] == '-'; } - return value[9] == '-' && value[16] == '-'; + // New Meet-like IDs are one speakable stem plus one to three digits. + const auto name = value.substr(3); + std::size_t digit = 0; + while (digit < name.size() && name[digit] >= 'a' && name[digit] <= 'z') ++digit; + if (digit < 4 || digit > 7 || name.size() - digit < 1 || + name.size() - digit > 3) return false; + for (; digit < name.size(); ++digit) + if (name[digit] < '0' || name[digit] > '9') return false; + return true; } } // namespace