'use strict'
/** Resolved while this classic script loads (opaque iframe origins cannot use ES module import). */
const interfaceColorHelperUrl = (() => {
try {
const src = document.currentScript?.src
return src ? new URL('ColorHelper.js', src).href : '/ColorHelper.js'
} catch {
return '/ColorHelper.js'
}
})()
class InterfaceHelper {
static #initiated = false
static #wired = false
static #onInit = null
static #onWorkerAdded = null
static #workers = new Map()
static #pendingPosts = new Map()
static #colorApi = null
static #colorApiPromise = null
static #colorContext = null
static #loadClassicScript(src) {
return new Promise((resolve, reject) => {
for (const existing of document.getElementsByTagName('script')) {
if (existing.src === src) {
if (globalThis.ColorHelper) {
resolve()
return
}
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), {
once: true,
})
return
}
}
const script = document.createElement('script')
script.src = src
script.onload = () => resolve()
script.onerror = () => reject(new Error(`Failed to load ${src}`))
document.head.appendChild(script)
})
}
static #ensureColorApi() {
if (InterfaceHelper.#colorApi) {
return Promise.resolve(InterfaceHelper.#colorApi)
}
if (globalThis.ColorHelper) {
InterfaceHelper.#colorApi = globalThis.ColorHelper
return Promise.resolve(InterfaceHelper.#colorApi)
}
if (!InterfaceHelper.#colorApiPromise) {
InterfaceHelper.#colorApiPromise = InterfaceHelper.#loadClassicScript(interfaceColorHelperUrl).then(() => {
InterfaceHelper.#colorApi = globalThis.ColorHelper
return InterfaceHelper.#colorApi
})
}
return InterfaceHelper.#colorApiPromise
}
static #refreshColorContext(init) {
const apply = (ColorHelper) => {
InterfaceHelper.#colorContext = ColorHelper.buildInterfaceColorContext(init)
}
if (InterfaceHelper.#colorApi) {
apply(InterfaceHelper.#colorApi)
return Promise.resolve()
}
if (globalThis.ColorHelper) {
InterfaceHelper.#colorApi = globalThis.ColorHelper
apply(InterfaceHelper.#colorApi)
return Promise.resolve()
}
return InterfaceHelper.#ensureColorApi().then(apply)
}
/**
* Arena team/member color aligned with replay colors. When opponent members are not disclosed,
* `memberIndex` is ignored and the team color is returned.
* @param {number} teamIndex
* @param {number} [memberIndex]
* @returns {{ hue: number, saturation: number, lightness: number, R: number, G: number, B: number, RGB: string }}
*/
static GetColor(teamIndex, memberIndex) {
const ColorHelper = InterfaceHelper.#colorApi ?? globalThis.ColorHelper
const context = InterfaceHelper.#colorContext ?? {
layout: { teamCount: 1, membersPerTeam: [1], onlySingleTeams: true },
discloseMembers: 'No',
}
return ColorHelper.getInterfaceColor(teamIndex, memberIndex, context)
}
static preInit() {
if (InterfaceHelper.#initiated) {
console.error('InterfaceHelper is already initiated.')
return
}
InterfaceHelper.#initiated = true
void InterfaceHelper.#ensureColorApi()
const globalStyle = document.createElement('link')
globalStyle.rel = 'stylesheet'
globalStyle.href = '/global.css'
document.head.prepend(globalStyle)
const fallbackStyle = document.createElement('style')
fallbackStyle.textContent = 'html { background-color: var(--main-background-color); }'
document.head.prepend(fallbackStyle)
}
/** Coordinator shell: popup opener or `/join` parent. */
static #target() {
return globalThis.opener ?? globalThis.parent
}
static #ensureWired() {
if (InterfaceHelper.#wired) {
return
}
InterfaceHelper.#wired = true
globalThis.addEventListener('message', InterfaceHelper.#onWindowMessage)
}
/**
* Called once with cloned match settings (`{ settings, opponents, ⦠}`).
* Register workers with the `workerAdded` callback (slot `0` after this handler returns, then each `{ type: 'WorkerAdded', slot }`).
* @param {(init: unknown, workerAdded: (participant: InterfaceHelperWorker) => void) => void} handler
*/
static onInit(handler) {
InterfaceHelper.#onInit = handler
InterfaceHelper.#ensureWired()
}
/**
* @deprecated Register workers via the `workerAdded` argument to {@link InterfaceHelper.onInit} instead.
* @param {(worker: InterfaceHelperWorker) => void} handler
*/
static workerAdded(handler) {
InterfaceHelper.#onWorkerAdded = handler
InterfaceHelper.#ensureWired()
}
static #registerWorker(handler) {
InterfaceHelper.#onWorkerAdded = handler
}
static #dispatchInit(init) {
void InterfaceHelper.#refreshColorContext(init)
.catch((error) => {
console.error(error)
})
.then(() => {
InterfaceHelper.#onInit?.(init, InterfaceHelper.#registerWorker)
InterfaceHelper.#attachWorker(0)
})
}
/** Tell the coordinator the interface applied init and is listening. */
static signalReady() {
const t = InterfaceHelper.#target()
if (t) {
t.postMessage(null, '*')
}
}
/** Coordinator init: `{ settings, opponents }` (see `cloneInterfaceWorkerInit`). */
static #isInit(data) {
if (typeof data !== 'object' || data === null) {
return false
}
const type = data.type
if (
type === 'Post' || type === 'Kill' || type === 'WorkerAdded' || type === 'Response' ||
type === 'Arena-Interface-Ready' || type === 'Arena-Interface-Disconnected'
) {
return false
}
const settings = data.settings
if (settings !== undefined && typeof settings === 'object' && settings !== null) {
return true
}
return data.general !== undefined && typeof data.general === 'object' && data.general !== null
}
static #unwrapPostEnvelope(data) {
if (
typeof data === 'object' &&
data !== null &&
data.type !== 'Post' &&
typeof data.message === 'object' &&
data.message !== null &&
data.message.type === 'Post'
) {
return data.message
}
return data
}
static #isPost(data) {
return typeof data === 'object' && data !== null && data.type === 'Post'
}
static #isDisconnected(data) {
return typeof data === 'object' && data !== null && data.type === 'Arena-Interface-Disconnected'
}
static #showDisconnectedState(label) {
const lock = document.getElementById('lock')
if (!lock) {
return
}
lock.classList.add('engaged')
lock.classList.remove('booting')
const span = lock.querySelector('span')
if (span) {
span.textContent = label
}
}
static #coerceWorkerSlot(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value !== '') {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
return 0
}
static #workerSlotFromEnvelope(data) {
if (typeof data.workerSlot === 'number') {
return data.workerSlot
}
if (typeof data.workerSlot === 'string') {
return InterfaceHelper.#coerceWorkerSlot(data.workerSlot)
}
if (typeof data.workerName === 'string') {
return data.workerName === '' ? 0 : InterfaceHelper.#coerceWorkerSlot(data.workerName)
}
if (data.type === 'Kill' && typeof data.slot === 'number') {
return InterfaceHelper.#coerceWorkerSlot(data.slot)
}
return 0
}
static #sendResponse(value, options, messageIndex, workerSlot) {
const t = InterfaceHelper.#target()
if (!t) {
return
}
const executionSteps = options?.executionSteps ?? { toRespond: 0, toTerminate: 0 }
const idx = options?.messageIndex ?? messageIndex
const slot = typeof options?.workerSlot === 'number' ? options.workerSlot : workerSlot
t.postMessage({
type: 'Response',
response: {
value,
executionSteps,
...(typeof idx === 'number' ? { messageIndex: idx } : {}),
...(typeof slot === 'number' ? { workerSlot: slot } : {}),
...(options?.console ? { console: options.console } : {}),
},
}, '*')
}
static #createWorker(workerSlot) {
/** @type {number | null} */
let lastMessageIndex = null
const worker = {
onMessage: null,
onKilled: null,
respond(value, options) {
InterfaceHelper.#sendResponse(value, options, options?.messageIndex ?? lastMessageIndex, workerSlot)
},
}
Object.defineProperty(worker, '_lastMessageIndex', {
get: () => lastMessageIndex,
set: (v) => {
lastMessageIndex = v
},
})
return worker
}
static #attachWorker(workerSlot) {
if (!InterfaceHelper.#onWorkerAdded) {
return
}
let worker = InterfaceHelper.#workers.get(workerSlot)
if (worker) {
return worker
}
worker = InterfaceHelper.#createWorker(workerSlot)
InterfaceHelper.#workers.set(workerSlot, worker)
InterfaceHelper.#onWorkerAdded(worker)
const pending = InterfaceHelper.#pendingPosts.get(workerSlot)
if (pending) {
InterfaceHelper.#pendingPosts.delete(workerSlot)
for (const post of pending) {
InterfaceHelper.#deliverPost(post)
}
}
return worker
}
static #deliverPost(data) {
const workerSlot = InterfaceHelper.#workerSlotFromEnvelope(data)
const worker = InterfaceHelper.#workers.get(workerSlot)
if (!worker) {
let pending = InterfaceHelper.#pendingPosts.get(workerSlot)
if (!pending) {
pending = []
InterfaceHelper.#pendingPosts.set(workerSlot, pending)
}
pending.push(data)
return
}
worker._lastMessageIndex = typeof data.messageIndex === 'number' ? data.messageIndex : null
const message = {
data: data.message,
respond(value, options) {
InterfaceHelper.#sendResponse(value, options, data.messageIndex, workerSlot)
},
}
worker.onMessage?.(message)
}
static #onWindowMessage(messageEvent) {
const data = InterfaceHelper.#unwrapPostEnvelope(messageEvent.data)
if (InterfaceHelper.#isDisconnected(data)) {
const label = typeof data.message === 'string' && data.message.trim() ? data.message.trim() : 'Match has ended'
InterfaceHelper.#showDisconnectedState(label)
return
}
if (InterfaceHelper.#isInit(data)) {
InterfaceHelper.#dispatchInit(data)
return
}
if (data.type === 'WorkerAdded' && typeof data.slot === 'number') {
InterfaceHelper.#attachWorker(data.slot)
return
}
if (data.type === 'Kill') {
const workerSlot = InterfaceHelper.#workerSlotFromEnvelope(data)
const worker = InterfaceHelper.#workers.get(workerSlot)
if (!worker) {
InterfaceHelper.#sendResponse('Dead', {}, data.messageIndex, workerSlot)
return
}
let responded = false
const priorRespond = worker.respond
worker.respond = function (value, options) {
responded = true
return priorRespond.call(
this,
value,
{ ...options, messageIndex: options?.messageIndex ?? data.messageIndex },
)
}
worker.onKilled?.()
worker.respond = priorRespond
if (!responded) {
priorRespond.call(worker, 'Dead', { messageIndex: data.messageIndex, workerSlot })
}
return
}
if (InterfaceHelper.#isPost(data)) {
InterfaceHelper.#deliverPost(data)
}
}
static #SEEDRANDOM_URL = 'https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js'
static #PARTICIPANT_HELPER_URL = '/ParticipantHelper.js'
static #escapeJsString(value) {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}
static #buildNativeParticipantBootstrap(seed) {
return `
ParticipantHelper.preInit();
Math.seedrandom('${InterfaceHelper.#escapeJsString(seed)}');
delete Math.seedrandom;
`.split('\n').map((line) => line.trim()).join('')
}
static #parseParticipantHeader(source) {
try {
return JSON.parse(source.substring(source.indexOf('/**') + 3, source.indexOf('**/')))
} catch {
return {}
}
}
/** Unwrap {@link CreateParticipantWorker} `Response` envelopes for {@link InterfaceHelperWorker.respond}. */
static participantWorkerResponse(data) {
if (typeof data === 'object' && data !== null && data.type === 'Response' && typeof data.response === 'object' && data.response !== null && 'value' in data.response) {
return data.response.value
}
return data
}
/**
* Fetch a participant script, load {@link ParticipantHelper}, and spawn a worker.
* @param {string} url Participant script URL
* @param {{ seed?: string, system?: string[] }} [options]
* @returns {Promise