feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged
Ported by hand: - public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into ui/gameLogic/shortcuts.js. - i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone, Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages. - public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo / fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only, so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name). - public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers. - public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear button's aria-label / title are t('设置') (en.json already has "Settings"). - tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the voice plan unchanged. - data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files 6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields. - docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md, DEPLOY.md, PLAYING.md auto-merged. - Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js, textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests. Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15, board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313, docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283 scenarios match, nothing regenerated.
This commit is contained in:
commit
7059528c47
40 files changed
+1944
-97
No files matched your search
@@ -664,3 +664,62 @@ describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
} finally { globalThis.performance = perf; restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
|
||||
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
|
||||
// silent, for the player's own field and for a 联防 the helpers could not hold alike.
|
||||
//
|
||||
// The cue is the ORIGINAL Arknights stage alarm an enemy entering the exit plays in any normal stage: the manifest's
|
||||
// `sfx.battle.leak`, bank `battle.ON_ENEMY_REACHED_EXIT`, file `Battle/b_ui/b_ui_alarmenter`. (The autochess banks
|
||||
// have nothing named for an escape — all 13,948 SFX banks searched — but the stage itself does.) The official bank is
|
||||
// a one-shot: `maxSoundAllowed: 1` with `popOldest: true` on the `Battle_UI_Important` mixer.
|
||||
|
||||
describe('漏怪 sound', () => {
|
||||
async function rig() {
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||
return { a, fw, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
|
||||
test('an escaped enemy plays the original stage exit alarm — and no death sound (a leak is not a `die`)', async () => {
|
||||
const { a, urls, settle, restore } = await rig();
|
||||
try {
|
||||
const url = manifest.audio.sfx.battle.leak;
|
||||
assert.ok(url, '前提:清单里有 sfx.battle.leak');
|
||||
assert.match(url, /b_ui_alarmenter\.mp3$/, '就是原版关卡里怪进蓝门那一声');
|
||||
a.handleBattleEvents([['leak', 7]]);
|
||||
await settle();
|
||||
assert.ok(asked(urls, url), `漏怪 plays ${url}`);
|
||||
assert.equal(askedCount(urls, manifest.audio.sfx.battle.enemyDie), 0, 'a leak is not a death — no death sound');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('leaks of one disaster are ONE alarm (the cue is 1.44 s long), a later one rings again', async () => {
|
||||
const { a, fw, urls, settle, restore } = await rig();
|
||||
try {
|
||||
const url = manifest.audio.sfx.battle.leak;
|
||||
a.handleBattleEvents([['leak', 1]]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, url), 1, 'the first escape rings');
|
||||
// the plays themselves, not the fetches: the buffer is cached after the first one
|
||||
const before = fw.made.started;
|
||||
// six more at once — a wiped board, or a 联防 the helpers could not hold
|
||||
a.handleBattleEvents([['leak', 2], ['leak', 3], ['leak', 4], ['leak', 5], ['leak', 6], ['leak', 7]]);
|
||||
await settle();
|
||||
assert.equal(fw.made.started - before, 0, 'one disaster never stacks alarms (the official bank allows 1)');
|
||||
// a genuine later leak is a new disaster and rings again, once the cue (1.44 s) has finished
|
||||
await new Promise((r) => setTimeout(r, 1600));
|
||||
a.handleBattleEvents([['leak', 8]]);
|
||||
await settle();
|
||||
assert.equal(fw.made.started - before, 1, 'a later leak rings again');
|
||||
assert.ok(a.limiter.active <= a.limiter.maxVoices);
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
+109
-1
@@ -12,7 +12,7 @@ import {
|
||||
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
|
||||
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
|
||||
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { pairPlayers } from '../../server/match/finalAssault.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
@@ -585,3 +585,111 @@ describe('equipment dropped on a tile goes to the unit on it', () => {
|
||||
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
|
||||
});
|
||||
});
|
||||
|
||||
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: tapping a special terrain tile explains it — in the stage's own
|
||||
// numbers (the same bb / special parameters the sim runs on), while an ordinary tile says nothing at all.
|
||||
describe('special terrain tip', () => {
|
||||
/** The first (row, col) of `glyph` in a real stage of data/stages.json. */
|
||||
const at = (stageId, glyph) => {
|
||||
const st = stages[stageId];
|
||||
for (let row = 0; row < st.rows.length; row++) {
|
||||
const col = st.rows[row].indexOf(glyph);
|
||||
if (col >= 0) return { st, row, col };
|
||||
}
|
||||
throw new Error(`no ${glyph} in ${stageId}`);
|
||||
};
|
||||
|
||||
test('each terrain of the mode, with the numbers of the stage it stands on', () => {
|
||||
// 活性源石 (战场#04): the official tile parameters (damage 70/s, +20% ATK, +20 ASPD, 300 s)
|
||||
const inf = at('act1autochess_m04', 'i');
|
||||
const tip = terrainInfo(inf.st, inf.row, inf.col);
|
||||
assert.equal(tip.key, 'infection');
|
||||
assert.equal(tip.name, '活性源石');
|
||||
assert.equal(tip.tag, '特殊地形');
|
||||
assert.deepEqual([tip.row, tip.col], [inf.row, inf.col]);
|
||||
assert.match(tip.lines[0], /每秒受到 70 点真实伤害/, 'damage from the stage\'s own bb');
|
||||
assert.match(tip.lines[1], /攻击力 \+20%、攻击速度 \+20/);
|
||||
assert.match(tip.lines[2], /300 秒/);
|
||||
assert.deepEqual(tip.facts, ['可部署', '地面单位可通过']);
|
||||
// 沼泽 (战场#06): one 陷入沼泽 layer a second, −5% ASPD (−5% move for enemies), 2 layers at 重量 ≥ 3, 10 at most
|
||||
const mire = terrainInfo(...(() => { const g = at('act2autochess_m02', 'm'); return [g.st, g.row, g.col]; })());
|
||||
assert.equal(mire.name, '沼泽');
|
||||
assert.ok(mire.lines.some((l) => /攻击速度 −5%/.test(l) && /移动速度 −5%/.test(l)));
|
||||
assert.ok(mire.lines.some((l) => /重量 ≥ 3 的敌人一次获得 2 层/.test(l)));
|
||||
assert.ok(mire.lines.some((l) => /最多 10 层/.test(l)));
|
||||
// 排气格栅 (战场#07): the 隐匿-like rule and its limit — the enemy it blocks still hits it (review on #185)
|
||||
const smog = at('act2autochess_m03', 'g');
|
||||
const smogTip = terrainInfo(smog.st, smog.row, smog.col);
|
||||
assert.equal(smogTip.name, '排气格栅');
|
||||
assert.match(smogTip.lines[0], /不会被敌方的远程攻击选中(效果相当于隐匿)/);
|
||||
assert.match(smogTip.lines[1], /挡住敌人的干员仍会被它攻击到/);
|
||||
// 深水区 (战场#05): drowning numbers, and 拒绝部署 although the level's own buildableType is ALL (grid.js)
|
||||
const sea = at('act1autochess_m05', 'd');
|
||||
const seaTip = terrainInfo(sea.st, sea.row, sea.col);
|
||||
assert.equal(seaTip.name, '深水区');
|
||||
assert.ok(seaTip.lines.some((l) => /每秒受到 40 点伤害/.test(l)));
|
||||
assert.ok(seaTip.lines.some((l) => /攻击速度 −60%/.test(l) && /移动速度 ×0.6/.test(l)));
|
||||
// …and what that damage IS: sourceless, and not 环境伤害 (devices.js tickDeepsea; review on #185)
|
||||
assert.ok(seaTip.lines.some((l) => /无来源伤害/.test(l) && /不归类为环境伤害/.test(l)));
|
||||
assert.ok(seaTip.lines.some((l) => /拒绝部署/.test(l)));
|
||||
assert.ok(seaTip.facts.includes('不可部署'));
|
||||
// the gates and teleports every stage carries (tile_start / tile_end / tile_telin / tile_telout)
|
||||
for (const [glyph, name] of [['S', '红门'], ['E', '蓝门'], ['I', '传送入口'], ['O', '传送出口']]) {
|
||||
const g = at('act1autochess_m04', glyph);
|
||||
const t = terrainInfo(g.st, g.row, g.col);
|
||||
assert.equal(t.name, name, glyph);
|
||||
assert.ok(t.lines.length >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('an ordinary tile says nothing (the press keeps its other meanings); nonsense input is safe', () => {
|
||||
const st = stages['act1autochess_m04'];
|
||||
const inf = at('act1autochess_m04', 'i');
|
||||
assert.ok(terrainInfo(inf.st, inf.row, inf.col), 'the stage does carry one tile that answers');
|
||||
// every non-special glyph the stage uses: the bench, the blocked rows, road / floor, the fence, the separator…
|
||||
for (const glyph of ['a', 'A', '#', 'X', 'r', 'f', 'b', 'h', 'p']) {
|
||||
let found = null;
|
||||
for (let row = 0; row < st.rows.length && !found; row++) {
|
||||
const col = st.rows[row].indexOf(glyph);
|
||||
if (col >= 0) found = { row, col };
|
||||
}
|
||||
if (!found) continue; // a glyph this stage does not use says nothing to test
|
||||
assert.equal(terrainInfo(st, found.row, found.col), null, glyph);
|
||||
}
|
||||
assert.equal(terrainInfo(inf.st, -1, inf.col), null);
|
||||
assert.equal(terrainInfo(inf.st, inf.st.rows.length, 0), null);
|
||||
assert.equal(terrainInfo(inf.st, inf.row, 999), null);
|
||||
assert.equal(terrainInfo(inf.st, 1.5, 2), null);
|
||||
assert.equal(terrainInfo(inf.st, '3', 2), null);
|
||||
assert.equal(terrainInfo(null, 0, 0), null);
|
||||
assert.equal(terrainInfo(undefined, 0, 0), null);
|
||||
assert.equal(terrainInfo({}, 0, 0), null);
|
||||
// a stage without the tile legend (or without that glyph) explains nothing rather than guessing
|
||||
assert.equal(terrainInfo({ rows: ['i'] }, 0, 0), null);
|
||||
assert.equal(terrainInfo({ rows: ['i'], tiles: {} }, 0, 0), null);
|
||||
assert.equal(terrainInfo({ rows: ['i'], tiles: { i: null } }, 0, 0), null);
|
||||
// …and a legend entry the mode never gave a tip (its own ordinary floor) is not a tip either
|
||||
assert.equal(terrainInfo({ rows: ['z'], tiles: { z: { tileKey: 'tile_floor', special: null } } }, 0, 0), null);
|
||||
});
|
||||
|
||||
test('the facts come from the tile\'s own legend entry; missing terrain parameters never crash', () => {
|
||||
const stage = {
|
||||
rows: ['ih'],
|
||||
tiles: {
|
||||
i: { tileKey: 'tile_infection', height: 'LOW', buildable: 'RANGED', groundPassable: false, special: 'infection' },
|
||||
h: { tileKey: 'tile_smog', height: 'HIGH', buildable: 'NONE', groundPassable: false, special: 'smog' },
|
||||
},
|
||||
special: {},
|
||||
};
|
||||
assert.deepEqual(terrainInfo(stage, 0, 0).facts, ['仅远程位可部署', '只有空中单位能通过']);
|
||||
assert.deepEqual(terrainInfo(stage, 0, 1).facts, ['不可部署', '高台', '只有空中单位能通过']);
|
||||
// a stage whose `special` is missing: the mechanism is still explained, just without the stage's numbers
|
||||
const bare = { rows: ['ih'], tiles: stage.tiles };
|
||||
assert.deepEqual(terrainInfo(bare, 0, 0).lines, ['在其上的我方单位与经过的敌方单位持续受到伤害']);
|
||||
assert.deepEqual(terrainInfo(bare, 0, 1).lines, ['站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)', '但挡住敌人的干员仍会被它攻击到']);
|
||||
// 沼泽 without its parameters falls back to the official template numbers
|
||||
const mire = { rows: ['m'], tiles: { m: { tileKey: 'tile_mire', height: 'LOW', buildable: 'ALL', groundPassable: true, special: 'mire' } } };
|
||||
assert.match(terrainInfo(mire, 0, 0).lines[0], /每 1 秒获得 1 层/);
|
||||
assert.match(terrainInfo(mire, 0, 0).lines[2], /最多 10 层/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: the tap's target (`screens/game.js` tileClick hands over
|
||||
// `{ kind: 'terrain', terrain }`, resolved from the stage the board on screen is built from) turns into the panel's
|
||||
// terrain card — and a target that carries no tip resolves to nothing rather than opening an empty card.
|
||||
// The words / numbers themselves are covered in test/ui/gameLogic.test.js (terrainInfo) and the tap itself reaches them
|
||||
// in the browser (test/ui/terrain-tip.e2e.test.js); this is the link between the two.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { terrainInfo } = await import('../../public/js/ui/gameLogic.js');
|
||||
|
||||
/** The first (row, col) of `glyph` in a stage of data/stages.json. */
|
||||
function at(stage, glyph) {
|
||||
for (let row = 0; row < stage.rows.length; row++) {
|
||||
const col = stage.rows[row].indexOf(glyph);
|
||||
if (col >= 0) return { row, col };
|
||||
}
|
||||
throw new Error(`no ${glyph} in ${stage.id}`);
|
||||
}
|
||||
|
||||
test('a terrain target resolves into the panel card; a tip-less target resolves to nothing', () => {
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
|
||||
const st = stages['act1autochess_m04'];
|
||||
const p = at(st, 'i');
|
||||
const info = terrainInfo(st, p.row, p.col);
|
||||
assert.equal(info.name, '活性源石');
|
||||
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: info }, new Map()), { type: 'terrain', terrain: info });
|
||||
// the screen never hands a tip-less tile over (terrainInfo returns null there), but a stray target must not throw or
|
||||
// open an empty card
|
||||
assert.equal(resolveDetail({ kind: 'terrain' }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'terrain', terrain: null }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'terrain', terrain: '活性源石' }, new Map()), null);
|
||||
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: {} }, new Map()), { type: 'terrain', terrain: {} },
|
||||
'a well-formed but empty tip still resolves (the screen filtered it before)');
|
||||
assert.equal(resolveDetail(null, new Map()), null);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Browser checks of the special-terrain tip (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) on the in-match mock
|
||||
// harness (public/dev/game-mock.html):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/terrain-tip.e2e.test.js
|
||||
//
|
||||
// A tap on the GROUND — nothing stands there — makes the tile explain itself, on both field implementations: the engine
|
||||
// canvas (render/app.js `tileClick`, reached by clicking the tile's own screen position) and the DOM fallback
|
||||
// (ui/fallbackField.js, the tile div). Asserted: the mock's own stage (act2autochess_m01) carries gates, a tap on 蓝门 /
|
||||
// 红门 opens the card with its mechanism line, a tap on an ordinary floor tile opens nothing (and closes the card that was
|
||||
// open), and no scenario logs a console error. The words and the numbers are unit-tested in test/ui/gameLogic.test.js
|
||||
// (terrainInfo, against every terrain of the real stages) — this file is about the tap reaching them.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Tiles of the mock's stage (act2autochess_m01) the tip must answer for, and one ordinary tile it must not. */
|
||||
const GATE = { row: 9, col: 10, name: '红门' };
|
||||
const GOAL = { row: 9, col: 2, name: '蓝门' };
|
||||
const FLOOR = { row: 10, col: 5, name: null };
|
||||
|
||||
describe('special terrain tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
|
||||
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** The open detail card's text (null while none is open). */
|
||||
const card = (page) => page.evaluate(() => {
|
||||
const el = document.querySelector('.dpanel');
|
||||
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||
});
|
||||
|
||||
test('the fallback (DOM) board: a tap on 蓝门 / 红门 opens the card, an ordinary tile opens nothing', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=fallback');
|
||||
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||
await sleep(400);
|
||||
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||
if (!el) return false;
|
||||
const b = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||
return true;
|
||||
}, [row, col]);
|
||||
|
||||
assert.ok(await tap(GOAL.row, GOAL.col), 'the mock board draws the 蓝门 tile');
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
let text = await card(page);
|
||||
assert.match(text, /蓝门/, text);
|
||||
assert.match(text, /目标生命值/, text);
|
||||
assert.match(text, /保护目标/, text);
|
||||
|
||||
// the 红门 (the enemies' own entrance) says its own thing
|
||||
assert.ok(await tap(GATE.row, GATE.col));
|
||||
await sleep(200);
|
||||
text = await card(page);
|
||||
assert.match(text, /红门/, text);
|
||||
assert.match(text, /从这里出场/, text);
|
||||
|
||||
// an ordinary floor tile: nothing to explain, and the card that was open closes with the press
|
||||
assert.ok(await tap(FLOOR.row, FLOOR.col));
|
||||
await sleep(200);
|
||||
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=engine', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await sleep(1200); // models / camera
|
||||
const at = (row, col) => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [row, col]);
|
||||
const pt = await at(GOAL.row, GOAL.col);
|
||||
assert.ok(pt, 'the 蓝门 tile has a screen position');
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
let text = await card(page);
|
||||
assert.match(text, /蓝门/, text);
|
||||
assert.match(text, /目标生命值/, text);
|
||||
|
||||
const floor = await at(FLOOR.row, FLOOR.col);
|
||||
await page.mouse.click(floor.x, floor.y);
|
||||
await sleep(250);
|
||||
assert.equal(await card(page), null, 'the floor tile closes it again');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('Final Assault prep: the tap reports the BOARD tile, so the boss field explains ITS tile (review on #185)', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&variant=boss&render=engine', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await page.waitForFunction(() => globalThis.__SP_VIEW__.raw.prepField?.().kind === 'bossPrep', { timeout: 15000 });
|
||||
await sleep(1200); // camera / models (a deployment with the local board art also builds the 3D board here)
|
||||
// A board tile of the player's half + the stage tile it draws (stage 2–5 shown as board 9–12): the card must be about
|
||||
// the stage tile. Reporting the drawn tile instead (the old groundTile) made the screen convert a second time, which
|
||||
// on this board explains a DIFFERENT tile — or none at all.
|
||||
const target = await page.evaluate(async () => {
|
||||
const { data } = await import('/js/data.js');
|
||||
const gl = await import('/js/ui/gameLogic.js');
|
||||
const S = globalThis.__MOCK__.S();
|
||||
const stage = data.lookup('stages', S.pub.stageId);
|
||||
for (let row = 9; row <= 12; row++) {
|
||||
for (let col = 0; col <= 10; col++) {
|
||||
const [sr, sc] = gl.fieldTile('bossL', row, col);
|
||||
const info = gl.terrainInfo(stage, sr, sc);
|
||||
if (!info) continue;
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(row, col);
|
||||
if (t) return { row, col, sr, sc, name: info.name, x: t.x, y: t.y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.ok(target, 'the boss field has a special tile on the player half');
|
||||
// The boss-prep camera is still settling when the board appears (and building the 3D board shifts a machine's timing),
|
||||
// so wait for the tile's screen position to stop moving, and re-read it before each try.
|
||||
const at = () => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [target.row, target.col]);
|
||||
let settled = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const p = await at();
|
||||
if (p && settled && Math.abs(p.x - settled.x) < 1 && Math.abs(p.y - settled.y) < 1) break;
|
||||
settled = p;
|
||||
await sleep(150);
|
||||
}
|
||||
assert.ok(settled, 'the boss-field tile still has a screen position');
|
||||
let text = null;
|
||||
for (let i = 0; i < 3 && text === null; i++) {
|
||||
const p = (await at()) || settled;
|
||||
await page.mouse.click(p.x, p.y);
|
||||
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; } // a tap that missed: retry
|
||||
text = await card(page);
|
||||
}
|
||||
assert.ok(text !== null, 'the tap opened the terrain card');
|
||||
assert.match(text, new RegExp(target.name), `board (${target.row},${target.col}) → stage (${target.sr},${target.sc}) is ${target.name}, card: ${text}`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user