diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index 910842aa..9ea7323f 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -32,8 +32,11 @@ .lo > :where(:not(.lo__bg)) { position: relative; } /* ---- top bar ---------------------------------------------------------------------------------------------------- */ +/* The tabs stay centred while both sides fit in equal halves beside them; a wider side keeps its own width and shifts + the tabs instead (0.2.0: three tabs and the co-op countdown ran the right column into the tabs on phones and in + English), and only when nothing else is left does the tab strip scroll (.lo-tabs). */ .lo-top { - flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: .2rem; + flex: none; display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center; gap: .2rem; height: max(1rem, 48px); padding: 0 .36rem; background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6)); border-bottom: 1px solid var(--line); @@ -43,7 +46,7 @@ background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700)); } .lo-top__left { justify-self: start; display: flex; align-items: center; gap: .14rem; } -.lo-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; } +.lo-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 0; } .lo-top__title { display: flex; align-items: center; gap: .12rem; margin: 0; font-size: max(.32rem, 16px); font-weight: 900; letter-spacing: .14em; line-height: 1.15; } .lo-top__icon { width: max(.3rem, 16px); height: max(.3rem, 16px); object-fit: contain; color: var(--mint-500); } .lo-top__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; } @@ -51,7 +54,8 @@ .lo-count b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem 0 .06rem; } /* co-op briefing: time left before the match locks the loadout (the overlay covers the briefing's own countdown) */ .lo-deadline { --cd-h: max(.44rem, 20px); flex: none; } -.lo-deadline .countdown__label { font-size: max(.11rem, 8px); } +/* its caption breaks at its spaces (Loadout / Deadline) rather than widening the column; 调配截止 stays one line */ +.lo-deadline .countdown__label { font-size: max(.11rem, 8px); width: min-content; word-break: keep-all; text-align: right; } .lo-sync { font-size: max(.14rem, 10px); padding: .03rem .1rem; border: 1px solid var(--line-2); color: var(--text-lo); white-space: nowrap; letter-spacing: .04em; } .lo-sync.is-ok { color: var(--mint-400); border-color: var(--mint-800); } .lo-sync.is-busy { color: var(--text-md); } @@ -330,7 +334,7 @@ .lo-locknote .icon { width: .16rem; height: .16rem; } /* ---- tabs: 干员调配 / 干员持有 (0.2.0 补位) ---------------------------------------------------------------------------- */ -.lo-tabs { display: flex; align-items: stretch; gap: .04rem; } +.lo-tabs { display: flex; align-items: stretch; gap: .04rem; max-width: 100%; overflow-x: auto; scrollbar-width: none; } .lo-tab { display: inline-flex; align-items: center; gap: .08rem; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900; letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer; @@ -382,7 +386,6 @@ @media (max-width: 760px) { .own { padding: .1rem .16rem .16rem; } .own__lead { display: none; } - .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } } /* ---- 自选编队 (screens/diy.js): four DIY slots by tier, the slot picker (operator list + skill / module) --------------- */ @@ -502,14 +505,27 @@ .sp-loadout-open .app-root > .screen { visibility: hidden; } /* ---- narrow / short viewports ------------------------------------------------------------------------------------------- */ +/* tighter tabs below 1200 px: the co-op briefing's countdown, chips and buttons beside three English tabs */ +@media (max-width: 1200px) { + .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } +} @media (max-width: 1100px) { .lo-body { grid-template-columns: minmax(0, 1fr) minmax(0, 6.4rem); gap: .16rem; padding: .12rem .2rem .16rem; } .lo-chip__lbl { display: none; } + /* the co-op briefing's countdown takes the place of the 已调整 / 同步 chips */ + .lo-deadline ~ .lo-sync, .lo-deadline ~ .lo-count { display: none; } +} +/* phones in landscape (up to 932 px wide): the top bar keeps the tabs clear of the right column — no 已调整 / 同步 chips, + and 返回 / 全部恢复默认 (全部持有, 全部清空; each confirms first) show their icon only, the text staying their name */ +@media (max-width: 1000px) { + .lo-count, .lo-sync { display: none; } + .lo-top .btn__icon + .btn__label { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; + } } /* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */ @media (max-width: 760px) { .lo-top { padding: 0 .16rem; } - .lo-count, .lo-sync { display: none; } .lo-body { grid-template-columns: minmax(0, 1fr); } .lo-detail-wrap { display: none; } .lo-body.is-detail .lo-roster { display: none; } diff --git a/test/e2e/client-wait.test.js b/test/e2e/client-wait.test.js index 09fec6ec..8644777f 100644 --- a/test/e2e/client-wait.test.js +++ b/test/e2e/client-wait.test.js @@ -27,12 +27,34 @@ function fakePage(trueAfter) { }; } +/** + * A page whose predicate holds from its `holdsOn`-th waitForFunction call on; each earlier slice times out after its own + * timeout, like puppeteer's. Counted in calls, not in wall time: on a loaded machine late timers merge time-based slices + * (fakePage(95) with 30 ms slices returned in 3 calls instead of 4 in the 0.2.0 candidate's full pass). + */ +function slicedPage(holdsOn) { + const calls = []; + return { + calls, + waitForFunction(fn, opts, ...args) { + calls.push({ fn, opts, args }); + const holds = calls.length >= holdsOn; + return new Promise((resolve, reject) => { + if (holds) setTimeout(() => resolve({ handle: 'ok', args }), 0); + else setTimeout(() => reject(timeoutError(opts.timeout)), opts.timeout); + }); + }, + }; +} + test('a wait longer than one slice keeps polling the same predicate until it holds', async () => { - const page = fakePage(95); + const page = slicedPage(4); const fn = () => true; - const got = await waitForFunctionLong(page, fn, { timeout: 1000, polling: 200, slice: 30 }, 'a', 2); + const t0 = Date.now(); + const got = await waitForFunctionLong(page, fn, { timeout: 60_000, polling: 200, slice: 30 }, 'a', 2); assert.deepEqual(got, { handle: 'ok', args: ['a', 2] }); - assert.ok(page.calls.length >= 4, `sliced (${page.calls.length} calls)`); + assert.equal(page.calls.length, 4, 'three slices ran out, the fourth held, nothing polled after it'); + assert.ok(Date.now() - t0 >= 85, `the three slices waited their 30 ms each (${Date.now() - t0} ms)`); for (const c of page.calls) { assert.equal(c.fn, fn); assert.deepEqual(c.args, ['a', 2]); diff --git a/test/sim/bossfield.browser.test.js b/test/sim/bossfield.browser.test.js index dd5224ee..fef482c2 100644 --- a/test/sim/bossfield.browser.test.js +++ b/test/sim/bossfield.browser.test.js @@ -8,6 +8,9 @@ // with the field's LocalBossPool, and must give the same result digest, the same pool and the same fx per kind. // The fields exercise the playtest-6b sim paths: 直接乘算 bonus sums (§20.10), one 奥术 instance per target // (applyStrongest), the drone 2 % links and the 剑 / 锤 / shell kit (§20.13), and 限伤 cancels (fx 'hitCap', §20.12). +// 瘫痪 (fx 'palsy': a 麻痹 stack interrupts an enemy attack) is checked on the same four specs with a golden 催泪瓦斯 in +// every operator's second slot (withTearGas), compared Chrome vs Node like the others: the bots' only 麻痹 had been one +// 5 % 催泪瓦斯 on one operator — a single hit at 0.1.4, none since 0.2.0's enemy damage frame (WE1) re-timed every fight. // // Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1) // Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default. @@ -59,6 +62,21 @@ function captureBossSpecs() { return specs; } +/** + * A copy of `spec` where every operator carries a golden 催泪瓦斯 (攻击时有5%概率使目标获得一层麻痹) in its second slot — + * two slots, as in the game: a 麻痹 source on every field whatever the bots drafted (seed 22's own was a single 5 % roll). + */ +const TEAR_GAS = 'chess_item_5_01_e_b'; +function withTearGas(spec) { + const s = JSON.parse(JSON.stringify(spec)); + for (const p of s.players || []) { + for (const u of p.units || []) { + if (u.kind === 'chess') u.items = [...(u.items || []).filter((id) => !String(id).startsWith('chess_item_5_01_e')).slice(0, 1), TEAR_GAS]; + } + } + return s; +} + /** * Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns * its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses). @@ -100,8 +118,10 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () = test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => { const specs = captureBossSpecs(); assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']); + // the four as captured, then the same four with a 麻痹 source on every field (withTearGas) + const runs = [...specs, ...specs.map(withTearGas)]; const ds = new DataSource(DATA, null); - const node = specs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS)); + const node = runs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS)); const page = await browser.newPage(); const problems = []; @@ -115,12 +135,12 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () = const { spec: S, ds } = await loadBrowserSim(); const run = new Function(`return (${src})`)(); return all.map((spec) => run(S, ds, spec, maxSeconds)); - }, specs, runField.toString(), MAX_SECONDS); + }, runs, runField.toString(), MAX_SECONDS); await page.close(); assert.deepEqual(problems, []); - specs.forEach((spec, i) => { - const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}`; + runs.forEach((spec, i) => { + const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}${i >= specs.length ? ' +催泪瓦斯' : ''}`; assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`); assert.equal(chrome[i].json, node[i].json, `${at}: browser and Node results are identical`); assert.equal(chrome[i].hash, node[i].hash, at); @@ -129,10 +149,12 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () = assert.equal(chrome[i].hpLoss, node[i].hpLoss, `${at}: same 无来源 HP losses on the leader`); assert.ok(node[i].maxArcane <= 1, `${at}: one 奥术 instance on the leader at a time (${node[i].maxArcane})`); }); - const sum = (k) => node.reduce((a, r) => a + (r.fx[k] || 0), 0); - assert.ok(sum('hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum('hitCap')})`); - assert.ok(sum('palsy') >= 1, `瘫痪 occurred (${sum('palsy')})`); - assert.ok(node.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${node.map((r) => r.hpLoss)})`); - assert.ok(node.some((r) => r.maxArcane === 1), 'the leader carried 奥术'); + const real = node.slice(0, specs.length); + const gassed = node.slice(specs.length); + const sum = (rows, k) => rows.reduce((a, r) => a + (r.fx[k] || 0), 0); + assert.ok(sum(real, 'hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum(real, 'hitCap')})`); + assert.ok(sum(gassed, 'palsy') >= 1, `瘫痪 occurred with the 催泪瓦斯 (${gassed.map((r) => r.fx.palsy || 0)})`); + assert.ok(real.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${real.map((r) => r.hpLoss)})`); + assert.ok(real.some((r) => r.maxArcane === 1), 'the leader carried 奥术'); }); }); diff --git a/test/ui/leftovers.e2e.test.js b/test/ui/leftovers.e2e.test.js index 4381b1ca..6095c964 100644 --- a/test/ui/leftovers.e2e.test.js +++ b/test/ui/leftovers.e2e.test.js @@ -10,7 +10,8 @@ // 4. solo battle: the pause button sends g.pause {on}, m.public.paused shows the overlay and freezes the countdown, // Space resumes; no pause control in co-op or in prep. // Real server (test/e2e/fastServer.mjs, real clicks and canvas drags): -// 1+4. a solo run: equip two items on an operator, drop a third → dialog → 取消 (nothing sent) → pick the OLDER item → +// 1+4. a solo run: equip two items on an operator, drop a third → dialog → 取消 (nothing sent, the item drawn back on its +// bench slot) → pick the OLDER item → // the real engine destroys exactly that one; then ready → the real local battle → pause (the battle clock and the // countdown stand still) → 继续作战; // 3. the server restarts while the briefing is on screen → 「服务器会话已重置,上一局模拟已结束」 and the lobby. @@ -275,6 +276,7 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 ( const equipped = await itemsOf(); assert.deepEqual(equipped.map((x) => x.id), KIT_ITEMS.slice(0, 2)); const third = await handItem(KIT_ITEMS[2]); + const benchSlot = await c.piecePoint(third.uid, 0.5); // where the view draws it in the hand // the third: dialog → 取消 → nothing sent / changed await dropItem(third); @@ -289,6 +291,15 @@ describe('client leftovers — real server', { skip: !ENABLED && 'set SP_E2E=1 ( assert.equal((await c.requests('g.equip')).length, sentBefore, '取消: no g.equip'); assert.deepEqual(await itemsOf(), equipped, '取消: unchanged'); assert.ok(await handItem(KIT_ITEMS[2]), '取消: the item stays in the hand'); + // ... and is drawn back on its bench slot before it is picked up again. The view keeps a dropped piece on the drop + // tile for 1.3 s (render/app/tune.js DROP_PENDING_MS), then flies it back in a frame-timed tween: with few frames + // (software-rendered headless Chrome on a loaded machine) the drag aimed where it stood a moment before grabbed the + // operator under it, whose direction wheel then swallowed the retry (the 0.2.0 candidate's full pass). + const back = await c.page.waitForFunction((uid, x, y) => { + const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid); + return !!r && Math.abs(r.left + r.width / 2 - x) < 2 && Math.abs(r.top + r.height / 2 - y) < 2; + }, { timeout: 15000, polling: 100 }, third.uid, benchSlot.x, benchSlot.y).then(() => true, () => false); + assert.ok(back, '取消: the item is drawn back on its bench slot'); // again → pick the OLDER item (not the server's default either way: explicit replaceUid) → 确认替换 await dropItem(third); diff --git a/test/ui/loadout.e2e.test.js b/test/ui/loadout.e2e.test.js index 5068d3e9..0605833e 100644 --- a/test/ui/loadout.e2e.test.js +++ b/test/ui/loadout.e2e.test.js @@ -243,19 +243,33 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 }); assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/); // review: the two new 导出 / 导入 buttons must fit next to the countdown on a narrow phone in landscape (667×375). + // 0.2.0: the third tab (自选编队) ran the right column into the tabs there, at 844×390, and in English at every size. // Only the size changes (toggling isMobile / hasTouch would make puppeteer reload the page). - await page.setViewport({ width: 667, height: 375 }); - const bar = await page.evaluate(() => { - const top = document.querySelector('.lo-top'); - const right = document.querySelector('.lo-top__right').getBoundingClientRect(); - const center = document.querySelector('.lo-top__center').getBoundingClientRect(); - const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right)); - return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, buttons }; - }); - assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${JSON.stringify(bar)})`); - assert.equal(bar.overlap, false, `the right column does not run into the title (${JSON.stringify(bar)})`); - assert.equal(bar.offscreen, false, `every button ends inside the viewport (${JSON.stringify(bar)})`); - await page.screenshot({ path: path.join(OUT, 'loadout-narrow-toolbar.png') }); + const switchLang = async (lang) => { + await page.evaluate((l) => import('/js/ui/lang.js').then((m) => m.switchLang(l)), lang); + await page.waitForFunction((l) => document.documentElement.lang.startsWith(l) && /[一-鿿]/.test(document.querySelector('.lo-tabs').textContent) === (l === 'zh'), { timeout: 5000 }, lang); + }; + for (const [lang, sizes] of [['zh', [[667, 375], [844, 390]]], ['en', [[667, 375], [844, 390], [1920, 1080]]]]) { + if (lang !== 'zh') await switchLang(lang); + for (const [w, h] of sizes) { + await page.setViewport({ width: w, height: h }); + const bar = await page.evaluate(() => { + const top = document.querySelector('.lo-top'); + const tabs = document.querySelector('.lo-tabs'); + const right = document.querySelector('.lo-top__right').getBoundingClientRect(); + const center = document.querySelector('.lo-top__center').getBoundingClientRect(); + const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right)); + return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, tabsCut: tabs.scrollWidth - tabs.clientWidth, buttons }; + }); + const at = `${lang} ${w}×${h} ${JSON.stringify(bar)}`; + assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${at})`); + assert.equal(bar.overlap, false, `the right column does not run into the title (${at})`); + assert.equal(bar.offscreen, false, `every button ends inside the viewport (${at})`); + assert.ok(bar.tabsCut <= 0, `the three tabs show in full (${at})`); + if (w === 667) await page.screenshot({ path: path.join(OUT, `loadout-narrow-toolbar${lang === 'zh' ? '' : `-${lang}`}.png`) }); + } + } + await switchLang('zh'); await page.setViewport({ width: 1920, height: 1080 }); await page.type('.lo-search input', '隐现'); await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });