Files
ark-stronghold-protocol/test/render/playtest5-maps.test.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

145 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// User playtest #5 item 6 — "有两个吹风的风道确实做出来了,但是吹风机的贴图丢失了,反而在关底吹风机的贴图又出现了":
// act2autochess_m01 (战场#05(下半) 源石流发生装置) has 8 blowers (trap_013_blower, dir DOWN): the normal field's on the
// separator row 13 (cols 5 / 9, the partner's 13 / 17) blowing into rows 12–10, the boss field's on row 6 (cols 5 / 9 /
// 11 / 15) blowing into rows 5–3. The 3D board's normal / 联防 area stopped at row 12, so the normal field's blowers
// were never built (their airflow streaks — Pixi — still showed), while the boss field's were (the Final Assault view
// and — facing away, under the bench on the row-6 wall — the normal view).
// Now: the normal / 联防 areas take the separator row 13, and every active device standing on a built tile is drawn
// (render/board3d/layout.js stageDevices; the 2D board render/tiles.js _stageDevices follows the same rule) — except
// the boss field's (rows ≤ 6, its top wall under the bench included), drawn with the boss field only: the official
// normal cameras never show that wall (under the shop bar in prep, off-screen in battle), and the remake's prep view
// showed the boss round's machines under the bench ("反而在关底吹风机的贴图又出现了", QA residual of the integration).
// Audit: for every stage and view the 3D board and the 2D board draw the same devices, and every active device is
// drawn exactly in the views whose area holds its tile (the boss field's only in the boss views).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildBoard, stageDevices, classifyStage, AREAS, BOSS_WALL_ROW } from '../../public/js/render/board3d/layout.js';
import { boardArea, bandFor, fieldRows, viewKind } from '../../public/js/render/app.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const M01 = stages.act2autochess_m01;
const devicesIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.map((d) => `${d.kind}@${d.r},${d.c}`).sort();
const blowersIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
/**
* Inside the 3D area of a view (the 2D board also shows the partner's half of rows 9–12 as a display in the own views,
* with its devices; the 3D board builds cols 0–10 there — plus the pen block, whose front wall holds the partner's
* blower (13,13) in the pen view).
*/
const inArea = (vk) => (k) => {
const [r, c] = k.split('@').pop().split(',').map(Number);
return boardArea(vk).some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
};
/** The 2D board's device list for a view kind, within that view's 3D area (external mode: no Pixi meshes needed). */
async function devicesIn2d(st, vk) {
const { installFakePixi } = await import('./fakepixi.js');
const fake = installFakePixi();
try {
const P = globalThis.PIXI;
P.BLEND_MODES = { ADD: 1, NORMAL: 0 };
const { TileField } = await import('../../public/js/render/tiles.js');
const layers = { ground: new P.Container(), overlay: new P.Container(), props: new P.Container(), anim: new P.Container() };
for (const l of Object.values(layers)) l.addChild = function (...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } return kids[0]; };
const f = new TileField(layers);
f.setExternal(true);
f.setView(bandFor(vk), null, fieldRows(vk));
f.setStage(st);
const out = f._stageDevices().map((d) => `${d.role}@${d.pos[0]},${d.pos[1]}`).filter(inArea(vk)).sort();
const flow = f.blowers.map((b) => `${b.row},${b.col}`).filter(inArea(vk)).sort();
f.destroy();
return { devices: out, flow };
} finally {
fake.restore();
}
}
const VIEWS = ['prep', 'normal', 'unite', 'bossPrep', 'boss', 'pen'];
describe('#6 act2 m01 blowers in every view', () => {
test('repro: the normal / prep view builds the own field\'s row-13 blowers (cols 5, 9) above the wind lanes — and not the boss field\'s row-6 machines under the bench', () => {
for (const vk of ['prep', 'normal']) assert.deepEqual(blowersIn3d(M01, vk), ['13,5', '13,9'], vk);
});
test('联防 builds both halves\' row-13 blowers; the Final Assault builds the four row-6 blowers', () => {
assert.deepEqual(blowersIn3d(M01, 'unite'), ['13,13', '13,17', '13,5', '13,9']);
for (const vk of ['boss', 'bossPrep', viewKind('hidden'), viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })]) {
assert.deepEqual(blowersIn3d(M01, vk), ['6,11', '6,15', '6,5', '6,9'], vk);
}
});
test('the blower machines stand on the separator blocks; the row-13 ones blow onto the field below them', () => {
const b = buildBoard(M01, { area: boardArea('prep') });
for (const d of b.devices.filter((x) => x.kind === 'blower')) {
const t = b.grid[d.r][d.c];
assert.equal(t.glyph, 'X');
assert.ok(t.drawn && d.z0 === t.h && t.h > 0, 'on top of the drawn separator block');
assert.equal(d.dir, 'DOWN');
// row 13: onto the built field's lanes (rows 12–10)
assert.equal(d.r, 13, `${d.r},${d.c}: only the field's own machines`);
const onto = d.rangeTiles.slice(1).map(([r, c]) => !!b.grid[r][c].content);
assert.deepEqual(onto, [true, true, true], `${d.r},${d.c}`);
}
// the separator row is built along the field's top edge (the pen rows 14–18 are still not)
const rows = new Set(b.grid.flat().filter((t) => t.drawn).map((t) => t.r));
assert.ok(rows.has(13) && !rows.has(14) && !rows.has(5), [...rows].join());
});
test('the 2D board draws the same blowers and airflow per view', async () => {
for (const vk of VIEWS) {
const d2 = await devicesIn2d(M01, vk);
assert.deepEqual(d2.flow, blowersIn3d(M01, vk), `${vk}: airflow streaks = built blowers`);
}
});
});
describe('#6 audit: every device of every stage is drawn in the views of the field it belongs to', () => {
test('3D board = 2D board for every stage and view', async () => {
for (const [id, st] of Object.entries(stages)) {
for (const vk of VIEWS) {
const d3 = devicesIn3d(st, vk);
const d2 = (await devicesIn2d(st, vk)).devices;
assert.deepEqual(d3, d2, `${id} ${vk}`);
}
}
});
test('every active device standing on the built island of a view is drawn — every field device and every device acting on the field among them', () => {
const KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
const inRect = (r, c, R) => r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1;
// the fields inside their separator walls (normal / 联防: rows 7–12; boss: rows 0–5); the boss field's devices
// (rows ≤ 6: its top wall under the bench too) are drawn in the boss views only
const INNER = { prep: { r0: 7, r1: 12, c0: 0, c1: 10 }, unite: { r0: 7, r1: 12, c0: 0, c1: 20 }, boss: { r0: 0, r1: 5, c0: 0, c1: 20 } };
for (const [id, st] of Object.entries(stages)) {
for (const [vk, F] of Object.entries(INNER)) {
const b = buildBoard(st, { area: boardArea(vk) });
const built = new Set(b.devices.map((d) => `${d.kind}@${d.r},${d.c}`));
for (const d of st.devices || []) {
if (!KINDS.has(d.role) || !(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
const [r, c] = d.pos;
const key = `${d.role}@${r},${c}`;
const onIsland = boardArea(vk).some((a) => inRect(r, c, a)) && !!b.grid[r]?.[c]?.drawn && (vk === 'boss' || r > BOSS_WALL_ROW);
const acts = (d.rangeTiles || []).some(([rr, cc]) => (rr !== r || cc !== c) && inRect(rr, cc, F) && b.grid[rr]?.[cc]?.content);
assert.equal(built.has(key), onIsland, `${id} ${vk}: ${key} (${d.alias || d.key}) drawn iff on the built island`);
if (inRect(r, c, F) || acts) assert.ok(built.has(key), `${id} ${vk}: ${key} of this field drawn`);
}
}
}
});
test('stageDevices keeps every active device on a drawn tile — the boss field\'s only when the area builds the boss field', () => {
const blowers = (area) => stageDevices(M01, classifyStage(M01, area), area).filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
assert.deepEqual(blowers(AREAS.boss), ['6,11', '6,15', '6,5', '6,9']);
assert.deepEqual(blowers(AREAS.normal), ['13,13', '13,5', '13,9'], 'the pen block\'s front wall holds the partner\'s (13,13); the row-6 wall under the bench none');
assert.deepEqual(blowers([...AREAS.boss, ...AREAS.normal]), ['13,13', '13,5', '13,9', '6,11', '6,15', '6,5', '6,9'], 'a camera flight between the fields: both');
assert.equal(blowers(AREAS.all).length, 8, 'the whole map: all 8');
assert.equal(stageDevices(M01, classifyStage(M01, null)).filter((d) => d.kind === 'blower').length, 8, 'no area: the whole map');
});
});