feedback5: merge W5M — the nine 0.1.4 PRs (#150 #66 #146 #74 #72 #103 #149 #109 #160) and W4A (高台 per the trait, 阿戈尔 revive slots, 联防 devour) ported into the split layout
Conflicts: server/match/bot.js (W4A's comment + W5K's player data view pgd), test/golden/diy.json (regenerated). data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 the moved roster / bonds / fields / matches / standins scenarios are exactly W5M's per-PR attribution (#160 skill events, #109 deploy-timed lifecycle, #66 灵巧 sources and hidden layers, 83fc408 歌蕾蒂娅 placement), plus coop2-NORMAL-70-diy (fields operators those PRs touch) and diy-051 / diy-052 (events.skill only: Misery's deploy passives, #160); against fb5-merge4 only feedback5's own additions (the diy kits, coop2-NORMAL-70-diy).
This commit is contained in:
commit
9ce5435373
74 files changed
+2966
-1338
No files matched your search
+13
-14
@@ -55,27 +55,26 @@ test('glyph rules: melee only on LOW buildable ALL/MELEE; ranged also on HIGH/RA
|
||||
assert.ok(canPlace(buildDeployMap(null), 'melee', 10, 5));
|
||||
});
|
||||
|
||||
test('positionClass: melee stays ground-only; only elite 歌蕾蒂娅 + HOK-Y is widened (owner 2026-10-04)', () => {
|
||||
test('positionClass: a MELEE chess whose trait reads 「可以放置于远程位」 is widened, normal or elite, whatever the module (owner 2026-10-05)', () => {
|
||||
assert.equal(positionClass({ position: 'MELEE' }), 'melee');
|
||||
assert.equal(positionClass({ position: 'MELEE', placement: 'all' }), 'melee', 'the old placement field is ignored');
|
||||
assert.equal(basePositionClass({ position: 'MELEE', isGolden: true, charId: 'char_474_glady' }), 'melee');
|
||||
assert.equal(positionClass({ position: 'MELEE', trait: { desc: '技能可以使敌人产生位移\n可以放置于远程位' } }), 'all', 'the trait line');
|
||||
assert.equal(positionClass({ position: 'MELEE', trait: { desc: '可以额外部署在远程位' } }), 'melee', 'another wording is not the line');
|
||||
assert.equal(positionClass({ position: 'RANGED', trait: { desc: '可以放置于远程位' } }), 'ranged', 'a ranged record keeps its class');
|
||||
assert.equal(basePositionClass({ position: 'MELEE', trait: { desc: '可以放置于远程位' } }), 'melee', 'the battle class stays melee');
|
||||
assert.equal(positionClass({ position: 'RANGED' }), 'ranged');
|
||||
assert.equal(positionClass({ position: 'ALL' }), 'all');
|
||||
assert.equal(positionClass(null), 'all');
|
||||
const HOK_Y = 'uniequip_003_glady';
|
||||
const gladE = DATA.chess.chess_char_4_12_b;
|
||||
const map = buildDeployMap(DATA.stages.act2autochess_m01);
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_4_12_a, HOK_Y), 10, 4), 'normal 歌蕾蒂娅, even with the id, not');
|
||||
assert.ok(!canPlace(map, positionClass(gladE), 10, 4), 'elite with no module id (default is not passed) not');
|
||||
assert.ok(!canPlace(map, positionClass(gladE, 'uniequip_002_glady'), 10, 4), 'HOK-X not');
|
||||
assert.ok(!canPlace(map, positionClass(gladE, 'none'), 10, 4), 'no module not');
|
||||
assert.ok(canPlace(map, positionClass(gladE, HOK_Y), 10, 4), 'elite + HOK-Y on the 高台');
|
||||
assert.ok(canPlace(map, positionClass(gladE, HOK_Y), 9, 3), 'and still on the ground');
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_2_03_b, HOK_Y), 10, 4), '崖心 not');
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_3_07_b, HOK_Y), 10, 4), '见行者 not');
|
||||
// the six trait holders: 歌蕾蒂娅 / 崖心 (钩索师) and 见行者 (推击手), normal and elite
|
||||
for (const id of ['chess_char_4_12_a', 'chess_char_4_12_b', 'chess_char_2_03_a', 'chess_char_2_03_b', 'chess_char_3_07_a', 'chess_char_3_07_b']) {
|
||||
assert.equal(positionClass(DATA.chess[id]), 'all', id);
|
||||
assert.ok(canPlace(map, positionClass(DATA.chess[id]), 10, 4), `${id} on the 高台`);
|
||||
assert.ok(canPlace(map, positionClass(DATA.chess[id]), 9, 3), `${id} still on the ground`);
|
||||
assert.equal(basePositionClass(DATA.chess[id]), 'melee', `${id} blocks like a melee unit`);
|
||||
assert.equal(DATA.chess[id].placement, undefined);
|
||||
}
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_1_02_a), 10, 4), '角峰 (重装) not');
|
||||
assert.equal(DATA.chess.chess_char_4_12_a.placement, undefined);
|
||||
assert.equal(gladE.placement, undefined);
|
||||
});
|
||||
|
||||
test('g.move: legality per chess position, BAD_TILE outside, deploy cap 8, swaps allowed at cap', () => {
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
// nowhere to go); moving the owner sends it back anyway (PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置
|
||||
// 至手牌区") — with no room it leaves the board until the next round start.
|
||||
// The sim side (突袭 landing tile, tactical point) is test/sim/feedback1-water.test.js.
|
||||
// Owner's decision 2026-10-04 reverses DESIGN §22.6: the trait 「可以放置于远程位」 is not trusted. Only elite
|
||||
// 歌蕾蒂娅 carrying HOK-Y 淡金坠饰 (uniequip_003_glady) may stand on a 高台. The battle keeps position MELEE
|
||||
// (on a 高台 she attacks but blocks nothing). A bot may plan that one loadout on a 高台; everyone else stays on the ground.
|
||||
// 高台: the owner's decision of 2026-10-05 (reversing the one of 2026-10-04, elite 歌蕾蒂娅 with HOK-Y only) follows
|
||||
// PRTS — every MELEE chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者,
|
||||
// normal and elite) may stand on a 高台, with any module or none; raised with the PRTS source in PR #69 by
|
||||
// @sunstricken. The battle keeps position MELEE (on a 高台 they attack but block nothing). The bots plan them on a 高台
|
||||
// that covers the enemy road; every other melee unit stays on the ground.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDeployMap, canPlace as boardCanPlace, legalTiles, positionClass, placeClass, tileKey, ownerRangeKeys } from '../../server/match/board.js';
|
||||
import { GLADIIA_HOK_Y } from '../../shared/highGround.js';
|
||||
import { PLACE_ON_RANGED, meleeOnHighGround } from '../../shared/highGround.js';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
|
||||
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
|
||||
@@ -305,75 +307,67 @@ test('#9 bots place 狼群 / 流形 only inside their owner\'s range', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 高台: only elite 歌蕾蒂娅 + HOK-Y (owner's decision 2026-10-04)
|
||||
// 高台: every melee chess whose trait reads 「可以放置于远程位」, any module (the owner's decision of 2026-10-05)
|
||||
|
||||
const HIGH_STAGE = 'act2autochess_m01';
|
||||
const HIGH = ['10,4', '11,4', '12,4']; // 战场#01(下半)'s ranged-only (高台) tiles
|
||||
const GLAD_N = 'chess_char_4_12_a';
|
||||
const GLAD_E = 'chess_char_4_12_b'; // the golden record
|
||||
const CLIFF_N = 'chess_char_2_03_a';
|
||||
const CLIFF_E = 'chess_char_2_03_b';
|
||||
const FORCER_N = 'chess_char_3_07_a';
|
||||
const FORCER_E = 'chess_char_3_07_b';
|
||||
const HOLDERS = [GLAD_N, GLAD_E, CLIFF_N, CLIFF_E, FORCER_N, FORCER_E];
|
||||
const HOK_X = 'uniequip_002_glady';
|
||||
const HOK_Y = 'uniequip_003_glady';
|
||||
const TANK = (c) => c.profession === 'TANK' && c.position === 'MELEE';
|
||||
const rc = (k) => k.split(',').map(Number);
|
||||
const toHand = (m, ps, p) => move(m, p.uid, { area: 'hand', idx: ps.hand.findIndex((x) => x == null) });
|
||||
const equip = (ps, module) => {
|
||||
assert.equal(ps.setLoadout({ [GLAD_N]: { module } }), true, module);
|
||||
};
|
||||
/**
|
||||
* The loadouts each holder is tried with: its default (null), no module, and every module it has. 崖心 and 见行者 are
|
||||
* hidden this season (never in the pool): a player cannot set their loadout (shared/protocol.js checkLoadout), so they
|
||||
* are tried with their defaults (elite: HOK-X / PUS-X).
|
||||
*/
|
||||
const loadoutsOf = (id) => (DATA.chess[DATA.chess[id].baseId].isHidden ? [null]
|
||||
: [null, 'none', ...(DATA.chess[DATA.chess[id].baseId].goldenId ? DATA.chess[DATA.chess[DATA.chess[id].baseId].goldenId].modules : []).map((x) => x.uniEquipId)]);
|
||||
|
||||
test('高台 data: no chess stores placement; HOK-Y is 淡金坠饰 on the elite 歌蕾蒂娅 only', () => {
|
||||
test('高台 data: no chess stores placement; 「可以放置于远程位」 is the trait of exactly the six 钩索师 / 推击手 records, and every module keeps it', () => {
|
||||
assert.equal(Object.values(DATA.chess).some((c) => c.placement !== undefined), false);
|
||||
for (const t of Object.values(DATA.tokens)) assert.equal(t.placement, undefined, t.tokenId);
|
||||
const e = DATA.chess[GLAD_E];
|
||||
const y = e.modules.find((m) => m.uniEquipId === GLADIIA_HOK_Y);
|
||||
assert.equal(e.charId, 'char_474_glady');
|
||||
assert.equal(e.isGolden, true);
|
||||
assert.equal(y.typeName, 'HOK-Y');
|
||||
assert.equal(y.name, '淡金坠饰');
|
||||
assert.equal(e.modules.find((m) => m.isDefault).uniEquipId, HOK_X, 'the default module is HOK-X, ground-only');
|
||||
assert.equal(DATA.chess[GLAD_N].isGolden, false);
|
||||
assert.ok(!DATA.chess[CLIFF_E].modules.some((m) => m.uniEquipId === GLADIIA_HOK_Y), '崖心 has no HOK-Y');
|
||||
assert.equal(DATA.chess[GLAD_N].position, 'MELEE');
|
||||
assert.equal(e.position, 'MELEE');
|
||||
const widened = Object.values(DATA.chess).filter((c) => meleeOnHighGround(c)).map((c) => c.chessId).sort();
|
||||
assert.deepEqual(widened, HOLDERS.slice().sort());
|
||||
for (const id of HOLDERS) {
|
||||
const c = DATA.chess[id];
|
||||
assert.equal(c.position, 'MELEE', id);
|
||||
assert.ok(['钩索师', '推击手'].includes(c.subProfessionName), id);
|
||||
for (const x of c.modules || []) assert.ok(x.traitOverride.desc.includes(PLACE_ON_RANGED), `${id} ${x.typeName} keeps the line`);
|
||||
}
|
||||
// the module text never adds the line to anybody else (教官 Y "可以额外部署在远程位" is a talent, not the trait)
|
||||
for (const c of Object.values(DATA.chess)) {
|
||||
if (HOLDERS.includes(c.chessId)) continue;
|
||||
for (const x of c.modules || []) assert.ok(!(x.traitOverride?.desc || '').includes(PLACE_ON_RANGED), `${c.chessId} ${x.typeName}`);
|
||||
}
|
||||
assert.equal(DATA.chess[GLAD_E].modules.find((x) => x.uniEquipId === HOK_Y).typeName, 'HOK-Y');
|
||||
});
|
||||
|
||||
test('高台 table: (operator, module, tile) → allowed only for elite 歌蕾蒂娅 + HOK-Y on a 高台; ranged unchanged', () => {
|
||||
test('高台 table: every trait holder, normal and elite, with its default, no module or any module, may take each 高台; a 重装 is refused; ranged unchanged', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE);
|
||||
assert.deepEqual([...ps.deployMap()].filter(([, cls]) => cls === 'ranged').map(([k]) => k).sort(), HIGH);
|
||||
// rows: chess id, module set on the 歌蕾蒂娅 loadout (null = leave the default), expect 高台
|
||||
const rows = [
|
||||
[GLAD_E, GLADIIA_HOK_Y, true],
|
||||
[GLAD_E, HOK_X, false],
|
||||
[GLAD_E, 'none', false],
|
||||
[GLAD_N, GLADIIA_HOK_Y, false],
|
||||
[GLAD_N, null, false],
|
||||
[CLIFF_E, GLADIIA_HOK_Y, false],
|
||||
[FORCER_N, null, false],
|
||||
[FORCER_E, null, false],
|
||||
];
|
||||
const owned = new Map();
|
||||
for (const [id, module, allowHigh] of rows) {
|
||||
if (module) equip(ps, module);
|
||||
else ps.setLoadout({});
|
||||
let p = owned.get(id);
|
||||
if (!p) { p = give(m, ps, id); owned.set(id, p); }
|
||||
else if (ps.find(p.uid)?.area === 'board') assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
for (const k of HIGH) {
|
||||
const [r, c] = rc(k);
|
||||
const res = move(m, p.uid, board(r, c), 'DOWN');
|
||||
if (allowHigh) {
|
||||
assert.deepEqual(res, { ok: true }, `${id} ${module} → ${k}`);
|
||||
for (const id of HOLDERS) {
|
||||
const p = give(m, ps, id);
|
||||
for (const module of loadoutsOf(id)) {
|
||||
assert.equal(ps.setLoadout(module ? { [DATA.chess[id].baseId]: { module } } : {}), true, `${id} ${module}`);
|
||||
for (const k of HIGH) {
|
||||
const [r, c] = rc(k);
|
||||
assert.deepEqual(move(m, p.uid, board(r, c), 'DOWN'), { ok: true }, `${id} ${module || 'default'} → ${k}`);
|
||||
assert.equal(ps.board.get(k), p);
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
} else {
|
||||
assert.equal(res.error, ERR.BAD_TILE, `${id} ${module || 'default'} → ${k}`);
|
||||
}
|
||||
assert.deepEqual(move(m, p.uid, board(9, 3)), { ok: true }, `${id}: the ground stays legal`);
|
||||
assert.equal(placeClass(ps, DATA.chess[id]), 'all', id);
|
||||
assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
}
|
||||
assert.deepEqual(move(m, p.uid, board(9, 3)), { ok: true }, `${id}: the ground stays legal`);
|
||||
assert.equal(placeClass(ps, DATA.chess[id]) === 'all', allowHigh, id);
|
||||
assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
ps.setLoadout({});
|
||||
}
|
||||
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
|
||||
@@ -390,43 +384,41 @@ test('高台 table: (operator, module, tile) → allowed only for elite 歌蕾
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('高台 client: drag highlights follow the loadout; a 重装 keeps "近战单位只能部署在地面"', () => {
|
||||
test('高台 client: drag highlights = the server for every trait holder under any module; a 重装 keeps "近战单位只能部署在地面"', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
const normal = give(m, ps, GLAD_N);
|
||||
const cliff = give(m, ps, CLIFF_E);
|
||||
const holders = HOLDERS.map((id) => give(m, ps, id));
|
||||
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
|
||||
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
|
||||
const ctx = clientCtx(m, ps);
|
||||
const map = ps.deployMap();
|
||||
for (const p of [glad, normal, cliff, tank, ranged]) {
|
||||
const lit = boardTargets(ctx, p.uid).legal.map(([r, c]) => tileKey(r, c)).sort();
|
||||
assert.deepEqual(lit, legalTiles(map, placeClass(ps, DATA.chess[p.id])).map(([r, c]) => tileKey(r, c)).sort(), `${p.id}: highlights = the server`);
|
||||
}
|
||||
for (const k of HIGH) {
|
||||
const [r, c] = rc(k);
|
||||
assert.equal(clientCanPlace(ctx, glad.uid, board(r, c)).ok, true, k);
|
||||
for (const p of [normal, cliff, tank]) {
|
||||
const res = clientCanPlace(ctx, p.uid, board(r, c));
|
||||
assert.equal(res.ok, false, p.id);
|
||||
for (const loadout of [{}, { [GLAD_N]: { module: HOK_Y } }, { [GLAD_N]: { module: HOK_X } }, { [GLAD_N]: { module: 'none' } }]) {
|
||||
assert.equal(ps.setLoadout(loadout), true);
|
||||
const ctx = clientCtx(m, ps);
|
||||
for (const p of [...holders, tank, ranged]) {
|
||||
const lit = boardTargets(ctx, p.uid).legal.map(([r, c]) => tileKey(r, c)).sort();
|
||||
assert.deepEqual(lit, legalTiles(map, placeClass(ps, DATA.chess[p.id])).map(([r, c]) => tileKey(r, c)).sort(), `${p.id}: highlights = the server`);
|
||||
}
|
||||
for (const k of HIGH) {
|
||||
const [r, c] = rc(k);
|
||||
for (const p of holders) assert.equal(clientCanPlace(ctx, p.uid, board(r, c)).ok, true, `${p.id} → ${k}`);
|
||||
const res = clientCanPlace(ctx, tank.uid, board(r, c));
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.reason, '近战单位只能部署在地面');
|
||||
assert.equal(clientCanPlace(ctx, ranged.uid, board(r, c)).ok, true);
|
||||
}
|
||||
assert.equal(clientCanPlace(ctx, ranged.uid, board(r, c)).ok, true);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('高台 swaps: elite 歌蕾蒂娅 + HOK-Y trades with a ranged operator, not with a 重装', () => {
|
||||
test('高台 swaps: 歌蕾蒂娅 (normal, no module) trades with a ranged operator, not with a 重装', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
const glad = give(m, ps, GLAD_N);
|
||||
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
|
||||
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
|
||||
assert.deepEqual(move(m, glad.uid, board(10, 4), 'DOWN'), { ok: true });
|
||||
assert.deepEqual(move(m, tank.uid, board(9, 3)), { ok: true });
|
||||
assert.deepEqual(move(m, ranged.uid, board(11, 4)), { ok: true });
|
||||
let ctx = clientCtx(m, ps);
|
||||
// swapping 歌蕾蒂娅 (高台) with the 重装 (ground) would put the 重装 on the 高台
|
||||
assert.equal(clientCanPlace(ctx, glad.uid, board(9, 3)).ok, false);
|
||||
assert.equal(move(m, glad.uid, board(9, 3)).error, ERR.BAD_TILE);
|
||||
assert.equal(clientCanPlace(ctx, tank.uid, board(10, 4)).ok, false);
|
||||
@@ -444,16 +436,14 @@ test('高台 swaps: elite 歌蕾蒂娅 + HOK-Y trades with a ranged operator, no
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('高台 battle input: elite 歌蕾蒂娅 + HOK-Y is fielded on (10,4) and blocks nothing there; on the ground she blocks', () => {
|
||||
test('高台 battle input: 歌蕾蒂娅 is fielded on (10,4) and blocks nothing there; on the ground she blocks', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
const glad = give(m, ps, GLAD_N);
|
||||
assert.deepEqual(move(m, glad.uid, board(10, 4), 'DOWN'), { ok: true });
|
||||
const u0 = ps.battleInput().units.find((u) => u.uid === glad.uid);
|
||||
assert.deepEqual([u0.row, u0.col, u0.dir, u0.moduleId], [10, 4, 'DOWN', GLADIIA_HOK_Y]);
|
||||
assert.deepEqual([u0.row, u0.col, u0.dir], [10, 4, 'DOWN']);
|
||||
m.dispose();
|
||||
// position stays MELEE either way: the normal record (the same body the 0.1.2 measurement used) attacks the lane
|
||||
// from the 高台 and blocks nothing there. HOK-Y's own range is not what this asserts.
|
||||
// position stays MELEE either way: she attacks the lane from the 高台 and blocks nothing there
|
||||
for (const [r, c, dir, high] of [[10, 4, 'DOWN', true], [9, 5, 'RIGHT', false]]) {
|
||||
const h = makeBattle({
|
||||
stageId: HIGH_STAGE, seed: 3, timeLimit: 60, autoFinish: false,
|
||||
@@ -473,42 +463,36 @@ test('高台 battle input: elite 歌蕾蒂娅 + HOK-Y is fielded on (10,4) and b
|
||||
}
|
||||
});
|
||||
|
||||
test('高台 bots: elite 歌蕾蒂娅 + HOK-Y takes a 高台 that covers the road while the ground is still free', () => {
|
||||
test('高台 bots: every trait holder, normal and elite, takes a 高台 that covers the road while the ground is still free; a plain melee stays on the road', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE, 11);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const elite = give(m, ps, GLAD_E);
|
||||
const k = planLayout(m, ps, [elite]).get(elite.uid);
|
||||
assert.equal(ps.deployMap().get(k), 'ranged', `planned on a 高台 with the road open (${k})`);
|
||||
const [r, c] = rc(k);
|
||||
assert.deepEqual(move(m, elite.uid, board(r, c)), { ok: true });
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('高台 bots: everyone else stays on the road; elite 歌蕾蒂娅 + HOK-Y may take a 高台 when the ground is full', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE, 11);
|
||||
const pieces = ['chess_char_4_02_a', GLAD_N, FORCER_N, 'chess_char_2_03_a'].map((id) => give(m, ps, id));
|
||||
const plan = planLayout(m, ps, pieces);
|
||||
const map = ps.deployMap();
|
||||
const road = fieldModel(m, ps).ground;
|
||||
for (const p of pieces.slice(1)) {
|
||||
const k = plan.get(p.uid);
|
||||
assert.equal(map.get(k), 'melee', `${p.id} on a ground tile (${k})`);
|
||||
assert.ok(road.has(k), `${p.id} on an enemy road tile (${k})`);
|
||||
assert.equal(move(m, p.uid, board(10, 4)).error, ERR.BAD_TILE, `${p.id} deploy refused`);
|
||||
for (const id of HOLDERS) {
|
||||
const p = give(m, ps, id);
|
||||
for (const module of loadoutsOf(id)) {
|
||||
assert.equal(ps.setLoadout(module ? { [DATA.chess[id].baseId]: { module } } : {}), true);
|
||||
const plan = planLayout(m, ps, [p]);
|
||||
const k = plan.get(p.uid);
|
||||
assert.equal(map.get(k), 'ranged', `${id} ${module || 'default'}: planned on a 高台 with the road open (${k})`);
|
||||
const [r, c] = rc(k);
|
||||
const cover = ownerRangeKeys(DATA.chess[id].rangeGrid, r, c, plan.dirOf(p.uid));
|
||||
assert.ok([...cover].some((t) => road.has(t)), `${id}: its range from ${k} covers the road`);
|
||||
assert.deepEqual(move(m, p.uid, board(r, c), plan.dirOf(p.uid)), { ok: true });
|
||||
assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
}
|
||||
}
|
||||
const elite = give(m, ps, GLAD_E);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const melee = [...map].filter(([, cls]) => cls === 'melee').map(([k]) => k);
|
||||
const highPlan = planLayout(m, ps, [elite], undefined, { occupied: new Set(melee) });
|
||||
const hk = highPlan.get(elite.uid);
|
||||
assert.equal(map.get(hk), 'ranged', `HOK-Y planned on a 高台 (${hk})`);
|
||||
const [hr, hc] = rc(hk);
|
||||
assert.deepEqual(move(m, elite.uid, board(hr, hc)), { ok: true });
|
||||
ps.setLoadout({});
|
||||
// a plain melee operator (a 重装) stays on an enemy road tile, and a 高台 is no fallback for it once the ground is full
|
||||
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
|
||||
const k = planLayout(m, ps, [tank]).get(tank.uid);
|
||||
assert.equal(map.get(k), 'melee', `the 重装 on a ground tile (${k})`);
|
||||
assert.ok(road.has(k), `the 重装 on an enemy road tile (${k})`);
|
||||
assert.equal(move(m, tank.uid, board(10, 4)).error, ERR.BAD_TILE, 'the 重装 deploy refused');
|
||||
const melee = new Set([...map].filter(([, cls]) => cls === 'melee').map(([key]) => key));
|
||||
assert.equal(planLayout(m, ps, [tank], undefined, { occupied: melee }).get(tank.uid), undefined, 'no ground left: no plan');
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
const hk = planLayout(m, ps, [glad], undefined, { occupied: melee }).get(glad.uid);
|
||||
assert.equal(map.get(hk), 'ranged', `elite 歌蕾蒂娅 still gets a 高台 (${hk})`);
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(toHand(m, ps, elite), { ok: true });
|
||||
equip(ps, 'none');
|
||||
const refused = planLayout(m, ps, [elite], undefined, { occupied: new Set(melee) });
|
||||
assert.equal(refused.get(elite.uid), undefined, 'no module: a 高台 is not a fallback');
|
||||
assert.equal(move(m, elite.uid, board(10, 4)).error, ERR.BAD_TILE);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA, makeMatch } from './harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { bondSnapshot } from '../../server/match/bondsMeta.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, compactResult } from '../../server/sim/spec.js';
|
||||
import { validateClientResult } from '../../server/match/fields.js';
|
||||
import { helperStats, helperOrder } from '../../server/match/unite.js';
|
||||
|
||||
const IDS = ['maniShip', 'emptyShip', 'soloShip', 'suntShip'];
|
||||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||||
const bonds = () => Object.fromEntries(IDS.map((id) => [id, { count: 2, active: true, tier: 1, layers: 20 }]));
|
||||
|
||||
test('hidden-layer bonds retain saved layers, prep gains, pending gains and battle input layers', () => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, fake: true }).start();
|
||||
try {
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
for (const id of IDS) {
|
||||
assert.equal(gd.bond(id).noStack, true, id);
|
||||
ps.bondCountBonus[id] = gd.bond(id).thresholds[0];
|
||||
ps.layers[id] = 50;
|
||||
}
|
||||
ps.bondCountBonus.deputShip = 2;
|
||||
ps.recompute();
|
||||
for (const id of IDS) {
|
||||
assert.equal(ps.bonds[id].active, true);
|
||||
assert.equal(ps.layers[id], 50, 'recompute must not erase saved layers');
|
||||
assert.equal(ps.addLayers(id, 8, { requireActive: true }), 8);
|
||||
assert.equal(ps.addLayers(id, 8, { requireActive: false }), 8);
|
||||
}
|
||||
h.m.dispatch(ps, 'onPrepEnd', { round: 1 });
|
||||
for (const id of IDS) assert.equal(ps.layers[id], 68, '助力 prep-end traits can add hidden layers');
|
||||
ps.pendingLayerGains = Object.fromEntries(IDS.map((id) => [id, 9]));
|
||||
for (const id of IDS) {
|
||||
assert.equal(ps.bondsView()[id].layers, 77);
|
||||
assert.equal(bondSnapshot(ps.bonds)[id].layers, 68, 'battle input keeps settled layers');
|
||||
}
|
||||
} finally { h.m.dispose(); }
|
||||
});
|
||||
|
||||
test('hidden-layer gains run through the simulator hooks, event stream and result', () => {
|
||||
const spec = buildBattleSpec({ stageId: 'act2autochess_m01', timeLimit: 1, content: 'none',
|
||||
players: [{ playerId: 'p_0', units: [], bonds: bonds() }], flags: { layerGainsEnabled: true } });
|
||||
const b = createBattleFromSpec(spec, DATA, { quiet: true });
|
||||
const calls = [];
|
||||
b.on('layerGain', (c) => calls.push(c.bondId));
|
||||
for (const id of IDS) {
|
||||
assert.equal(b.getPlayer('p_0').bonds[id].layers, 20);
|
||||
assert.equal(b.addLayers('p_0', id, 4), 4);
|
||||
assert.equal(b.getPlayer('p_0').bonds[id].layers, 24);
|
||||
}
|
||||
assert.deepEqual(calls, IDS);
|
||||
assert.deepEqual(b.drainEvents().filter((e) => e[0] === 'layer').map((e) => e[2]), IDS);
|
||||
b.forceEnd();
|
||||
assert.deepEqual(b.result().perPlayer.p_0.layerGains, Object.fromEntries(IDS.map((id) => [id, 4])));
|
||||
});
|
||||
|
||||
test('client validation accepts legal hidden-layer gains and still rejects malformed JSON values', () => {
|
||||
const spec = buildBattleSpec({ stageId: 'act2autochess_m01', timeLimit: 1, content: 'none',
|
||||
players: [{ playerId: 'p_0', units: [], bonds: bonds() }], flags: { layerGainsEnabled: true } });
|
||||
const b = createBattleFromSpec(spec, DATA, { quiet: true });
|
||||
for (const id of IDS) b.addLayers('p_0', id, 4);
|
||||
b.forceEnd();
|
||||
const raw = compactResult(b.result());
|
||||
assert.equal(validateClientResult(spec, raw, { gd }).ok, true);
|
||||
for (const value of [-1, '4', null, {}, 1000]) {
|
||||
const forged = structuredClone(b.result());
|
||||
forged.perPlayer.p_0.layerGains.soloShip = value;
|
||||
assert.equal(validateClientResult(spec, forged, { gd }).ok, false, JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('settlement retains hidden-layer gains from the server result', () => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, fake: true,
|
||||
script: () => ({ layerGains: { p_0: { soloShip: 12, suntShip: 9, yanShip: 3 } } }) }).start();
|
||||
try {
|
||||
h.toPrep(1);
|
||||
h.m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
h.toPrep(2);
|
||||
assert.equal(h.ps('p_0').layers.soloShip, 12);
|
||||
assert.equal(h.ps('p_0').layers.suntShip, 9);
|
||||
assert.equal(h.ps('p_0').layers.yanShip, 3);
|
||||
} finally { h.m.dispose(); }
|
||||
});
|
||||
|
||||
test('hidden layers contribute to unite pair ordering while inactive layers do not', () => {
|
||||
const player = (id, seat, layers) => ({ playerId: id, seat, deployCount: 1, board: new Map(),
|
||||
layers: { soloShip: layers, suntShip: 999 }, bonds: {
|
||||
soloShip: { active: true, layers }, suntShip: { active: false, layers: 999 },
|
||||
} });
|
||||
const a = player('p_0', 0, 5), b = player('p_1', 1, 8), m = { gd }, results = new Map();
|
||||
assert.equal(helperStats(m, b, results).layers, 8);
|
||||
assert.deepEqual(helperOrder(m, [a, b], results).map((p) => p.playerId), ['p_1', 'p_0']);
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { Match } from '../../server/match/Match.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
@@ -23,7 +24,8 @@ let srv = null;
|
||||
const clients = [];
|
||||
|
||||
async function server() {
|
||||
if (!srv) srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: FastMatch });
|
||||
// Issue #144: in the one-human + AI draft, seed 69 makes the AI take band_bldsk first.
|
||||
if (!srv) srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: FastMatch, seedFn: () => 69 });
|
||||
return srv;
|
||||
}
|
||||
async function player(name, token) {
|
||||
@@ -38,6 +40,23 @@ async function player(name, token) {
|
||||
}
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
|
||||
/** Pick from the current public draft, including the AI's choices; never rely on a timeout after a refused pick. */
|
||||
async function pickCoopBands(players) {
|
||||
const pending = new Map(players.map((c) => [c.id, c]));
|
||||
let frame;
|
||||
while (pending.size) {
|
||||
frame = await players[0].waitFor('m.public', (p) => p.phase === 'BAND_DRAFT' && pending.has(p.draft?.turn), 10000);
|
||||
assert.equal(frame.draft.order.length, frame.players.length);
|
||||
const taken = new Set(Object.values(frame.draft.picks));
|
||||
const band = Object.values(getData().bands).find((b) =>
|
||||
(!Array.isArray(b.modeTypeList) || b.modeTypeList.includes('MULTI')) && !taken.has(b.bandId));
|
||||
assert.ok(band, 'a co-op strategy is still available');
|
||||
await ok(pending.get(frame.draft.turn), { t: 'g.band', bandId: band.bandId });
|
||||
pending.delete(frame.draft.turn);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
for (const c of clients) await c.terminate().catch(() => {});
|
||||
if (srv) await srv.close();
|
||||
@@ -101,15 +120,7 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match
|
||||
for (const c of [a, b]) await c.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await ok(b, { t: 'g.infoReady' });
|
||||
const draft = await a.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT' && p.draft);
|
||||
assert.equal(draft.draft.order.length, 3);
|
||||
// humans pick when it is their turn (bots pick themselves)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const p = await a.waitFor('m.public', (x) => x.phase !== 'BAND_DRAFT' || [a.id, b.id].includes(x.draft && x.draft.turn), 10000);
|
||||
if (p.phase !== 'BAND_DRAFT') break;
|
||||
const who = p.draft.turn === a.id ? a : b;
|
||||
await who.request({ t: 'g.band', bandId: 'band_bldsk' });
|
||||
}
|
||||
await pickCoopBands([a, b]);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||
// B drops and comes back with its token
|
||||
await b.terminate();
|
||||
@@ -120,7 +131,9 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match
|
||||
assert.equal(priv.playerId, b2.id);
|
||||
await ok(a, { t: 'g.ready', ready: true });
|
||||
await ok(b2, { t: 'g.ready', ready: true });
|
||||
await a.waitFor('m.public', (p) => p.phase === 'COMBAT', 10000);
|
||||
// Instant simulated battles can finish before the throttled public phase is broadcast, as in the solo test.
|
||||
const start = await a.waitFor('b.start', (x) => x.authoritative, 10000);
|
||||
assert.equal(start.fieldId, `n:${a.id}`);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
|
||||
await ok(a, { t: 'g.leave' });
|
||||
await ok(b2, { t: 'g.leave' });
|
||||
@@ -154,11 +167,8 @@ test('co-op spectator over websockets: watches the real match like an eliminated
|
||||
assert.equal(r.code, 'SPECTATOR', msg.t);
|
||||
}
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
for (let i = 0; i < 40; i++) { // every draft frame in order: pick on the own turn (the AI picks at once)
|
||||
const p = await a.waitFor('m.public', (x) => x.phase !== 'INFO_CHECK', 10000);
|
||||
if (p.phase !== 'BAND_DRAFT') break;
|
||||
if (p.draft && p.draft.turn === a.id && !p.draft.picks[a.id]) await ok(a, { t: 'g.band', bandId: 'band_bldsk' });
|
||||
}
|
||||
const draft = await pickCoopBands([a]);
|
||||
assert.ok(Object.values(draft.draft.picks).includes('band_bldsk'), 'seed 69: the AI already took the default');
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||
for (const msg of [{ t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.refresh' }]) {
|
||||
const r = await s.request(msg);
|
||||
|
||||
Reference in new issue
Block a user