diff --git a/docs/design/client.md b/docs/design/client.md index 9725cb9d..b0b94b44 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -87,7 +87,7 @@ Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; t Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`. -Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files. +Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files. Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer: - Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects. diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 9733914d..106c1995 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -391,7 +391,9 @@ lightmap pages with the baked shadow mask in alpha, albedo textures) to `public/ `.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the -fallback and still draws the devices, gates, field edges and terrain overlays. +fallback and still draws the devices, gates, field edges and terrain overlays. Chunks of the parts on screen that +share a material and a lightmap page are one mesh (`mergeSceneChunks`), so a battlefield such as act2 m02 (36 chunks, +5 lightmap pages) stays inside the board's draw-call budget. A switched-off HIDDEN part is left out of that mesh. Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip includes the extracted files. A lite package or a source install may download and extract them on that machine diff --git a/public/js/render/board3d/load.js b/public/js/render/board3d/load.js index 5303e9b2..e1063faa 100644 --- a/public/js/render/board3d/load.js +++ b/public/js/render/board3d/load.js @@ -195,6 +195,74 @@ export function parseSceneChunks(doc, buf) { return out; } +/** + * Mesh bucket of a scene chunk. One material and one lightmap page are one draw. Water ignores the page: that shader + * has no lightmap (`scene.js` `sceneWaterMaterial`). + * @param {{ material?: string, lightmap?: number }} chunk + * @param {{ kind?: string }|null} info scene.json materials[name] + */ +export function sceneMaterialKey(chunk, info) { + return `${chunk?.material}|${info?.kind === 'water' ? '' : chunk?.lightmap}`; +} + +/** + * One scene chunk from several that share a material bucket (`sceneMaterialKey`). Vertex attributes stay in the + * scene.bin layout (position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4) and indices are rewritten + * with the vertex offset; past 65535 vertices the index is 32-bit (WebGL2). A single chunk is returned as-is, so the + * pack's views of scene.bin stay shared. The caller passes chunks of one bucket that are on screen together: a + * file-level merge would bake a switched-off HIDDEN part into the base mesh. + * @param {object[]|null} chunks + * @returns {object|null} + */ +export function mergeSceneChunks(chunks) { + if (!Array.isArray(chunks) || chunks.length === 0) return null; + if (chunks.length === 1) return chunks[0]; + const first = chunks[0]; + let nv = 0, ni = 0, index32 = false; + for (const c of chunks) { + const n = c?.position?.length / 3; + if (!Number.isInteger(n) || n <= 0) throw new Error(`scene chunk ${c?.material}: ${c?.position?.length ?? 0} position components`); + if (c.material !== first.material) throw new Error(`scene merge mixes ${first.material} and ${c.material}`); + if (c.uv?.length !== n * 2 || c.uv1?.length !== n * 2 || c.normal?.length !== n * 4 || c.color?.length !== n * 4) { + throw new Error(`scene chunk ${c.material}: attributes do not match ${n} vertices`); + } + if (!c.index || c.index.length === 0) throw new Error(`scene chunk ${c.material}: no indices`); + nv += n; + ni += c.index.length; + if (c.index instanceof Uint32Array) index32 = true; + } + if (nv > 65535) index32 = true; + const position = new Float32Array(nv * 3); + const uv = new Float32Array(nv * 2); + const uv1 = new Float32Array(nv * 2); + const normal = new Int8Array(nv * 4); + const color = new Uint8Array(nv * 4); + const index = index32 ? new Uint32Array(ni) : new Uint16Array(ni); + let vo = 0, io = 0, bounds = null; + for (const c of chunks) { + const n = c.position.length / 3; + position.set(c.position, vo * 3); + uv.set(c.uv, vo * 2); + uv1.set(c.uv1, vo * 2); + normal.set(c.normal, vo * 4); + color.set(c.color, vo * 4); + for (let i = 0; i < c.index.length; i++) { + const v = c.index[i]; + if (v < 0 || v >= n) throw new Error(`scene chunk ${c.material}: index ${v} out of ${n}`); + index[io + i] = v + vo; + } + vo += n; + io += c.index.length; + if (Array.isArray(c.bounds) && c.bounds.length >= 6) { + const b = c.bounds; + bounds = bounds + ? [Math.min(bounds[0], b[0]), Math.min(bounds[1], b[1]), Math.min(bounds[2], b[2]), Math.max(bounds[3], b[3]), Math.max(bounds[4], b[4]), Math.max(bounds[5], b[5])] + : b.slice(); + } + } + return { material: first.material, lightmap: first.lightmap, group: '', bounds, position, uv, uv1, normal, color, index }; +} + /** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */ export function sceneGroupRects(chunks) { const out = {}; diff --git a/public/js/render/board3d/scene.js b/public/js/render/board3d/scene.js index 0e08ce03..502b7806 100644 --- a/public/js/render/board3d/scene.js +++ b/public/js/render/board3d/scene.js @@ -19,6 +19,8 @@ // static crates (merged, same material), // decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per // material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total. +// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap +// page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay. // The key light's shadow map is rendered only when the geometry changes (autoUpdate off). import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js'; @@ -28,6 +30,7 @@ import { waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial, } from './materials.js'; import { syncThreeCamera } from '../projection.js'; +import { mergeSceneChunks, sceneMaterialKey } from './load.js'; /** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */ export const LIGHTING = Object.freeze({ @@ -343,18 +346,29 @@ export class BoardScene { this.sceneRes.delete(pack.key); } - /** The official scene's meshes: one per chunk of a shown part (the base + the fields of the built areas). */ + /** + * The official scene's meshes: the base plus the fields of the built areas, one mesh per material and lightmap + * page (`mergeSceneChunks`). A switched-off HIDDEN part is left out before the merge. + */ _buildScene(pack) { const T = this.THREE; const res = this._sceneResources(pack); const infos = pack.doc?.materials || {}; this.sceneWater = []; - let n = 0; + const buckets = new Map(); for (const c of pack.chunks || []) { if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue; const info = infos[c.material] || null; + const mk = sceneMaterialKey(c, info); + let bucket = buckets.get(mk); + if (!bucket) { bucket = { info, chunks: [] }; buckets.set(mk, bucket); } + bucket.chunks.push(c); + } + let n = 0; + for (const [mk, bucket] of buckets) { + const c = mergeSceneChunks(bucket.chunks); + const info = bucket.info; const water = info?.kind === 'water'; - const mk = `${c.material}|${water ? '' : c.lightmap}`; let mat = res.mats.get(mk); if (!mat) { mat = water diff --git a/test/render/board3d-scene.test.js b/test/render/board3d-scene.test.js index ee87eed4..e07b0761 100644 --- a/test/render/board3d-scene.test.js +++ b/test/render/board3d-scene.test.js @@ -18,7 +18,8 @@ import { resolveUvTable } from '../../public/js/render/board3d/atlas.js'; import { presetCamera } from '../../public/js/render/projection.js'; import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js'; import { - parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES, + parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, sceneMaterialKey, mergeSceneChunks, + SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES, } from '../../public/js/render/board3d/load.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -211,6 +212,45 @@ describe('scene chunks (load.js)', () => { assert.equal(parseSceneChunks(doc, null), null); }); + test('sceneMaterialKey / mergeSceneChunks: one mesh per material and lightmap page; indices keep each quad', () => { + assert.equal(sceneMaterialKey({ material: 'M', lightmap: 3 }, { kind: 'lit' }), 'M|3'); + assert.equal(sceneMaterialKey({ material: 'M', lightmap: 0 }, null), 'M|0'); + assert.equal(sceneMaterialKey({ material: 'W', lightmap: -1 }, { kind: 'water' }), 'W|'); + assert.equal(sceneMaterialKey({ material: 'W', lightmap: 2 }, { kind: 'water' }), 'W|', 'water has no lightmap page'); + const { doc, bin } = fakeScene([{ material: 'M', x0: 0, y0: 0 }, { material: 'M', x0: 3, y0: 1, group: 'HIDDEN@1' }]); + const chunks = parseSceneChunks(doc, bin); + assert.equal(mergeSceneChunks([chunks[0]]), chunks[0], 'a single chunk stays the scene.bin view'); + assert.equal(mergeSceneChunks([]), null); + assert.equal(mergeSceneChunks(null), null); + const merged = mergeSceneChunks(chunks); + assert.equal(merged.position.length / 3, 8); + assert.ok(merged.index instanceof Uint16Array); + assert.deepEqual([...merged.index], [0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7]); + assert.equal(merged.position[12], 3, 'the second quad keeps its board position'); + assert.equal(merged.normal[18], 127); + assert.deepEqual([...merged.color.slice(16, 20)], [255, 255, 255, 255]); + assert.deepEqual([...merged.uv1.slice(8, 12)], [0, 0, 0.5, 0]); + assert.deepEqual([...chunks[1].index], [0, 1, 2, 0, 2, 3], 'the source chunk is not rewritten'); + assert.deepEqual(merged.bounds, [0, 0, 0, 4, 2, 0]); + const big = new Float32Array(65535 * 3); + const one = { + material: 'M', lightmap: 0, group: '', bounds: null, + position: big, uv: new Float32Array(65535 * 2), uv1: new Float32Array(65535 * 2), + normal: new Int8Array(65535 * 4), color: new Uint8Array(65535 * 4), index: new Uint16Array([0, 0, 0]), + }; + const extra = { + material: 'M', lightmap: 0, group: 'HIDDEN@1', bounds: null, + position: new Float32Array([9, 8, 7]), uv: new Float32Array(2), uv1: new Float32Array(2), + normal: new Int8Array(4), color: new Uint8Array(4), index: new Uint16Array([0, 0, 0]), + }; + const wide = mergeSceneChunks([one, extra]); + assert.ok(wide.index instanceof Uint32Array, 'past 65535 vertices the index is 32-bit'); + assert.equal(wide.position.length / 3, 65536); + assert.equal(wide.index[3], 65535); + assert.equal(wide.position[65535 * 3], 9); + assert.throws(() => mergeSceneChunks([{ ...chunks[0], material: 'Other' }, chunks[1]]), /mixes/); + }); + test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => { const { doc, bin } = fakeScene(PARTS); const rects = sceneGroupRects(parseSceneChunks(doc, bin)); @@ -305,9 +345,17 @@ describe('BoardScene with an official scene (stub renderer)', () => { assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow); assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join()); const meshes = sceneMeshes(board); - assert.equal(meshes.length, 4, 'base ×2 + the left normal field ×2 (AREAS.normal)'); + assert.equal(meshes.length, 3, 'base + the left field: the two M quads share one mesh'); + assert.deepEqual(meshes.map((m) => m.geometry.getAttribute('position').count).sort((a, b) => a - b), [4, 4, 8]); + const xs = meshes.flatMap((m) => { + const a = m.geometry.getAttribute('position').array; + const out = []; + for (let i = 0; i < a.length; i += 3) out.push(a[i]); + return out; + }); + assert.ok(xs.includes(9) && xs.includes(4), 'base and the left field'); + assert.ok(!xs.includes(16) && !xs.includes(11), 'the right field and the boss field stay out'); for (const m of meshes) { - assert.equal(m.geometry.getAttribute('position').count, 4); assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized); assert.equal(m.castShadow, false); } @@ -328,9 +376,9 @@ describe('BoardScene with an official scene (stub renderer)', () => { assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace); assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace); board.setArea(AREAS.boss); - assert.equal(sceneMeshes(board).length, 3, 'base ×2 + the boss field'); + assert.equal(sceneMeshes(board).length, 2, 'base + the boss field, the two M quads merged'); board.setArea(AREAS.unite); - assert.equal(sceneMeshes(board).length, 5, 'base ×2 + both normal fields (2 + 1)'); + assert.equal(sceneMeshes(board).length, 3, 'base + both normal fields: M merged, water, cutout'); assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object'); assert.equal(board.sceneRes.size, 1); board.destroy(); @@ -353,6 +401,27 @@ describe('BoardScene with an official scene (stub renderer)', () => { board.destroy(); }); + test('a different lightmap page stays its own mesh; water pages share one', () => { + const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() }); + const parts = [ + { material: 'M', x0: 4, y0: 9, lightmap: 0, group: 'HIDDEN@1' }, + { material: 'M', x0: 9, y0: 15, lightmap: 1 }, + { material: 'W', x0: 5, y0: 9, lightmap: 0, group: 'HIDDEN@1' }, + { material: 'W', x0: 6, y0: 9, lightmap: 1, group: 'HIDDEN@1' }, + ]; + const { doc, bin } = fakeScene(parts); + const chunks = parseSceneChunks(doc, bin); + board.setScenePack(stage.id, { key: 'split', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } }); + board.setStage(stage); + const meshes = sceneMeshes(board); + assert.equal(meshes.length, 3, 'M|0, M|1, and one water mesh'); + const water = meshes.filter((m) => m.material.uniforms.uDeep); + assert.equal(water.length, 1); + assert.equal(water[0].geometry.getAttribute('position').count, 8, 'both water quads'); + assert.equal(board.sceneWater.length, 1); + board.destroy(); + }); + test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => { const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() }); const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]); @@ -391,4 +460,35 @@ describe('extracted scenes (when scene bundles were extracted here)', { skip: !O assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board'); }); } + + test('each built area is one mesh per material and lightmap, with every shown vertex and no switched-off part', () => { + const views = [['normal', AREAS.normal], ['boss', AREAS.boss], ['unite', AREAS.unite]]; + for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) { + if (!existsSync(file(groups[g].scene?.path || '/x'))) continue; + const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8')); + const bin = readFileSync(file(groups[g].bin.path)); + const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length)); + const rects = sceneGroupRects(chunks); + const stage = stages[doc.stage]; + const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() }); + board.setScenePack(doc.stage, { key: g, stage: doc.stage, doc, chunks, groups: rects, images: { tex: {}, lm: [] } }); + board.setStage(stage); + for (const [name, area] of views) { + board.setArea(area); + const shown = chunks.filter((c) => !c.group || sceneGroupVisible(rects[c.group], area)); + const keys = new Set(shown.map((c) => sceneMaterialKey(c, doc.materials[c.material]))); + const meshes = sceneMeshes(board); + assert.equal(meshes.length, keys.size, `${doc.stage} ${name}: ${shown.length} chunks → ${keys.size} meshes`); + assert.ok(meshes.length > 0 && meshes.length <= 16, `${doc.stage} ${name}: ${meshes.length} scene draw calls`); + let verts = 0; + for (const m of meshes) verts += m.geometry.getAttribute('position').count; + assert.equal(verts, shown.reduce((a, c) => a + c.position.length / 3, 0), `${doc.stage} ${name} vertices`); + if (name !== 'unite') assert.ok(board.root.children.length <= 19, `${doc.stage} ${name}: ${board.root.children.length} meshes beside one battle device still fit 20 draws`); + } + assert.equal(board.meshes.board, undefined, `${doc.stage}: the scene replaces the metal tiles`); + board.setScenePack(doc.stage, null); + assert.ok(board.meshes.board && board.meshes.bg, `${doc.stage}: dropping the scene restores the metal board`); + board.destroy(); + } + }); }); diff --git a/test/ui/playtest2.real.e2e.test.js b/test/ui/playtest2.real.e2e.test.js index 6cb6a3fd..e0bf9011 100644 --- a/test/ui/playtest2.real.e2e.test.js +++ b/test/ui/playtest2.real.e2e.test.js @@ -1,6 +1,7 @@ // Real-server browser E2E of user playtest #2 item 10 (+ item 6 on the real flow): the boss round's prep shows the // player's own half of the boss field (tiles, gates, objective) and placing works — solo (标准: R9 最终攻势; 险境: -// R15 隐秘核心) and co-op (two humans: the host prepares on the LEFT half, the guest on the mirrored RIGHT half) — then +// R15 隐秘核心) and co-op (two humans: each prepares on the half m.public.bossPairs assigns, the second of the pair +// on the mirrored RIGHT half) — then // the Final Assault starts with the placed units on the boss field. Along the way (item 6): the pen stays hidden with // the board (🔍▶▶ shows it, 🔍◀◀ "返回战场" hides it again), scouting a teammate (前往查看) and coming back, and in // the boss battle. @@ -21,6 +22,7 @@ import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf, waitForFunctionLong } from '../e2e/client.mjs'; +import { prepCamera } from '../../public/js/ui/gameLogic/camera.js'; const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets')); const ONLY = process.env.SP_P2_REAL || ''; @@ -101,6 +103,29 @@ async function placeOne(c, dir = 'RIGHT') { throw new Error(`${c.label}: nothing could be placed`); } +/** + * The half this client prepares on, from the published pairing (`m.public.bossPairs` through `prepCamera`). + * The first of a pair is on the left half; the second is on the mirrored right half. Seat order is not a side. + */ +async function assignedBossSide(c) { + const snap = await c.page.evaluate(() => { + const s = globalThis.__SP__.store.get(); + const pub = s.match.public || {}; + return { + me: s.me?.playerId ?? null, + phase: pub.phase ?? null, round: pub.round ?? null, bossRound: pub.bossRound ?? null, hiddenRound: pub.hiddenRound ?? null, + bossPairs: pub.bossPairs ?? null, + players: (pub.players || []).map((p) => ({ playerId: p.playerId, seat: p.seat, alive: p.alive !== false })), + }; + }); + const cam = prepCamera(snap, snap.me); + const pair = (Array.isArray(snap.bossPairs) ? snap.bossPairs : []).find((g) => Array.isArray(g) && g.includes(snap.me)); + assert.equal(cam.kind, 'bossPrep', `${c.label}: boss-round prep (pairs ${JSON.stringify(snap.bossPairs)})`); + assert.ok(pair, `${c.label}: a published pair`); + assert.equal(cam.opts.side, pair.indexOf(snap.me) % 2 === 1 ? 'R' : 'L', `${c.label}: the side is the pair index`); + return { side: cam.opts.side, me: snap.me, pair, bossPairs: snap.bossPairs }; +} + /** The boss-round prep checks shared by every variant. */ async function checkBossPrep(c, side, tag) { const v = await viewState(c); @@ -344,7 +369,7 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s } }); - test('co-op 险境 (R14): host on the left half, guest on the mirrored right half; scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => { + test('co-op 险境 (R14): each player on the half bossPairs assigns (the right half mirrored); scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => { const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 8, startRound: 'boss', items: ['chess_item_6_03_m'] } }); const P = await pptr(); const host = new Client(P, srv.base, 'host', { prefix: 'fix' }); @@ -386,8 +411,12 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s await sleep(1800); const hs = await host.st(); assert.equal(hs.round, hs.bossRound, 'the boss round'); - await checkBossPrep(host, 'L', 'host'); - await checkBossPrep(guest, 'R', 'guest'); + const hostSeat = await assignedBossSide(host); + const guestSeat = await assignedBossSide(guest); + assert.ok(hostSeat.pair.includes(guestSeat.me), `the two players share a pair (${JSON.stringify(hostSeat.bossPairs)})`); + assert.notEqual(hostSeat.side, guestSeat.side, 'a pair occupies both halves'); + await checkBossPrep(host, hostSeat.side, 'host'); + await checkBossPrep(guest, guestSeat.side, 'guest'); const hp = await placeOne(host, 'RIGHT'); const gp = await placeOne(guest, 'RIGHT'); await sleep(600); @@ -404,12 +433,14 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s const w = await viewState(host); assert.equal(w.penVisible, 0, 'scouting: no pen figure with the teammate\'s board'); assert.ok(w.band[1] <= 13, `scouting: no pen rows (${w.band})`); + assert.equal(w.camKind, 'bossPrep', 'scouting: the boss field'); + assert.deepEqual(w.prepField, { kind: 'bossPrep', side: guestSeat.side, mirror: guestSeat.side === 'R' }, 'scouting: the teammate\'s half'); await host.click('.gm__watching button, .team__back', null, { timeout: 4000 }); await host.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 6000 }); await sleep(900); const back = await viewState(host); assert.equal(back.penVisible, 0, '返回: no pen figure'); - assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again'); + assert.deepEqual(back.prepField, { kind: 'bossPrep', side: hostSeat.side, mirror: hostSeat.side === 'R' }, '返回: the own boss half again'); } await penRoundTrip(guest, 'guest'); // Real clients open their own 教鞭 candidates together; refreshing one tab restores that same private choice. @@ -454,8 +485,8 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private.personalChoice === null, { timeout: 6000 }); } for (const c of [host, guest]) await c.click('.readybtn'); - await checkBossBattle(host, hp, 'L', 'host'); - await checkBossBattle(guest, gp, 'R', 'guest'); + await checkBossBattle(host, hp, hostSeat.side, 'host'); + await checkBossBattle(guest, gp, guestSeat.side, 'guest'); assert.deepEqual(problemsOf([host, guest]), []); } finally { for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));