diff --git a/docs/design/client.md b/docs/design/client.md index 426d4864..8359756c 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -50,6 +50,12 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). +The owner's `prepMapChars` (外勤医疗's 预备干员-医疗, or another displayed map-character variant) are read-only +preparation views: tapping one opens its existing operator-style detail card and highlights its attack range from +its board-space position and facing, including on a mirrored boss half. They cannot be dragged, sold or retreated +from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates +the selected variant at battle start (§29.15). + Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. --- diff --git a/docs/design/network.md b/docs/design/network.md index 434cd6eb..8d672142 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -137,6 +137,7 @@ codes are UI metadata only, never authentication. No new network message or nick Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name. -Preparation state carries read-only `prepMapChars` for 外勤医疗. Boss-mate and scouting metadata include them in -displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client +Preparation state carries read-only `prepMapChars` for 外勤医疗. The owner's entries can be clicked for a detail card +and board-space range preview without adding a placement intent or a combat entity. Boss-mate and scouting metadata +include them in displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client disables picking and the server checks again before consumption. diff --git a/docs/history/0.2.4.md b/docs/history/0.2.4.md index 02262ca7..2ea9d53e 100644 --- a/docs/history/0.2.4.md +++ b/docs/history/0.2.4.md @@ -292,6 +292,13 @@ hooks and scheduled effects. [ASSUMED] If several players have prepared slots, t existing player order. A map character spawned ad hoc after battle start keeps normal summon order. The real flying 守墓石像 tests check both variants at target-lock time, a right-side 联防 Touch, and the higher-taunt exception. +The owner's further [official preparation screenshot](https://b23.tv/4JXAp12) at 0:17 shows 预备干员-医疗 selected, +with its detail card and attack range visible before combat. This proves an inspection affordance, but does not +establish that the official game already simulates the medic as a combat entity. The client now lets the existing +read-only preparation view open its operator-style card and board-space range by tap, including on the mirrored boss +half and in the flat fallback renderer. It remains immovable and the battle creation point is unchanged. The same +click path accepts another map-character variant if one is ever included in `prepMapChars`. + `npm run golden:update` recomputed all 287 scenarios. Only `coop2-NORMAL-14-standins` changed: its human seat uses `band_amedic`, and combat differences first appear in round 11. The match remains a round-14 victory with team LP 31; that seat's final kills move 272 → 273 and leaks 44 → 43. Its boss damage moves 193,091 → 189,876, while the partner's diff --git a/public/js/render/app.js b/public/js/render/app.js index d457dbd2..d43677ea 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -265,6 +265,7 @@ export async function createFieldView(host, options = {}) { let pendingView = null; // tile band/focus to apply when the camera transition ends const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id) let prepPieces = []; // { uid, piece, area, idx, row, col, key } + let prepMapChars = []; // { unit (board space), display } — read-only, but inspectable // the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band // or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b) let promoBase = []; @@ -804,16 +805,17 @@ export async function createFieldView(host, options = {}) { if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir); } for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); - // The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to - // the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates. - const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => { - if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null; + // The 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece. + // Keep its original board-space record for the detail card/range; only the rendered view is transformed to the + // displayed boss half. The partner's units already use field coordinates and are not own prep map characters. + prepMapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => { + if (!u || !Number.isInteger(u.uid) || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null; const p = prepXf.toDisp(u.y, u.x); const dir = prepXf.dirToDisp(u.dir); - return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 }; + return { unit: u, display: { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 } }; }).filter(Boolean) : []; const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []; - syncReadOnlyPrepUnits([...mapChars, ...mate]); + syncReadOnlyPrepUnits([...prepMapChars.map((e) => e.display), ...mate]); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) @@ -821,8 +823,8 @@ export async function createFieldView(host, options = {}) { } /** - * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not prep pieces, so they - * are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes. + * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not movable prep pieces; + * only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes. */ function syncReadOnlyPrepUnits(units) { const keep = new Set(); @@ -1016,6 +1018,17 @@ export async function createFieldView(host, options = {}) { return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true }; } + /** The owner's read-only preparation map character under a canvas point (never a drag target). */ + function mapCharAt(x, y) { + if (mode !== 'prep') return null; + const units = []; + for (const e of prepMapChars) { + const u = pickUnitOf(views.get(`m:${e.unit.uid}`)); + if (u) { u.unit = e.unit; units.push(u); } + } + return pickOnTile(units, groundTile(x, y))?.unit || null; + } + const drag = createDragController({ hitPiece: (x, y) => pieceAt(x, y), pickTile: (x, y) => pickBoardTile(x, y), @@ -1218,6 +1231,12 @@ export async function createFieldView(host, options = {}) { return; } if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; } + const mapChar = mapCharAt(ev.x, ev.y); + if (mapChar) { + emit('pieceClick', { unit: mapChar, unitId: null, uid: mapChar.uid, prepMapChar: true, + button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY }); + return; + } if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } } if (penViews.size && mode === 'prep') { const pv = penUnitAt(ev.x, ev.y); @@ -1299,6 +1318,7 @@ export async function createFieldView(host, options = {}) { clearViews(); if (prepPieces.length) promoBase = prepPieces; prepPieces = []; + prepMapChars = []; infos.clear(); gone.clear(); interp.reset(); @@ -1937,7 +1957,7 @@ export async function createFieldView(host, options = {}) { app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); }, promotions, // picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it - pick: { pieceAt, battleUnitAt, penUnitAt, groundTile }, + pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile }, }, }; // All teardown dependencies now exist. Aborting during either asset wait immediately releases diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 165eb512..e8b8b391 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -998,7 +998,7 @@ function MatchScreen() { // an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card) const penKey = previewEnemyKey(e); if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; } - if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid }); return; } + if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid, prepMapChar: e.prepMapChar === true }); return; } if (!Number.isInteger(e.uid)) return; setDetail({ kind: 'piece', uid: e.uid }); // a tap selects an own piece (underframe + range); right-click / long-press only opens its detail card. A tap on @@ -1398,7 +1398,7 @@ function MatchScreen() { return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards })(); // the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281): - // an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally + // an own board piece or read-only prepared map character in prep; in battle or on a scouted board an ally // operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's // tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen // draw their own diff --git a/public/js/screens/game/range.js b/public/js/screens/game/range.js index 969d528e..f0b80b6f 100644 --- a/public/js/screens/game/range.js +++ b/public/js/screens/game/range.js @@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field, const e = pieces?.get(target.uid); if (!e || e.area !== 'board') return null; row = e.row; col = e.col; dir = normDir(e.piece.dir); - } else if (target?.kind === 'unit' && !showPrep) { + } else if (target?.kind === 'unit' && (!showPrep || target.prepMapChar === true)) { const unit = target.unit; if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null; if (unit.area != null && unit.area !== 'board') return null; + // A prepared map character is a read-only view, not a simulated unit yet. Its board-space coordinates come from + // m.private.prepMapChars and must not be read from a previous battle's snapshot or transformed twice on boss R. + if (showPrep && (unit.kind !== 'token' || detail.type !== 'token' || detail.token?.kind !== 'mapChar')) return null; // Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation. - if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null; - row = Math.round(snapshot ? snapshot[2] : unit.y); - col = Math.round(snapshot ? snapshot[1] : unit.x); - dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT'); + if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null; + row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y); + col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x); + dir = (!showPrep && normDir(live?.dir, null)) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT'); } else return null; if (!Number.isFinite(row) || !Number.isFinite(col)) return null; const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) } diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index 925a5ece..e68214a1 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -337,7 +337,9 @@ export function createFallbackView(host, opts = {}) { const pos = tilePos(L, u.y, u.x); const src = tokenAvatarUrl(m(), u.defId); pieces.push(html`
{ if (e.button === 0) emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 0, clientX: e.clientX, clientY: e.clientY }); }} + onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY }); }} title=${u.name || u.defId}>
${src ? html`` : html`${[...(u.name || '?')][0]}`}
`); diff --git a/test/render/browser.test.js b/test/render/browser.test.js index dcae4be7..a2433ca6 100644 --- a/test/render/browser.test.js +++ b/test/render/browser.test.js @@ -96,7 +96,7 @@ describe('render engine in headless Chrome', { skip }, () => { } finally { await page.close(); } }); - test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => { + test('外勤医疗 reserve medic opens its detail from normal and right boss preparation without being draggable', async () => { const { page, problems } = await open('scene=prep&panel=0', 1600, 900); const first = await page.evaluate(() => { const v = window.__demo.view; @@ -108,8 +108,14 @@ describe('render engine in headless Chrome', { skip }, () => { v.setPrep(st, { editable: true }); const medic = v.debug.views.get('m:-1'); const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); + let clicked = null; + const off = v.on('pieceClick', (e) => { clicked = e; }); + v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 61, clientX: p.x, clientY: p.y })); + off(); const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y, - draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) }; + draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top), + clicked: clicked ? { uid: clicked.uid, id: clicked.unit?.defId, row: clicked.unit?.y, col: clicked.unit?.x, + prepMapChar: clicked.prepMapChar } : null }; return out; }); await wait(500); @@ -118,7 +124,13 @@ describe('render engine in headless Chrome', { skip }, () => { const v = window.__demo.view; v.setCamera('bossPrep', { side: 'R', instant: true }); const mirrored = v.debug.views.get('m:-1'); - return [mirrored?.x, mirrored?.y, mirrored?.dir]; + const p = v.tileScreen(10, 2); + let clicked = null; + const off = v.on('pieceClick', (e) => { clicked = e; }); + v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 62, clientX: p.x, clientY: p.y })); + off(); + return { view: [mirrored?.x, mirrored?.y, mirrored?.dir], + clicked: clicked ? [clicked.unit?.y, clicked.unit?.x, clicked.unit?.dir, clicked.prepMapChar] : null }; }); await wait(500); await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') }); @@ -127,7 +139,8 @@ describe('render engine in headless Chrome', { skip }, () => { const st = JSON.parse(JSON.stringify(window.__demo.scene.state)); st.prepMapChars = []; v.setPrep(st, { editable: true }); - return !v.debug.views.has('m:-1'); + const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); + return !v.debug.views.has('m:-1') && !v.debug.pick.mapCharAt(p.x - rect.left, p.y - rect.top); }); const fallback = await page.evaluate(async () => { const { createFallbackView } = await import('/js/ui/fallbackField.js'); @@ -138,18 +151,23 @@ describe('render engine in headless Chrome', { skip }, () => { ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const el = host.querySelector('.ff-piece--map-char'); + let clicked = null; + const off = ff.on('pieceClick', (e) => { clicked = e; }); + el?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 63 })); + off(); const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null, - movableId: el?.dataset.uid ?? null }; + movableId: el?.dataset.uid ?? null, clicked: clicked ? [clicked.unit?.defId, clicked.prepMapChar] : null }; ff.destroy(); host.remove(); return out; }); await page.close(); assert.deepEqual(problems, []); assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10, - draggable: false }); - assert.deepEqual(boss, [18, 3, 'LEFT']); + draggable: false, clicked: { uid: -1, id: 'char_605_cmedic', row: 10, col: 2, prepMapChar: true } }); + assert.deepEqual(boss, { view: [18, 3, 'LEFT'], clicked: [10, 2, 'RIGHT', true] }); assert.equal(removed, true); - assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null }); + assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'auto', movableId: null, + clicked: ['char_605_cmedic', true] }); }); test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => { diff --git a/test/ui/inspect-range.test.js b/test/ui/inspect-range.test.js index 49e1674e..50ec3417 100644 --- a/test/ui/inspect-range.test.js +++ b/test/ui/inspect-range.test.js @@ -44,6 +44,18 @@ test('user Given strategy medics When inspecting them Then their healing tiles r } }); +test('user Given a prepared map medic When its card opens Then its board-space range is visible before battle', () => { + for (const id of ['char_605_cmedic', 'char_613_acmedc']) { + const unit = medic(id, { id: -1, uid: -1, area: 'board', x: 2, y: 10, dir: 'RIGHT' }); + const r = inspectRange({ ...args(unit), target: { kind: 'unit', unit, prepMapChar: true }, showPrep: true, + field: { prep: false }, snapshot: [99, 18, 3, 1202, 1202] }); + assert.deepEqual([r.row, r.col, r.dir], [10, 2, 'RIGHT'], 'use the original board coordinates even on the mirrored boss half'); + assert.equal(rangeTiles(r.grid, r.row, r.col, r.dir).length, 12); + assert.equal(inspectRange({ ...args(unit), target: { kind: 'unit', unit }, showPrep: true }), null, + 'other unit cards do not become prep-range previews'); + } +}); + test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => { const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } })); assert.equal(r.grid.length, 18); diff --git a/test/ui/playtest6_summons.test.js b/test/ui/playtest6_summons.test.js index c6f6e423..60a75e7a 100644 --- a/test/ui/playtest6_summons.test.js +++ b/test/ui/playtest6_summons.test.js @@ -134,15 +134,19 @@ test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and sh ['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']], ['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%', '攻击提升', '攻击力+4%', '恢复友方单位生命']], ]) { - // a battle click resolves the unit as its token record - const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map()); - assert.equal(detail.type, 'token'); - const text = allText(TokenDetail(detail)); - assert.match(text, /干员/); - assert.match(text, /医疗/); - assert.doesNotMatch(text, /召唤物/); - for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`); - assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`); + for (const unit of [ + { id: 91, side: 'ally', defId: id }, // battle click + { id: -1, uid: -1, kind: 'token', side: 'ally', defId: id, area: 'board', x: 2, y: 10 }, // preparation preview click + ]) { + const detail = resolveDetail({ kind: 'unit', unit, prepMapChar: unit.id === -1 }, new Map()); + assert.equal(detail.type, 'token'); + const text = allText(TokenDetail(detail)); + assert.match(text, /干员/); + assert.match(text, /医疗/); + assert.doesNotMatch(text, /召唤物/); + for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`); + assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`); + } } // the record keeps the official sentence; the line under it is what touchGospel does (strictly below hp_ratio) assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/);