A special terrain tile explains itself on a tap, and 活性源石 is one material on both boards (GitHub #184) (#185)

Tapping a 活性源石 / 沼泽 / 深水区 / 排气格栅 / gate / teleport tile opens a read-only card with that stage's own terrain numbers (the boss-prep board picks the board tile once, through prepXf.toBoard); the 2D atlas cell and the 3D shader now share one 活性源石 palette and recipe and draw to the tile edge. Contributed by @Convey123.
This commit is contained in:
Cartethyia authored and GitHub committed 2026-10-06 14:08:18 +08:00
1 parent d582925816
commit f6f5ed50cd
16 files changed
+905 -37

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
// GitHub #184 (second half): 活性源石 was drawn as two different materials — the 2D atlas cell was a beveled brick with
// random crystal clusters, the 3D board's quad a world-space crust with pulsing veins — so the same tile read as two
// different floors. These are the couplings that keep it ONE material: the palette of `render/style.js ORIGINIUM`, the
// concrete slab it lies on, and no per-tile frame (which made a field of 活性源石 look like separate patches).
// The pixels themselves are checked in test/render/originium-cell.e2e.test.js (it needs a real canvas).
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 read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const style = read('public/js/render/style.js');
const textures = read('public/js/render/textures.js');
const materials = read('public/js/render/board3d/materials.js');
const tiles = read('public/js/render/tiles.js');
/** The source of one exported function of a module (its JSDoc through the closing brace at column 0). */
function fnSource(src, name) {
const at = src.indexOf(`export function ${name}(`);
assert.ok(at > 0, `${name} not found`);
const end = src.indexOf('\n}\n', at);
return src.slice(src.lastIndexOf('/**', at), end + 2);
}
test('活性源石: one palette for both boards, and the canvas converts the working space', () => {
assert.match(style, /export const ORIGINIUM = Object\.freeze\(\{/, 'the palette lives in style.js');
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(style, new RegExp(`${k}: Object\\.freeze\\(\\[`), k);
assert.match(style, /export function linearToSrgb255/, 'the sRGB conversion the canvas needs');
// the 2D drawer takes its colours from the palette (no private copy) and converts them for the canvas
assert.match(textures, /import \{[^}]*ORIGINIUM[^}]*\} from '\.\/style\.js'/);
assert.match(textures, /ORIGINIUM\.vein/);
assert.match(textures, /linearToSrgb255\(/, 'the canvas stores sRGB, the palette is the shaders\' linear space');
// so does the 3D shader, through the vec3 helper
assert.match(materials, /import \{ ORIGINIUM \} from '\.\.\/style\.js'/);
assert.match(materials, /const V3 = \(c\) => `vec3\(/);
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(materials, new RegExp(`V3\\(ORIGINIUM\\.${k}\\)`), k);
// the identical recipe (the shader's own thresholds, mirrored in the drawer's smoothstep calls)
assert.match(materials, /smoothstep\(0\.38, 0\.62, n \* 0\.7 \+ n2 \* 0\.45\)/);
assert.match(textures, /smoothstep\(0\.38, 0\.62, n1 \* 0\.7 \+ n2 \* 0\.45\)/);
assert.match(materials, /smoothstep\(0\.86, 0\.98, 1\.0 - abs\(n2 \* 2\.0 - 1\.0\)\)/);
assert.match(textures, /smoothstep\(0\.86, 0\.98, 1 - Math\.abs\(n2 \* 2 - 1\)\)/);
});
test('活性源石: the 2D tile lies on the same concrete the 3D board lays its crust on', () => {
// board3d/atlas.js `concrete` and textures.js DEFAULT_ART_LAYERS.infection must name the same atlas rect: with the
// board art installed the 2D tile is composed exactly like the 3D one (official slab + our crust).
const atlas = read('public/js/render/board3d/atlas.js');
const m2 = atlas.match(/concrete: S\('D', \[([\d, ]+)\]\)/);
const m1 = textures.match(/infection: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[([\d, ]+)\] \}\)/);
assert.ok(m1 && m2, 'both define the slab');
assert.equal(m1[1].replace(/\s/g, ''), m2[1].replace(/\s/g, ''), 'the same concrete cell');
});
test('活性源石: neither board fades the tile out at its own border', () => {
// the 3D crust used to be multiplied by a per-tile `edge` fade (min(vUv, 1 − vUv)), which left a transparent band
// between neighbouring tiles; the drawer must reach the cell's edges the same way. Its siblings (mire / water) keep
// their own fade — this is about 活性源石 only.
const inf = fnSource(materials, 'infectionMaterial');
assert.ok(!/min\(e\.x, e\.y\)|float edge/.test(inf), 'no per-tile edge fade in the infection shader');
assert.match(inf, /float a = clamp\(crust \* 0\.72 \+ vein, 0\.0, 1\.0\)/);
const mire = fnSource(materials, 'mireMaterial');
assert.match(mire, /float edge = smoothstep/, 'the mire keeps its own fade');
// the 2D cell has no frame either: no bevelTile in the drawer's two variants
const draw = textures.slice(textures.indexOf(' infection(ctx, x, y, s, r) {'), textures.indexOf(' cliff(ctx, x, y, s) {'));
assert.ok(!/bevelTile/.test(draw), 'the old beveled brick is gone');
assert.match(draw, /floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, false\);/);
assert.match(draw, /infection2\(ctx, x, y, s, r\) \{ floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, true\); \}/);
});
test('活性源石: two variants are registered, picked by tile, and the pulse uses the shared tint', () => {
assert.match(textures, /'deepsea', 'infection', 'infection2', 'cliff'/);
assert.match(textures, /originium2\(ctx, x, y, s\) \{ originiumOverlay\(ctx, x, y, s, true\); \}/, 'the proc layer for the art path');
assert.match(textures, /infection2: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[256, 512, 256, 256\] \}\), Object\.freeze\(\{ proc: 'originium2' \}\)\]\)/);
assert.match(tiles, /case 'infection': return h < 4 \? 'infection' : 'infection2';/);
assert.match(tiles, /add\('infect', 'glow', P\.BLEND_MODES\.ADD, ORIGINIUM\.glow, 1\)/);
});
+133
View File
@@ -0,0 +1,133 @@
// GitHub #184 (second half), the pixels: the 2D board's 活性源石 cell must be the material the 3D board's shader draws
// (`render/board3d/materials.js infectionMaterial`) — the palette of `render/style.js ORIGINIUM`, the crust reaching the
// tile's edges (no per-tile frame: a field of 活性源石 must read as ONE floor, not as separate patches), and the cell
// joining itself without a seam (the noise wraps, so a repeated cell lines up).
// SP_E2E=1 CHROME_PATH=… node --test test/render/originium-cell.e2e.test.js
// Both configurations are checked: the atlas a deployment WITH the board art renders (the official concrete slab + our
// crust, `DEFAULT_ART_LAYERS.infection`) and the procedural one upstream players without the art see (`MAT_DRAW.infection`).
// The couplings between the two renderers' sources are in test/render/originium-art.test.js (no browser needed).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const f3 = (v) => Math.round(v * 1000) / 1000;
describe('活性源石 cell (2D board)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
let configs = []; // [{ name, art, infection, infection2, road, palette }]
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'] });
const page = await browser.newPage();
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'domcontentloaded' });
await page.addScriptTag({ url: '/vendor/pixi.min.js' }); // textures.js builds its atlas with PIXI
configs = await page.evaluate(async () => {
const m = await import('/js/render/textures.js');
const st = await import('/js/render/style.js');
const { assets } = await import('/js/assets.js');
const { loadBoardArt } = await import('/js/render/boardArt.js');
// the atlas a deployment with the board art renders, and the procedural one (no art) upstream sees
await assets.load?.().catch?.(() => null);
const art = await loadBoardArt(assets).catch(() => null);
const read = (atlas, name) => {
const uv = atlas.uv[name];
if (!uv) return null;
const W = atlas.canvas.width, H = atlas.canvas.height;
const x0 = Math.round(uv[0] * W), y0 = Math.round(uv[1] * H);
const w = Math.round((uv[2] - uv[0]) * W), h = Math.round((uv[3] - uv[1]) * H);
const img = atlas.canvas.getContext('2d').getImageData(x0, y0, w, h);
const px = [];
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const k = (y * w + x) * 4;
px.push({ x, y, r: img.data[k] / 255, g: img.data[k + 1] / 255, b: img.data[k + 2] / 255, a: img.data[k + 3] / 255 });
}
return { w, h, px, data: Array.from(img.data) };
};
const cells = (atlas, name) => ({ name, art: atlas.art, artCount: atlas.artCount,
infection: read(atlas, 'infection'), infection2: read(atlas, 'infection2'), road: read(atlas, 'road') });
// the vein's DISPLAYED colour (what a canvas stores): the palette converted out of the shaders' working space
const hex = st.linearToHex(st.ORIGINIUM.vein);
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const palette = { veinHex: hex, gr: rgb[1] / rgb[0], br: rgb[2] / rgb[0] };
const out = [cells(m.tileAtlas(art || null), art ? 'board art' : 'no art installed')];
if (art) out.push(cells(m.tileAtlas(null), 'procedural'));
return out.map((c) => ({ ...c, palette }));
});
await page.close();
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** The numbers of one cell: veins, edges, ring vs middle, and the difference to another cell. */
function stats(c) {
const veinish = (p) => p.a > 0.6 && p.r > 0.55 && p.g > 0.2 && p.g < 0.8 && p.b < 0.5;
const mean = (arr, k) => arr.reduce((n, p) => n + p[k], 0) / Math.max(1, arr.length);
const hot = c.px.filter(veinish);
const ring = c.px.filter((p) => Math.min(p.x, p.y, c.w - 1 - p.x, c.h - 1 - p.y) < c.w * 0.08);
const lum = (arr) => arr.reduce((n, p) => n + (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b) * p.a, 0) / Math.max(1, arr.length);
const band = (pred) => { const a = c.px.filter(pred); return f3(a.filter(veinish).length / Math.max(1, a.length)); };
// the vein CORES are opaque, so their colour does not depend on the slab underneath (the art config draws one):
// the brightest few percent are exactly those, whichever base the cell has
const core = [...c.px].sort((p, q) => (q.r + q.g + q.b) - (p.r + p.g + p.b)).slice(0, Math.max(1, Math.round(c.px.length * 0.03)));
return {
veinPct: f3(hot.length / c.px.length), ringVeinPct: f3(ring.filter(veinish).length / ring.length),
hotGR: f3(mean(hot, 'g') / mean(hot, 'r')), hotBR: f3(mean(hot, 'b') / mean(hot, 'r')), borderLum: f3(lum(ring)),
coreGR: f3(mean(core, 'g') / mean(core, 'r')), coreBR: f3(mean(core, 'b') / mean(core, 'r')),
// our own crust at the tile's borders (the base slab may be official art with edges of its own, so the veins — the
// layer this change drew — are what shows whether the tile joins the next one)
veinsL: band((p) => p.x < 3), veinsR: band((p) => p.x >= c.w - 3),
veinsT: band((p) => p.y < 3), veinsB: band((p) => p.y >= c.h - 3),
};
}
const diff = (a, b) => f3(a.data.reduce((n, v, i) => n + Math.abs(v - b.data[i]), 0) / a.data.length);
test('the cell wears the shared palette: the veins hit the colour the 3D shader writes', () => {
for (const cfg of configs) {
const s = stats(cfg.infection);
const { gr, br, veinHex } = cfg.palette;
assert.ok(s.veinPct >= 0.05, `${cfg.name}: the crust carries veins (${s.veinPct})`);
assert.ok(Math.abs(s.coreGR - gr) <= 0.06, `${cfg.name}: the veins wear the palette's colour (g/r ${s.coreGR} vs ${f3(gr)} for ${veinHex})`);
assert.ok(Math.abs(s.coreBR - br) <= 0.06, `${cfg.name}: …and its blue (b/r ${s.coreBR} vs ${f3(br)})`);
// the whole cell is that material, not the bare slab the atlas holds for an ordinary tile
assert.ok(diff(cfg.infection, cfg.road) > 8, `${cfg.name}: the overlay really draws over the slab (${diff(cfg.infection, cfg.road)})`);
}
});
test('no per-tile frame — the crust reaches the edges, and the cell joins itself', () => {
for (const cfg of configs) {
const s = stats(cfg.infection);
// veins at the border like anywhere else: the old beveled brick had a frame and none at the very edge
assert.ok(s.ringVeinPct >= s.veinPct * 0.6, `${cfg.name}: veins reach the tile's border (${s.ringVeinPct} at the ring vs ${s.veinPct})`);
// a repeated cell must line up: the noise wraps, so opposite edges sample the same field — the veins run through
// the edge on both sides instead of stopping at it. Compared exactly on the cell WE compose (floor + crust); with
// the official slab under the crust the vein/not-vein classification of the composite pixels follows the slab's
// own shading (its edges are the official art's, on every tile of the board), so there only the ring rule above
// applies — an edge band is 3 of 240 columns, and one slab of the noise is not the cell's average.
if (!cfg.art) {
for (const [a, b, where] of [[s.veinsL, s.veinsR, 'left/right'], [s.veinsT, s.veinsB, 'top/bottom']]) {
assert.ok(Math.abs(a - b) <= 0.06, `${cfg.name}: ${where} edge veins ${a} vs ${b}`);
}
}
}
});
test('two variants, so a field of 活性源石 does not repeat one pattern', () => {
for (const cfg of configs) {
assert.ok(cfg.infection2, `${cfg.name}: infection2 is an atlas cell`);
const a = stats(cfg.infection), b = stats(cfg.infection2);
assert.ok(Math.abs(a.veinPct - b.veinPct) <= 0.02, `${cfg.name}: the same recipe (${a.veinPct} vs ${b.veinPct})`);
assert.ok(diff(cfg.infection, cfg.infection2) > 6, `${cfg.name}: the second variant is a different pattern (${diff(cfg.infection, cfg.infection2)})`);
}
});
});
+109 -1
View File
@@ -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';
@@ -592,3 +592,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 层/);
});
});
+41
View File
@@ -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);
});
+172
View File
@@ -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();
});
});