diff --git a/README.md b/README.md index b066ce8..b88882d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ npm run test:browser 维护模式回归直接测试发布页面,并对照退役前版本核对主页文字及布局。原认证、媒体生命周期回归仅在测试服务器的响应中关闭维护开关,以保留未启用代码的验证;线上没有查询参数或浏览器存储开关可恢复 AI 服务。 +游戏专项覆盖异常存档的保留与恢复、受限存储、旧 Service Worker 更新,以及 320px/390px/短横屏弹窗的触摸滚动、关闭与焦点恢复。Frontier 回归检查快速暂停恢复仅有一条动画循环、后台停帧和实际触摸移动;游戏画质保持不变。大厅仍保留竖屏游玩限制,已打开的弹窗可在横屏操作。 + 公开仓库只包含前端和测试,不包含生产凭据、用户数据或 Worker 私有源码。后端在本机 `.local-secrets/ray-backend` 独立版本管理,包含迁移、恢复验证和受保护发布工具;该目录不能提交到本仓库或暴露给 HTTP 服务。 - [云存档协议与迁移](docs/game-save-cloud-api.md) diff --git a/arcade/css/style.css b/arcade/css/style.css index 8d5c842..6a280ab 100644 --- a/arcade/css/style.css +++ b/arcade/css/style.css @@ -458,21 +458,30 @@ p { backdrop-filter: blur(8px); } +html.arcade-modal-open, +body.arcade-modal-open { + overflow: hidden; + touch-action: pan-y; +} + .modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; - padding: calc(env(safe-area-inset-top) + 16px) 18px calc(env(safe-area-inset-bottom) + 16px); + padding: calc(env(safe-area-inset-top) + 16px) calc(env(safe-area-inset-right) + 18px) calc(env(safe-area-inset-bottom) + 16px) calc(env(safe-area-inset-left) + 18px); background: rgba(1, 3, 17, .66); backdrop-filter: blur(10px); - touch-action: none; + touch-action: pan-y; + overscroll-behavior: contain; } .modal-card { width: min(100%, 420px); - max-height: min(78svh, 78dvh); + min-height: 0; + max-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 32px); + max-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 32px); overflow: hidden; display: flex; flex-direction: column; @@ -486,15 +495,39 @@ p { } .modal-head { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; padding: 20px 18px 0; } .modal-title { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; font-size: 22px; - letter-spacing: -.03em; + letter-spacing: 0; +} + +.modal-close { + flex: 0 0 44px; + width: 44px; + min-height: 44px; + padding: 0; + border: 1px solid rgba(103, 232, 249, .24); + border-radius: 12px; + background: rgba(255, 255, 255, .06); + color: inherit; + font: inherit; + font-size: 26px; + cursor: pointer; } .modal-body { + min-height: 0; + overscroll-behavior: contain; + overflow-wrap: anywhere; overflow-y: auto; -webkit-overflow-scrolling: touch; touch-action: pan-y; @@ -510,12 +543,23 @@ p { .modal-actions { display: flex; + flex-shrink: 0; + flex-wrap: wrap; gap: 10px; padding: 0 18px 18px; } .modal-actions button { - flex: 1; + flex: 1 1 88px; + min-height: 44px; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; +} + +@media (max-height: 420px) { + .modal-head { padding-top: 8px; } + .modal-actions { padding-bottom: 8px; } } .toast { @@ -1021,6 +1065,10 @@ p { } } +body.arcade-modal-open .orientation-lock { + display: none; +} + @media (max-width: 374px) { .game-topbar { grid-template-columns: auto 1fr auto auto; diff --git a/arcade/frontier/index.html b/arcade/frontier/index.html index b64a515..4cbc21f 100644 --- a/arcade/frontier/index.html +++ b/arcade/frontier/index.html @@ -100,10 +100,10 @@

突围结束

- + - - + + diff --git a/arcade/frontier/js/frontier.js b/arcade/frontier/js/frontier.js index 2590fde..fbc41a7 100644 --- a/arcade/frontier/js/frontier.js +++ b/arcade/frontier/js/frontier.js @@ -121,6 +121,7 @@ import * as THREE from "../vendor/three.module.js"; this.state = "menu"; this.last = 0; this.raf = 0; + this.idleRaf = 0; this.time = 0; this.cloudSave = null; this.activePointer = null; @@ -155,9 +156,13 @@ import * as THREE from "../vendor/three.module.js"; this.resize(); window.addEventListener("resize", () => this.resize()); document.addEventListener("visibilitychange", () => { - if (document.hidden && this.state === "playing") this.pause(); + if (document.hidden) { + this.pause(); + this.stopAnimation(); + } else if (this.state === "menu") this.drawIdle(); }); - window.addEventListener("pagehide", () => this.pause()); + window.addEventListener("pagehide", () => { this.pause(); this.stopAnimation(); }); + window.addEventListener("pageshow", () => { if (this.state === "menu") this.drawIdle(); }); this.loading.classList.add("is-hidden"); this.drawIdle(); } @@ -644,6 +649,7 @@ import * as THREE from "../vendor/three.module.js"; start() { if (!this.scene) return; + this.stopAnimation(); this.result.hidden = true; this.result.classList.remove("is-open"); this.menu.classList.remove("is-open"); @@ -659,32 +665,46 @@ import * as THREE from "../vendor/three.module.js"; pause() { if (this.state !== "playing") return; this.state = "paused"; + this.stopAnimation(); this.releaseStick(); UI && UI.toast && UI.toast("Ray Frontier 已暂停"); } resume() { - if (this.state !== "paused") return; + if (this.state !== "paused" || document.hidden) return; + this.stopAnimation(); this.state = "playing"; this.last = performance.now(); - this.loop(this.last); + this.raf = requestAnimationFrame((time) => this.loop(time)); + } + + stopAnimation() { + cancelAnimationFrame(this.raf); + cancelAnimationFrame(this.idleRaf); + this.raf = this.idleRaf = 0; } drawIdle() { + cancelAnimationFrame(this.idleRaf); + this.idleRaf = 0; + if (this.state !== "menu" || document.hidden) return; this.nodes.player.rotation.y += .01; this.camera.position.lerp(new THREE.Vector3(0, 19.4, 22.4), .05); this.camera.lookAt(0, 1.05, -5.8); this.renderer.render(this.scene, this.camera); - if (this.state === "menu") requestAnimationFrame(() => this.drawIdle()); + this.idleRaf = requestAnimationFrame(() => this.drawIdle()); } loop(now) { + cancelAnimationFrame(this.raf); + this.raf = 0; + if (document.hidden) { this.pause(); return; } if (this.state !== "playing") return; const dt = Math.min(.033, Math.max(.001, (now - this.last) / 1000)); this.last = now; this.update(dt); this.render(); - this.raf = requestAnimationFrame((t) => this.loop(t)); + if (this.state === "playing" && !document.hidden) this.raf = requestAnimationFrame((t) => this.loop(t)); } resize() { @@ -1114,7 +1134,7 @@ import * as THREE from "../vendor/three.module.js"; p.xpNext = Math.floor(p.xpNext * 1.26 + 12); p.hp = Math.min(p.maxHp, p.hp + 18); this.state = "levelup"; - cancelAnimationFrame(this.raf); + this.stopAnimation(); this.showUpgrades(); pulse("success"); } @@ -1133,11 +1153,11 @@ import * as THREE from "../vendor/three.module.js"; button.className = "upgrade-card"; button.innerHTML = `${up.name}${up.text}`; button.addEventListener("click", () => { + if (this.state !== "levelup") return; up.apply(this); this.upgrades.hidden = true; - this.state = "playing"; - this.last = performance.now(); - this.loop(this.last); + this.state = "paused"; + this.resume(); UI && UI.toast && UI.toast(up.name); }); this.upgradeGrid.appendChild(button); @@ -1310,7 +1330,7 @@ import * as THREE from "../vendor/three.module.js"; gameOver() { if (this.state === "gameover") return; this.state = "gameover"; - cancelAnimationFrame(this.raf); + this.stopAnimation(); this.releaseStick(); const p = this.player; Storage && Storage.updateBest && Storage.updateBest("frontier", { diff --git a/arcade/index.html b/arcade/index.html index 3ae1b4d..9da97d7 100644 --- a/arcade/index.html +++ b/arcade/index.html @@ -11,7 +11,7 @@ - + @@ -26,10 +26,10 @@

请竖屏游玩

- + - + @@ -37,6 +37,6 @@

请竖屏游玩

- + diff --git a/arcade/js/app.js b/arcade/js/app.js index 8e09582..3646351 100644 --- a/arcade/js/app.js +++ b/arcade/js/app.js @@ -417,6 +417,10 @@ UI.showModal({ title: "暂停中", html: `

${UI.escapeHtml(message || "游戏已暂停。")}

`, + onDismiss: close => { + close(); + if (currentGame) currentGame.resume(); + }, actions: [ { label: "继续", kind: "primary", onClick: (close) => { close(); pauseModalOpen = false; currentGame && currentGame.resume(); } }, { label: "重开", kind: "secondary", onClick: (close) => { close(); pauseModalOpen = false; currentGame && currentGame.restart(); } }, @@ -441,12 +445,6 @@ window.addEventListener("blur", () => currentGame && currentGame.pause()); document.addEventListener("gesturestart", (ev) => ev.preventDefault(), { passive: false }); - let lastTouchEnd = 0; - document.addEventListener("touchend", (ev) => { - const now = Date.now(); - if (now - lastTouchEnd <= 320) ev.preventDefault(); - lastTouchEnd = now; - }, { passive: false }); initCloudSave(); renderLobby(); diff --git a/arcade/js/save-core.js b/arcade/js/save-core.js index 0b9aca4..bec2733 100644 --- a/arcade/js/save-core.js +++ b/arcade/js/save-core.js @@ -149,12 +149,14 @@ const work = async () => { const result = await transaction(key, fresh(this.initial), edit); if (epoch === this.epoch) { - if (['pulled', 'resolved', 'imported'].includes(reason) && !equal(this.memory, result.record.payload)) { + const recovering = ['resolved', 'imported'].includes(reason); + const replacesMemory = version === this.optimisticVersion && (!this.failed || recovering); + if (replacesMemory && (reason === 'pulled' || recovering) && !equal(this.memory, result.record.payload)) { window.dispatchEvent(new CustomEvent('ray-save-owner-changing', { detail: { appId: this.appId, reason } })); } this.record = result.record; - if (['resolved', 'imported'].includes(reason)) { this.failed = false; this.volatile.delete(key); } - if (!this.failed && version === this.optimisticVersion) this.memory = copy(result.record.payload); + if (recovering) { this.failed = false; this.volatile.delete(key); } + if (replacesMemory) this.memory = copy(result.record.payload); this.persisted = !this.failed; this.notify(reason); } diff --git a/arcade/js/ui.js b/arcade/js/ui.js index f6bee60..7a8f124 100644 --- a/arcade/js/ui.js +++ b/arcade/js/ui.js @@ -5,6 +5,64 @@ let audioCtx = null; let toastTimer = 0; let iosSwitchHaptic = null; + let modalId = 0; + const modalStack = []; + const inertState = new Map(); + let modalObserver = null; + let lastPointerOpener = null; + let pointerTime = 0; + + // iOS does not always focus a button on tap. Remember the actual opener. + document.addEventListener("pointerdown", (event) => { + lastPointerOpener = event.target.closest("button, a[href], [role='button'], [tabindex]"); + pointerTime = Date.now(); + }, true); + + function modalFocusables(card) { + return $all("button, a[href], input, select, textarea, [tabindex]", card) + .filter((node) => !node.disabled && node.tabIndex >= 0 && !node.closest("[inert]") && node.getClientRects().length); + } + + function syncModalBackground() { + const top = modalStack[modalStack.length - 1]; + for (const node of document.body.children) { + if (!inertState.has(node)) inertState.set(node, node.inert); + node.inert = top ? node !== top.backdrop : inertState.get(node); + } + if (!top) { + for (const [node, inert] of inertState) node.inert = inert; + inertState.clear(); + } + document.body.classList.toggle("arcade-modal-open", Boolean(top)); + document.documentElement.classList.toggle("arcade-modal-open", Boolean(top)); + } + + function modalKeydown(event) { + const top = modalStack[modalStack.length - 1]; + if (!top) return; + // Keep keyboard controls in the dialog, away from game-level listeners. + event.stopImmediatePropagation(); + if (event.key === "Escape") { + event.preventDefault(); + top.dismiss(); + } else if (event.key === "Tab") { + const nodes = modalFocusables(top.card); + const first = nodes[0] || top.card; + const last = nodes[nodes.length - 1] || top.card; + if (!top.card.contains(document.activeElement) || document.activeElement === top.card || + (event.shiftKey ? document.activeElement === first : document.activeElement === last)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus({ preventScroll: true }); + } + } + } + + function modalFocusin(event) { + const top = modalStack[modalStack.length - 1]; + if (top && !top.card.contains(event.target)) { + (modalFocusables(top.card)[0] || top.card).focus({ preventScroll: true }); + } + } function $(selector, root) { return (root || document).querySelector(selector); @@ -211,30 +269,80 @@ function showModal(options) { const opts = options || {}; + const opener = Date.now() - pointerTime < 500 && lastPointerOpener && lastPointerOpener.isConnected ? lastPointerOpener : document.activeElement; + lastPointerOpener = null; const backdrop = el("div", "modal-backdrop"); const card = el("section", "modal-card" + (opts.className ? " " + opts.className : "")); const head = el("div", "modal-head"); const title = el("h2", "modal-title", opts.title || "Ray Arcade"); const body = el("div", "modal-body"); const actions = el("div", "modal-actions"); + const closeButton = el("button", "modal-close", "\u00d7"); + const actionList = opts.actions || [{ label: "我知道了", kind: "primary", value: true }]; + let closed = false; + title.id = "arcade-modal-title-" + (++modalId); + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-labelledby", title.id); + card.tabIndex = -1; + closeButton.type = "button"; + closeButton.setAttribute("aria-label", "关闭"); head.appendChild(title); + head.appendChild(closeButton); if (opts.html) body.innerHTML = opts.html; else if (opts.content) body.appendChild(opts.content); else body.textContent = opts.message || ""; function close(result) { + if (closed) return; + closed = true; + const wasTop = modalStack[modalStack.length - 1] === entry; + modalStack.splice(modalStack.indexOf(entry), 1); + for (const modal of modalStack) { + if (backdrop.contains(modal.opener)) modal.opener = entry.opener; + } backdrop.remove(); + syncModalBackground(); + if (!modalStack.length) { + modalObserver.disconnect(); + modalObserver = null; + document.removeEventListener("keydown", modalKeydown, true); + document.removeEventListener("focusin", modalFocusin, true); + } + if (wasTop) { + const top = modalStack[modalStack.length - 1]; + const restore = entry.opener; + const target = restore && restore.isConnected && !restore.closest("[inert]") && + (!top || top.card.contains(restore)) ? restore : top && top.card; + if (target) target.focus({ preventScroll: true }); + } if (opts.onClose) opts.onClose(result); } - (opts.actions || [{ label: "我知道了", kind: "primary", value: true }]).forEach((act) => { + function runAction(act) { + if (closed) return; + if (act && act.onClick) act.onClick(close); + else close(act && act.value); + } + + function dismiss() { + if (closed) return; + // Dismissal is cancellation, never implicit consent to a primary action. + if (opts.onDismiss) opts.onDismiss(close); + else close(); + } + + const entry = { backdrop, card, dismiss, opener }; + closeButton.addEventListener("click", dismiss); + actionList.forEach((act) => { const btn = el("button", act.kind === "secondary" ? "secondary-btn" : act.kind === "danger" ? "danger-btn" : "primary-btn", act.label); + btn.type = "button"; btn.addEventListener("click", () => { + if (closed) return; beep(act.beep || "tap"); vibrate(12); - if (act.onClick) act.onClick(close); - else close(act.value); + runAction(act); }); actions.appendChild(btn); }); @@ -244,6 +352,15 @@ card.appendChild(actions); backdrop.appendChild(card); document.body.appendChild(backdrop); + modalStack.push(entry); + if (!modalObserver) { + modalObserver = new MutationObserver(syncModalBackground); + modalObserver.observe(document.body, { childList: true }); + document.addEventListener("keydown", modalKeydown, true); + document.addEventListener("focusin", modalFocusin, true); + } + syncModalBackground(); + card.focus({ preventScroll: true }); return close; } @@ -252,7 +369,8 @@ return showModal({ title: meta.title || "怎么玩", html: lines, - actions: [{ label: first ? "开始游戏" : "我知道了", kind: "primary", beep: "ok", onClick: (close) => { close(true); if (onDone) onDone(); } }] + actions: [{ label: first ? "开始游戏" : "我知道了", kind: "primary", beep: "ok", value: true }], + onClose: () => { if (onDone) onDone(); } }); } diff --git a/arcade/starfall/index.html b/arcade/starfall/index.html index 20b9f71..d555337 100644 --- a/arcade/starfall/index.html +++ b/arcade/starfall/index.html @@ -137,7 +137,7 @@

请竖屏游玩

- + diff --git a/arcade/starfall/sw-cloudsave-2.js b/arcade/starfall/sw-cloudsave-2.js index e10af09..68e74dd 100644 --- a/arcade/starfall/sw-cloudsave-2.js +++ b/arcade/starfall/sw-cloudsave-2.js @@ -1 +1 @@ -importScripts('./sw-runtime-v2.js?v=static-v3'); +importScripts('./sw-runtime-v2.js?v=static-v4'); diff --git a/arcade/starfall/sw-cloudsave-3.js b/arcade/starfall/sw-cloudsave-3.js index e10af09..68e74dd 100644 --- a/arcade/starfall/sw-cloudsave-3.js +++ b/arcade/starfall/sw-cloudsave-3.js @@ -1 +1 @@ -importScripts('./sw-runtime-v2.js?v=static-v3'); +importScripts('./sw-runtime-v2.js?v=static-v4'); diff --git a/arcade/starfall/sw-runtime-v2.js b/arcade/starfall/sw-runtime-v2.js index 13db8fe..169d2a2 100644 --- a/arcade/starfall/sw-runtime-v2.js +++ b/arcade/starfall/sw-runtime-v2.js @@ -1,10 +1,10 @@ // Shared by every historical registration URL; never cache private requests. -const CACHE_NAME = 'ray-cat-starfall-static-v3'; +const CACHE_NAME = 'ray-cat-starfall-static-v4'; const ASSETS = [ './', './index.html', './manifest.webmanifest', './assets/icon.svg', './css/style.css?v=heavy-1', '../css/save-ui.css?v=save-v2', - '../js/save-core.js?v=save-v2', + '../js/save-core.js?v=mobile-3', '../js/save-schema.js?v=save-v3', './js/storage.js?v=save-v2', '../js/cloud-save.js?v=save-v2', './js/audio.js', './js/game.js?v=save-v2', './js/app-cloudsave-3.js?v=save-v2' diff --git a/arcade/starfall/sw.js b/arcade/starfall/sw.js index e10af09..68e74dd 100644 --- a/arcade/starfall/sw.js +++ b/arcade/starfall/sw.js @@ -1 +1 @@ -importScripts('./sw-runtime-v2.js?v=static-v3'); +importScripts('./sw-runtime-v2.js?v=static-v4'); diff --git a/tests/arcade-modal-browser.cjs b/tests/arcade-modal-browser.cjs new file mode 100644 index 0000000..a5105a3 --- /dev/null +++ b/tests/arcade-modal-browser.cjs @@ -0,0 +1,180 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { chromium } = require(process.env.PLAYWRIGHT_CORE || 'playwright'); +const { browserLaunchOptions } = require('../tools/playwright.cjs'); +const root = path.resolve(__dirname, '..'); +const output = fs.mkdtempSync(path.join(os.tmpdir(), 'ray-arcade-modal-')); +const fixture = ` + + +
+ +`; +const assets = new Map([ + ['/arcade/css/style.css', 'text/css'], ['/arcade/js/ui.js', 'text/javascript'] +]); +const server = http.createServer((request, response) => { + const pathname = new URL(request.url, 'http://localhost').pathname; + if (pathname === '/') return response.writeHead(200, { 'content-type': 'text/html' }).end(fixture); + if (!pathname.startsWith('/arcade/') || pathname.split('/').some(part => part.startsWith('.'))) return response.writeHead(404).end(); + const file = path.resolve(root, '.' + pathname + (pathname.endsWith('/') ? 'index.html' : '')); + if (!file.startsWith(root + path.sep)) return response.writeHead(404).end(); + const types = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.png': 'image/png' }; + fs.readFile(file, (error, data) => error ? response.writeHead(404).end() : + response.writeHead(200, { 'content-type': assets.get(pathname) || types[path.extname(file)] || 'application/octet-stream' }).end(data)); +}); + +(async () => { + let browser; + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + browser = await chromium.launch({ ...browserLaunchOptions(), headless: true, args: ['--no-sandbox'] }); + for (const [width, height] of [[320, 568], [390, 844], [844, 390]]) { + const context = await browser.newContext({ viewport: { width, height }, isMobile: true, hasTouch: true }); + await context.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.origin === base) return route.continue(); + if (url.hostname === 'api.rayalex.cn') return route.fulfill({ status: 401, + contentType: 'application/json', body: JSON.stringify({ ok: false, error: 'not_authenticated' }) }); + return route.abort(); + }); + await context.addInitScript(() => { + let games; + Object.defineProperty(window, 'RayGames', { + get: () => games, set: value => { games = new Proxy(value, { set(target, key, entry) { + target[key] = typeof entry === 'function' ? new Proxy(entry, { construct(ctor, args) { + const game = Reflect.construct(ctor, args); window.__modalTestGame = game; return game; + } }) : entry; + return true; + } }); } + }); + }); + const page = await context.newPage(), errors = []; + page.setDefaultTimeout(10000); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(base); + await page.evaluate(() => { + window.inputEvents = []; + for (const type of ['pointerdown', 'pointerup', 'pointercancel', 'touchend', 'click']) { + document.addEventListener(type, event => window.inputEvents.push({ type, + target: event.target.outerHTML.slice(0, 100), prevented: event.defaultPrevented }), true); + } + }); + await page.locator('#open').tap(); + const dialog = page.getByRole('dialog', { name: 'Game instructions' }); + await dialog.waitFor(); + assert.equal(await page.locator('#app').evaluate(node => node.inert), true); + assert.equal(await dialog.evaluate(node => node === document.activeElement), true); + const rect = await dialog.boundingBox(); + assert(rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= width && rect.y + rect.height <= height); + for (const button of await dialog.locator('button').all()) { + const box = await button.boundingBox(); + assert(box.width >= 44 && box.height >= 44, 'All modal targets are at least 44px'); + } + for (let index = 0; index < 8; index++) { + await page.keyboard.press(index % 2 ? 'Shift+Tab' : 'Tab'); + assert.equal(await dialog.evaluate(node => node.contains(document.activeElement)), true); + } + const body = page.locator('.modal-body'), box = await body.boundingBox(); + assert(box.height > 30, 'Short landscape keeps a usable scrolling body'); + const session = await context.newCDPSession(page); + const x = box.x + box.width / 2, bottom = box.y + box.height - 5; + await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: bottom }] }); + for (let step = 1; step <= 8; step++) { + await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: bottom - (box.height - 15) * step / 8 }] }); + await page.waitForTimeout(25); + } + await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForFunction(() => document.querySelector('.modal-body').scrollTop > 0); + // A tap during native inertial scrolling stops scrolling instead of clicking. + await body.evaluate(node => new Promise(resolve => { + let last = node.scrollTop, changed = performance.now(); + const poll = () => { + if (node.scrollTop !== last) { last = node.scrollTop; changed = performance.now(); } + if (performance.now() - changed >= 200) resolve(); + else requestAnimationFrame(poll); + }; + requestAnimationFrame(poll); + })); + assert.equal(await page.evaluate(() => window.backgroundClicks), 0); + await page.screenshot({ path: path.join(output, `${width}x${height}.png`) }); + await dialog.getByRole('button', { name: '关闭', exact: true }).tap(); + await dialog.waitFor({ state: 'hidden' }).catch(async error => { + console.error(await page.evaluate(() => ({ events: window.inputEvents, active: document.activeElement.outerHTML.slice(0, 150) }))); + throw error; + }); + assert.equal(await page.locator('#open').evaluate(node => node === document.activeElement), true); + assert.equal(await page.evaluate(() => window.resumes), 1); + assert.equal(await page.locator('#app').evaluate(node => node.inert), false); + await page.locator('#open').tap(); await page.keyboard.press('Escape'); + assert.equal(await page.evaluate(() => window.resumes), 2); + + const landscape = width > height; + if (landscape) await page.setViewportSize({ width: height, height: width }); + await page.goto(base + '/arcade/'); + await page.waitForFunction(() => window.RayArcade?.Storage.store.persisted); + await page.locator('[data-start="merge2048"]').tap(); + await page.locator('.modal-actions button').first().tap(); + await page.waitForFunction(() => window.__modalTestGame?.started); + await page.locator('[data-pause]').tap(); + await page.getByRole('dialog', { name: '暂停中' }).waitFor(); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), true); + await page.getByRole('dialog', { name: '暂停中' }).getByRole('button', { name: '关闭', exact: true }).tap(); + await page.getByRole('dialog', { name: '暂停中' }).waitFor({ state: 'hidden' }); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), false); + assert.equal(await page.locator('[data-pause]').evaluate(node => node === document.activeElement), true); + await page.locator('[data-help]').tap(); + await page.getByRole('dialog').waitFor(); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), true); + if (landscape) { + await page.setViewportSize({ width, height }); + assert.equal(await page.locator('.orientation-lock').isVisible(), false); + const helpBox = await page.getByRole('dialog').boundingBox(); + assert(helpBox.x >= 0 && helpBox.y >= 0 && helpBox.x + helpBox.width <= width && helpBox.y + helpBox.height <= height); + await page.screenshot({ path: path.join(output, 'actual-help-landscape.png') }); + } + await page.keyboard.press('Escape'); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), false); + if (landscape) { + assert.equal(await page.locator('.orientation-lock').isVisible(), true); + await page.setViewportSize({ width: height, height: width }); + } + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), true); + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.getByRole('dialog', { name: '暂停中' }).waitFor(); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), true); + await page.getByRole('button', { name: '继续', exact: true }).tap(); + assert.equal(await page.evaluate(() => window.__modalTestGame.paused), false); + await page.locator('[data-back]').tap(); + await page.locator('[data-achievements]').tap(); + await page.getByRole('dialog', { name: 'Ray 成就墙' }).getByRole('button', { name: '关闭', exact: true }).first().tap(); + assert.equal(await page.locator('#app').evaluate(node => node.inert), false); + assert.deepEqual(errors, []); + await context.close(); + console.log(`PASS arcade modal ${width}x${height}: touch scroll, targets, focus and resume`); + } + console.log(`Screenshots: ${output}`); + } finally { + if (browser) await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/arcade-modal.test.cjs b/tests/arcade-modal.test.cjs new file mode 100644 index 0000000..189712a --- /dev/null +++ b/tests/arcade-modal.test.cjs @@ -0,0 +1,112 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function harness() { + const events = new Map(); + const document = { + addEventListener(type, fn) { events.set(type, fn); }, + removeEventListener(type, fn) { if (events.get(type) === fn) events.delete(type); } + }; + class Node { + constructor(tag) { + this.tagName = tag; this.children = []; this.attrs = {}; this.inert = false; + this.tabIndex = tag === 'button' ? 0 : -1; this.handlers = {}; + this.classList = { toggle() {} }; + } + appendChild(node) { this.children.push(node); node.parentNode = this; return node; } + setAttribute(name, value) { this.attrs[name] = value; } + contains(node) { return this === node || this.children.some(child => child.contains(node)); } + get isConnected() { return document.body.contains(this); } + getClientRects() { return this.hidden ? [] : [1]; } + closest(selector) { + if (selector === '[inert]') return this.inert ? this : this.parentNode?.closest(selector); + return this.tagName === 'button' || this.tabIndex >= 0 ? this : this.parentNode?.closest(selector); + } + querySelectorAll() { + return this.children.flatMap(node => [node, ...node.querySelectorAll()]).filter(node => node.tagName === 'button' || node.tabIndex >= 0); + } + addEventListener(type, fn) { this.handlers[type] = fn; } + click() { this.handlers.click?.({}); } + focus() { document.activeElement = this; events.get('focusin')?.({ target: this }); } + remove() { this.parentNode.children = this.parentNode.children.filter(node => node !== this); this.parentNode = null; } + } + document.createElement = tag => new Node(tag); + document.body = new Node('body'); document.documentElement = new Node('html'); + document.activeElement = document.body; + let observe; + const window = { RayArcade: { Storage: { getSettings: () => ({ sound: false, vibrate: false }) } } }; + vm.runInNewContext(fs.readFileSync('arcade/js/ui.js', 'utf8'), { + window, document, navigator: {}, Date, Map, console, + MutationObserver: class { constructor(fn) { observe = fn; } observe() {} disconnect() {} } + }); + const background = document.body.appendChild(new Node('main')); + const opener = background.appendChild(new Node('button')); opener.focus(); + const top = () => document.body.children.at(-1).children[0]; + const key = (key, shiftKey = false) => { + let prevented = false, stopped = false; + events.get('keydown')?.({ key, shiftKey, preventDefault() { prevented = true; }, stopImmediatePropagation() { stopped = true; } }); + return { prevented, stopped }; + }; + return { UI: window.RayArcade.UI, document, Node, events, background, opener, top, key, sync: () => observe() }; +} + +test('modal labels, focuses, isolates background and restores prior inert state', () => { + const h = harness(); + const preserved = h.document.body.appendChild(new h.Node('aside')); preserved.inert = true; + let closed = 0; + const close = h.UI.showModal({ title: 'Help', onClose: () => closed++ }); + const card = h.top(); + assert.equal(card.attrs.role, 'dialog'); assert.equal(card.attrs['aria-modal'], 'true'); + assert.equal(card.attrs['aria-labelledby'], card.children[0].children[0].id); + assert.equal(h.document.activeElement, card); assert.equal(h.background.inert, true); + const added = h.document.body.appendChild(new h.Node('button')); h.sync(); + assert.equal(added.inert, true); + h.opener.focus(); assert.equal(card.contains(h.document.activeElement), true); + close(); close(); + assert.equal(closed, 1); assert.equal(h.document.activeElement, h.opener); + assert.equal(h.background.inert, false); assert.equal(preserved.inert, true); assert.equal(added.inert, false); + assert.equal(h.events.has('keydown'), false); +}); + +test('Tab loops visible controls and Escape invokes the existing resume action once', () => { + const h = harness(); let resumed = 0; + const resume = close => { close(); resumed++; }; + h.UI.showModal({ onDismiss: resume, actions: [{ label: 'Resume', kind: 'primary', onClick: resume }] }); + const card = h.top(), first = card.children[0].children[1], last = card.children[2].children[0]; + assert.equal(h.key('Tab').prevented, true); assert.equal(h.document.activeElement, first); + h.key('Tab', true); assert.equal(h.document.activeElement, last); + h.key('Tab'); assert.equal(h.document.activeElement, first); + assert.equal(h.key('ArrowLeft').stopped, true); + h.key('Escape'); first.click(); + assert.equal(resumed, 1); assert.equal(h.document.activeElement, h.opener); +}); + +test('close icon completes guide callback and never implies consent to primary or danger actions', () => { + const h = harness(); let completed = 0, destroyed = 0; + h.UI.showGuide({ title: 'Guide', lines: [] }, false, () => completed++); + h.top().children[0].children[1].click(); assert.equal(completed, 1); + h.UI.showModal({ actions: [{ label: 'Delete', kind: 'danger', onClick: () => destroyed++ }] }); + h.key('Escape'); assert.equal(destroyed, 0); assert.equal(h.background.inert, false); + h.UI.showModal({ actions: [{ label: 'Repair', kind: 'primary', onClick: () => destroyed++ }] }); + h.top().children[0].children[1].click(); assert.equal(destroyed, 0); +}); + +test('nested modal restores underlying focus and supports out-of-order closure', () => { + const h = harness(); + const closeFirst = h.UI.showModal(); const first = h.top(); + const firstButton = first.children[2].children[0]; firstButton.focus(); + const closeSecond = h.UI.showModal(); + assert.equal(first.parentNode.inert, true); + closeSecond(); assert.equal(first.parentNode.inert, false); assert.equal(h.document.activeElement, firstButton); + const closeThird = h.UI.showModal(); closeFirst(); + assert.equal(h.background.inert, true); closeThird(); assert.equal(h.background.inert, false); + assert.equal(h.document.activeElement, h.opener); +}); + +test('tap opener is restored when mobile Safari leaves focus on body', () => { + const h = harness(); h.document.activeElement = h.document.body; + h.events.get('pointerdown')({ target: h.opener }); + const close = h.UI.showModal(); close(); assert.equal(h.document.activeElement, h.opener); +}); diff --git a/tests/frontier-lifecycle.test.cjs b/tests/frontier-lifecycle.test.cjs new file mode 100644 index 0000000..398c082 --- /dev/null +++ b/tests/frontier-lifecycle.test.cjs @@ -0,0 +1,75 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function fixture() { + const pending = new Map(); + let next = 0, updates = 0, renders = 0; + const c = { window: { RayArcade: { UI: { toast() {} } } }, + document: { hidden: false }, THREE: { Vector3: class {} }, + performance: { now: () => 100 }, + requestAnimationFrame: callback => { pending.set(++next, callback); return next; }, + cancelAnimationFrame: id => pending.delete(id) }; + vm.createContext(c); + const source = fs.readFileSync('arcade/frontier/js/frontier.js', 'utf8') + .replace(/^import[^\n]*\n/, '') + .replace('const app = new RayFrontier();', 'window.Frontier = RayFrontier; return; const app = new RayFrontier();'); + vm.runInContext(source, c); + const game = Object.create(c.window.Frontier.prototype); + Object.assign(game, { state: 'playing', raf: 0, idleRaf: 0, last: 0, + releaseStick() {}, update() { updates++; }, render() { renders++; }, + renderer: { render() { renders++; } }, scene: {}, + nodes: { player: { rotation: { y: 0 } } }, camera: { position: { lerp() {} }, lookAt() {} } }); + return { game, c, pending, get updates() { return updates; }, get renders() { return renders; }, frame(time = 116) { + const callbacks = [...pending.values()]; pending.clear(); + callbacks.forEach(callback => callback(time)); + } }; +} + +test('rapid Frontier pause/resume retains exactly one animation chain', () => { + const f = fixture(); + f.game.loop(100); + const initial = f.updates; + for (let i = 0; i < 20; i++) { f.game.pause(); f.game.resume(); } + assert.equal(f.pending.size, 1); + assert.equal(f.updates, initial, 'Resume defers work to the next animation frame'); + const before = f.updates; + f.frame(); + assert.equal(f.updates - before, 1); + assert.equal(f.pending.size, 1); + f.game.pause(); + assert.equal(f.pending.size, 0); +}); + +test('Frontier does not schedule a new frame when update pauses or ends the game', () => { + for (const state of ['paused', 'levelup', 'gameover']) { + const f = fixture(); + f.game.update = () => { f.game.state = state; }; + f.game.loop(100); + assert.equal(f.pending.size, 0, state); + } +}); + +test('Frontier menu scheduling is idempotent and hidden pages do not render', () => { + const f = fixture(); f.game.state = 'menu'; + f.game.drawIdle(); f.game.drawIdle(); + assert.equal(f.pending.size, 1); + f.c.document.hidden = true; + const before = f.renders; + f.frame(); + assert.equal(f.renders, before); + assert.equal(f.pending.size, 0); +}); + +test('a hidden Frontier frame pauses without advancing gameplay or silently resuming', () => { + const f = fixture(); f.game.loop(100); + const before = f.updates; + f.c.document.hidden = true; + f.frame(); + assert.equal(f.updates, before); + assert.equal(f.game.state, 'paused'); + f.game.resume(); + assert.equal(f.game.state, 'paused'); + assert.equal(f.pending.size, 0); +}); diff --git a/tests/game-browser-smoke.cjs b/tests/game-browser-smoke.cjs index 70d3fd6..392222c 100644 --- a/tests/game-browser-smoke.cjs +++ b/tests/game-browser-smoke.cjs @@ -234,11 +234,59 @@ async function merge2048(page, origin, label) { } async function frontier(page, origin, label) { await page.goto(origin + '/arcade/frontier/'); await ready(page, 'arcade'); - await page.locator('[data-start]').click(); await page.waitForFunction(() => window.RayFrontierApp.state === 'playing'); + const menuFrames = await page.evaluate(() => { + const app = window.RayFrontierApp; + window.__frontierOwnerEvents = []; + window.addEventListener('ray-save-owner-changing', event => window.__frontierOwnerEvents.push(event.detail)); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + const stopped = app.idleRaf === 0 && app.raf === 0; + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + return { stopped, resumed: app.idleRaf !== 0 }; + }); + assert.deepEqual(menuFrames, { stopped: true, resumed: true }); + await page.locator('[data-start]').click(); + await page.waitForFunction(() => window.RayFrontierApp.state === 'playing').catch(async error => { + console.error(await page.evaluate(() => ({ state: window.RayFrontierApp.state, hidden: document.hidden, + ownerEvents: window.__frontierOwnerEvents, booting: !!window.RayFrontierApp.cloudSave.booting }))); + throw error; + }); + const updatesPerFrame = await page.evaluate(() => new Promise(resolve => { + const app = window.RayFrontierApp, update = app.update; + let updates = 0; + app.update = function(dt) { updates++; return update.call(this, dt); }; + for (let i = 0; i < 20; i++) { app.pause(); app.resume(); } + updates = 0; + requestAnimationFrame(() => { app.update = update; resolve(updates); }); + })); + assert.equal(updatesPerFrame, 1, 'Rapid pause/resume must not multiply gameplay updates'); + const pausedTime = await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + return window.RayFrontierApp.time; + }); + await page.waitForTimeout(120); + assert.deepEqual(await page.evaluate(() => { + const app = window.RayFrontierApp; + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + return { state: app.state, time: app.time, raf: app.raf }; + }), { state: 'paused', time: pausedTime, raf: 0 }); + await page.locator('[data-pause]').click(); const before = await page.evaluate(() => ({ x: window.RayFrontierApp.player.pos.x, z: window.RayFrontierApp.player.pos.z })); const size = page.viewportSize(); - await page.mouse.move(size.width * .25, size.height * .65); await page.mouse.down(); - await page.mouse.move(size.width * .4, size.height * .55, { steps: 6 }); await page.waitForTimeout(400); await page.mouse.up(); + if (label === 'mobile') { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: size.width * .25, y: size.height * .65 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: size.width * .4, y: size.height * .55 }] }); + await page.waitForTimeout(400); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await cdp.detach(); + } else { + await page.mouse.move(size.width * .25, size.height * .65); await page.mouse.down(); + await page.mouse.move(size.width * .4, size.height * .55, { steps: 6 }); await page.waitForTimeout(400); await page.mouse.up(); + } const after = await page.evaluate(() => ({ x: window.RayFrontierApp.player.pos.x, z: window.RayFrontierApp.player.pos.z })); assert.ok(Math.hypot(before.x - after.x, before.z - after.z) > .1); await page.waitForFunction(() => window.RayFrontierApp.player.score > 0, null, { timeout: 15000 }); @@ -251,7 +299,7 @@ async function frontier(page, origin, label) { await page.locator('[data-restart]').click(); assert.equal(await page.evaluate(() => window.RayFrontierApp.state), 'playing'); await page.reload(); await ready(page, 'arcade'); assert.ok(await page.evaluate(score => window.RayArcade.Storage.getStats().frontier.bestScore >= score, score)); - return { score, pixels, persisted: true }; + return { score, pixels, persisted: true, updatesPerFrame, menuFrames, backgroundPaused: true, input: label === 'mobile' ? 'touch' : 'mouse' }; } async function workerUpgrade(browser, origin) { diff --git a/tests/storage-transactions.test.cjs b/tests/storage-transactions.test.cjs index d62ba4c..dd5f5a2 100644 --- a/tests/storage-transactions.test.cjs +++ b/tests/storage-transactions.test.cjs @@ -2,6 +2,22 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { makeBrowser, loadArcade, loadStarfall, plain, IDBFactory } = require('./storage-harness.cjs'); +test('a stale no-op cloud pull cannot interrupt newer local gameplay', async () => { + const c = makeBrowser(), a = loadArcade(c); + await a.store.ready; + const changes = []; + c.addEventListener('ray-save-owner-changing', event => changes.push(event.detail.reason)); + const pull = a.store.edit(() => {}, 'pulled'); + a.notePlay('frontier'); + await pull; + await a.store.tail; + assert.equal(a.getStats().frontier.plays, 1); + assert.deepEqual(changes, []); + await a.store.edit(record => { record.payload.buckets.stats.frontier.bestScore = 12; }, 'pulled'); + assert.deepEqual(changes, ['pulled'], 'A real remote replacement must still stop the old game'); + assert.equal(a.getStats().frontier.bestScore, 12); +}); + test('two tabs add coins and change only settings without losing economic state', async () => { const indexedDB = new IDBFactory(); const a = loadStarfall(makeBrowser({ indexedDB })); diff --git a/tests/sw-cache.test.cjs b/tests/sw-cache.test.cjs index c450931..8169948 100644 --- a/tests/sw-cache.test.cjs +++ b/tests/sw-cache.test.cjs @@ -38,18 +38,21 @@ function harness() { test('all historical SW URLs load the safe runtime', () => { for (const name of ['sw.js', 'sw-cloudsave-2.js', 'sw-cloudsave-3.js']) { - assert.equal(fs.readFileSync('arcade/starfall/' + name, 'utf8').trim(), "importScripts('./sw-runtime-v2.js?v=static-v3');"); + assert.equal(fs.readFileSync('arcade/starfall/' + name, 'utf8').trim(), "importScripts('./sw-runtime-v2.js?v=static-v4');"); } }); test('upgrade clears known old API caches and leaves unrelated caches alone', async () => { const h = harness(); h.caches.set('ray-cat-starfall-v6-heavy-1', new Map([['https://api.rayalex.cn/auth/me', new Response('private')]])); h.caches.set('ray-cat-starfall-static-v2', new Map([['https://rayalex.cn/arcade/js/save-schema.js?v=save-v2', new Response('old-validator')]])); + h.caches.set('ray-cat-starfall-static-v3', new Map([['https://rayalex.cn/arcade/js/save-core.js?v=save-v2', new Response('old-core')]])); h.caches.set('other-app', new Map([['https://example.org/x', new Response('keep')]])); await h.event('install'); await h.event('activate'); assert.equal(h.caches.has('ray-cat-starfall-v6-heavy-1'), false); assert.equal(h.caches.has('ray-cat-starfall-static-v2'), false); - assert.equal(h.caches.get('ray-cat-starfall-static-v3').has('https://rayalex.cn/arcade/js/save-schema.js?v=save-v3'), true); + assert.equal(h.caches.has('ray-cat-starfall-static-v3'), false); + assert.equal(h.caches.get('ray-cat-starfall-static-v4').has('https://rayalex.cn/arcade/js/save-schema.js?v=save-v3'), true); + assert.equal(h.caches.get('ray-cat-starfall-static-v4').has('https://rayalex.cn/arcade/js/save-core.js?v=mobile-3'), true); assert.equal(h.caches.has('other-app'), true); for (const [name, values] of h.caches) { if (name.startsWith('ray-cat-starfall-')) assert.ok([...values.keys()].every(url => !url.includes('api.rayalex'))); @@ -65,7 +68,7 @@ test('cross-origin auth, saves, same-origin API and non-GET requests are never i test('offline HTML fallback is navigation-only; failed JS never receives HTML', async () => { const h = harness(); await h.event('install'); h.setNetwork(async () => { throw new Error('offline'); }); - const cache = h.caches.get('ray-cat-starfall-static-v3'); + const cache = h.caches.get('ray-cat-starfall-static-v4'); cache.delete('https://rayalex.cn/arcade/starfall/'); const nav = await h.event('fetch', { request: { method: 'GET', url: 'https://rayalex.cn/arcade/starfall/', mode: 'navigate' } }); assert.match(await nav.text(), /html/); @@ -74,9 +77,9 @@ test('offline HTML fallback is navigation-only; failed JS never receives HTML', }); test('safe controller handshake follows cleanup', async () => { const h = harness(); await h.event('install'); - h.caches.get('ray-cat-starfall-static-v3').set('https://api.rayalex.cn/auth/me', new Response('old-private')); + h.caches.get('ray-cat-starfall-static-v4').set('https://api.rayalex.cn/auth/me', new Response('old-private')); let ack; await h.event('message', { data: { type: 'RAY_GAME_CACHE_PROTOCOL' }, ports: [{ postMessage: data => { ack = data; } }] }); assert.equal(ack.rayGameCacheProtocol, 2); - assert.equal(h.caches.get('ray-cat-starfall-static-v3').has('https://api.rayalex.cn/auth/me'), false); + assert.equal(h.caches.get('ray-cat-starfall-static-v4').has('https://api.rayalex.cn/auth/me'), false); }); diff --git a/tools/playwright.cjs b/tools/playwright.cjs index 2236113..88e68ac 100644 --- a/tools/playwright.cjs +++ b/tools/playwright.cjs @@ -55,6 +55,7 @@ function main() { run(['tests/home-browser.cjs']); run(['tests/game-browser-smoke.cjs']); run(['tests/save-hardening-browser.cjs']); + run(['tests/arcade-modal-browser.cjs']); } }