feedback7: merge fb7-late — #389 / PR #390 画卷 keeps the items to copy across a merge, #385 / PR #387 spectator bond details count the hand like the own popup, #394 / PR #395 Enter during facing selection follows the focus, #281 the attack range drawn on a tapped operator, #388 引星棘刺 S1 DEF stacks, PR #386 谢拉格 寒风 blows bottom-up (owner)

- Golden union OK (roster-033 / roster-034 moved on both sides: #298 and #388); broad node --test 5598 (5596 pass, 0 fail,
  2 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 21:54:03 +08:00
commit 2492966c4f
32 files changed
+913 -138

No files matched your search

+30
View File
@@ -1031,6 +1031,36 @@ test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +
}
});
test('引星棘刺 S1 度算浪波: two alchemy units on one ally add their DEF (PRTS 备注 「效果均可叠加」, GitHub #388)', () => {
// two 引星棘刺 (a normal and an elite: +60 and +75) throw at the same lowest-HP ally; one shared buff key used to let the
// last unit's DEF replace the other's (+75 instead of +135) while both heals already landed
const [na, el] = pair('15');
const h = run({
defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }) } },
units: [entry(na, 'skchr_thorn2_1', { row: 10, col: 3 }), entry(el, 'skchr_thorn2_1', { row: 11, col: 3 }), { chessId: 't_low', row: 10, col: 5 }],
enemies: [],
});
const a = sel(h, na, 'skchr_thorn2_1'), b = sel(h, el, 'skchr_thorn2_1');
const low = h.unit('t_low');
h.step();
low.hp = low.s.maxHp * 0.3;
for (const u of [a, b]) u.skill.gainSp(1000);
h.runUntil(() => [a, b].every((u) => (u.mem.zones || []).some((z) => z.type === 'guard')), 10);
h.run(1.1);
const [za, zb] = [a, b].map((u) => u.mem.zones.find((z) => z.type === 'guard'));
assert.deepEqual([za.r, za.c, zb.r, zb.c], [10, 5, 10, 5], 'both on the lowest-HP ally');
assert.equal(low.s.def, 100 + bbOf(a).def + bbOf(b).def, '+60 +75');
assert.ok(low.hp > low.s.maxHp * 0.3, 'and they restore its HP');
// one unit runs out: the other's DEF stays
mute(h, a); mute(h, b);
h.run(Math.max(0, za.dur - za.t) + 0.6);
assert.ok(!a.mem.zones.includes(za) && b.mem.zones.includes(zb), 'the elite\'s unit (7 s) outlasts the normal one (6 s)');
assert.equal(low.s.def, 100 + bbOf(b).def, 'its DEF stays alone');
h.run(zb.dur);
assert.equal(low.s.def, 100, 'no DEF bonus afterwards');
done(h);
});
test('引星棘刺 S1 度算浪波 (AUTO): fires as soon as its SP is full, no enemy needed (GitHub #124)', () => {
for (const id of pair('15')) {
const h = run({
+55 -55
View File
@@ -2017,64 +2017,64 @@
"round": 12,
"seed": 3754088384,
"waveId": "act1autochess_h05",
"time": 219.47,
"ticks": 6584,
"time": 214.93,
"ticks": 6448,
"reason": "cleared",
"killed": 35,
"killed": 54,
"total": 167,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 182,
"rngDraws": 203,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 35,
"killed": 54,
"total": 167,
"perfect": false,
"leaks": 132,
"leaks": 113,
"leaksUncounted": 0,
"coins": 2,
"dmg": 366322,
"dmg": 579384,
"bossDmg": 0,
"heal": 45493,
"deaths": 31,
"layerGains": {"indomShip":35}
"heal": 69092,
"deaths": 26,
"layerGains": {"indomShip":75}
}
},
"units": [
["p1",1,"op","chess_char_5_11_a",0,null,0,0,7,3,18161,12816,11936,37,6],
["p1",2,"op","chess_char_5_19_a",0,null,0,0,181,1,24090,10511,2980,9,1],
["p1",3,"op","chess_char_5_13_a",0,null,0,0,182,4,69847,16642,1033,31,1],
["p1",4,"op","chess_char_5_14_a",0,null,0,0,200,0,2553,6628,1432,9,2],
["p1",5,"op","chess_char_5_06_b",0,"uniequip_003_etlchi",0,0,88,14,152980,19394,11582,36,8],
["p1",6,"op","chess_char_5_17_a",0,null,0,0,198,0,16224,20093,192,30,7],
["p1",7,"op","chess_char_5_12_a",1,null,0,0,3,3,14164,3395,1991,13,2],
["p1",8,"op","chess_char_5_15_a",0,null,0,0,201,5,18715,1950,98,75,20],
["p1",9,"op","chess_char_5_20_a",0,null,0,0,184,0,7091,3502,178,15,1],
["p1",10,"op","chess_char_5_21_a",0,null,0,0,189,0,5581,7990,180,9,0],
["p1",11,"op","chess_char_5_22_a",0,null,0,0,193,5,34367,5558,1385,49,2],
["p1",12,"op","chess_char_5_23_a",0,null,1422,1,202,0,0,6488,11768,20,7],
["p1",1,"op","chess_char_5_11_a",0,null,0,0,185,6,24682,19544,18577,52,11],
["p1",2,"op","chess_char_5_19_a",0,null,3241,1,200,8,50200,14729,7170,23,3],
["p1",3,"op","chess_char_5_13_a",0,null,681.15,1,202,6,179758,34866,5612,46,1],
["p1",4,"op","chess_char_5_14_a",0,null,1331.1,1,203,0,4188,13059,3998,14,2],
["p1",5,"op","chess_char_5_06_b",0,"uniequip_003_etlchi",2992,1,204,15,169257,19787,11958,43,10],
["p1",6,"op","chess_char_5_17_a",0,null,723.48,1,193,1,20066,17784,192,37,9],
["p1",7,"op","chess_char_5_12_a",1,null,1404,1,3,3,15355,4263,3154,15,3],
["p1",8,"op","chess_char_5_15_a",0,null,769.65,1,4,7,30505,713,557,126,34],
["p1",9,"op","chess_char_5_20_a",0,null,0,0,188,0,7281,3522,198,16,1],
["p1",10,"op","chess_char_5_21_a",0,null,0,0,191,0,5581,7990,180,9,0],
["p1",11,"op","chess_char_5_22_a",0,null,1237,1,5,8,69963,3136,3136,103,4],
["p1",12,"op","chess_char_5_23_a",0,null,1422,1,205,0,0,8541,13622,21,7],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["p1","~1","token","token_10015_dusk_drgn",null,null,1947.38,0,38,0,2547,787,737,8,null]
],
"enemies": {
"enemy_1005_yokai_3": [1,0,1,5891,11900],
"enemy_1009_lurker": [72,17,55,921,93670],
"enemy_1005_yokai_3": [1,0,1,5415,17555],
"enemy_1009_lurker": [72,19,53,995,136536],
"enemy_10159_mntrjn": [1,0,1,0,25020],
"enemy_1019_jshoot_2": [51,12,39,19173,94135],
"enemy_1019_jshoot_2": [51,24,27,18021,164779],
"enemy_1118_lidbox_2": [1,1,0,39,14774],
"enemy_1196_msfyin": [1,1,0,0,3],
"enemy_1252_lysytb_2": [1,0,1,338,11250],
"enemy_1364_spnaxe_2": [1,0,1,5266,7966],
"enemy_1404_msnip": [36,4,32,77659,149942],
"enemy_1516_jakill": [1,0,1,6133,5143],
"enemy_9011_acrefr": [1,0,1,336,2479]
"enemy_1364_spnaxe_2": [1,0,1,4584,15996],
"enemy_1404_msnip": [36,9,27,111370,227827],
"enemy_1516_jakill": [1,0,1,7554,10944],
"enemy_9011_acrefr": [1,0,1,408,6100]
},
"hooks": [1,202,6584,853,853,3205,3222,10125,66,199,57,57,0,18157,181,97,167,132,1,69,7,0,8],
"events": {"atk":955,"bounty":1,"deploy":33,"die":67,"dmg":3218,"engage":12,"fx":68,"heal":56,"layer":7,"leak":132,"skill":114,"spawn":182,"status":191},
"fx": {"aoe":2,"crit":4,"dodge":8,"dollEnd":3,"garrison":7,"healAoe":6,"hpShare":1,"palsy":1,"reborn":2,"revive":6,"sp":4,"substitute":3,"summon":1,"zone":20},
"snaps": "0092f82c 0dd327ad 53a3d810 58ba1139 90f0fe1e 37f52c37 cd5a8c36 c2f3acff ca099967 25de291a 2f88cb0f 5966eb8a c429d9ee 2513f9c2 c0f223dd 17c04486 0f59775e 7a41457d 4c1669d7 078e9615 676431e7 b6538f95"
"hooks": [1,205,6448,1165,1165,4391,4433,16853,80,194,85,83,0,27673,241,133,167,113,1,86,15,0,6],
"events": {"atk":1276,"bounty":1,"deploy":36,"die":81,"dmg":4429,"engage":12,"fx":107,"heal":69,"layer":15,"leak":113,"skill":168,"spawn":182,"status":237},
"fx": {"aoe":2,"crit":6,"dodge":6,"dollEnd":3,"garrison":15,"healAoe":11,"hpShare":1,"palsy":1,"reborn":2,"revive":11,"sp":5,"substitute":6,"summon":1,"swap":3,"zone":34},
"snaps": "0092f82c 0dd327ad 53a3d810 58ba1139 90f0fe1e cb512e37 ceb2cf74 a613cd55 5ce6b6e5 d58de777 28df897a f1e4ae5f b4781ed7 3af8405e 2c626d36 ce72c9fa e19c7d08 6db15d3a b2ed2de3 dadece28 8a66c30e ce8ff9d7"
},
"roster-034": {
"about": "pass 1 band 6: chess_char_5_08_b chess_char_5_11_b chess_char_5_19_b chess_char_5_13_b chess_char_5_14_b chess_char_5_07_b chess_char_5_17_b chess_char_5_10_b chess_char_5_12_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b",
@@ -2088,26 +2088,26 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 86,
"killed": 90,
"total": 104,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 621,
"rngDraws": 565,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":86,"total":104,"perfect":false,"leaks":18,"leaksUncounted":0,"coins":8,"dmg":5013885,"bossDmg":0,"heal":163673,"deaths":1,"layerGains":{}}},
"players": {"p1":{"killed":90,"total":104,"perfect":false,"leaks":14,"leaksUncounted":0,"coins":8,"dmg":5110704,"bossDmg":0,"heal":120360,"deaths":1,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_b",0,"uniequip_003_horn",3000,1,7,25,1690098,32737,709,96,86],
["p1",2,"op","chess_char_5_11_b",0,"uniequip_003_demkni",4176.51,1,10,0,29905,12253,69243,182,38],
["p1",3,"op","chess_char_5_19_b",0,"none",3682,1,11,3,184370,9411,9828,136,5],
["p1",4,"op","chess_char_5_13_b",0,"uniequip_003_ghost2",3050.48,1,13,3,99196,55309,54515,210,5],
["p1",5,"op","chess_char_5_14_b",0,"none",3261,1,15,0,16840,26781,26785,217,15],
["p1",6,"op","chess_char_5_07_b",0,"uniequip_003_surtr",1804.11,1,8,6,337400,27088,605,137,60],
["p1",7,"op","chess_char_5_17_b",0,"uniequip_003_f12yin",214.49,1,97,4,324747,5435,193,119,54],
["p1",8,"op","chess_char_5_10_b",0,"uniequip_003_lisa",1470,1,4,4,161523,0,0,118,6],
["p1",9,"op","chess_char_5_12_b",1,"uniequip_003_dusk",1667,1,5,33,1577956,0,0,89,5],
["p1",10,"op","chess_char_5_15_b",0,"none",1907.5,1,12,0,47882,0,0,166,89],
["p1",11,"op","chess_char_5_20_b",0,"uniequip_003_aglina",1352,1,14,3,205107,1794,1794,138,7],
["p1",12,"op","chess_char_5_21_b",0,"none",3330.35,1,6,5,338859,0,0,67,3],
["p1",1,"op","chess_char_5_08_b",0,"uniequip_003_horn",1512.22,1,7,28,1726631,34150,947,96,84],
["p1",2,"op","chess_char_5_11_b",0,"uniequip_003_demkni",3477.62,1,10,0,39384,11346,66820,184,36],
["p1",3,"op","chess_char_5_19_b",0,"none",3682,1,11,4,178330,6313,6313,129,5],
["p1",4,"op","chess_char_5_13_b",0,"uniequip_003_ghost2",3448.92,1,13,3,109842,29514,29300,210,5],
["p1",5,"op","chess_char_5_14_b",0,"none",3261,1,15,0,16840,13747,13949,217,20],
["p1",6,"op","chess_char_5_07_b",0,"uniequip_003_surtr",2680,1,8,7,313834,25889,788,121,56],
["p1",7,"op","chess_char_5_17_b",0,"uniequip_003_f12yin",538.04,1,97,6,252445,5368,450,89,42],
["p1",8,"op","chess_char_5_10_b",0,"uniequip_003_lisa",1470,1,4,5,160605,0,0,120,6],
["p1",9,"op","chess_char_5_12_b",1,"uniequip_003_dusk",1667,1,5,32,1794727,0,0,91,6],
["p1",10,"op","chess_char_5_15_b",0,"none",1907.5,1,12,1,57355,0,0,166,82],
["p1",11,"op","chess_char_5_20_b",0,"uniequip_003_aglina",1352,1,14,2,213658,1794,1794,138,6],
["p1",12,"op","chess_char_5_21_b",0,"none",3330.35,1,6,2,247052,0,0,57,3],
["-","~1","device","trap_1105_accrate",null,null,100,0,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
@@ -2119,16 +2119,16 @@
"enemy_1165_duhond_2": [1,1,0,114,24767],
"enemy_1204_msfhu": [1,1,0,0,12],
"enemy_1288_duskls": [1,1,0,1594,30964],
"enemy_1381_winman": [27,23,4,6312,906059],
"enemy_1381_winman_2": [39,29,9,26620,1665434],
"enemy_1387_winshd": [30,26,2,114701,2095022],
"enemy_1381_winman": [27,25,2,4083,938899],
"enemy_1381_winman_2": [39,31,7,24737,1719910],
"enemy_1387_winshd": [30,26,2,76127,2104525],
"enemy_1425_lrcmra_2": [1,1,0,11329,154793],
"enemy_2009_csaudc": [1,1,0,2690,49534]
},
"hooks": [1,120,6900,2623,2623,4011,4006,10250,87,103,373,370,0,31826,6617,100,104,15,1,88,0,0,14],
"events": {"atk":3112,"bounty":4,"deploy":13,"die":88,"dmg":3995,"engage":12,"fx":345,"heal":38,"leak":15,"skill":743,"spawn":119,"status":810},
"fx": {"aoe":15,"crit":21,"dodge":14,"ember":1,"healAoe":38,"hpShare":5,"sp":140,"zone":111},
"snaps": "de3bd2e2 efefe0a2 43519431 531ae1eb aa084bc8 507c87eb 0f5120b4 9a46e524 080c06c4 5b040f54 0c7a6be0 c2043b05 ae95d0ab e2cd74c3 5ca51241 aaf4ddc9 9f2d19ce dd8d93e5 7dbbf487 3f8ce0c6 325e24cf 4a98b418 a0f8abef"
"hooks": [1,120,6900,2558,2558,3995,3996,8433,91,103,351,349,0,31469,6479,104,104,11,1,92,0,0,8],
"events": {"atk":3070,"bounty":4,"deploy":13,"die":92,"dmg":3985,"engage":12,"fx":326,"heal":36,"leak":11,"skill":700,"spawn":119,"status":807},
"fx": {"aoe":20,"crit":14,"dodge":8,"ember":1,"healAoe":36,"hpShare":5,"sp":139,"zone":103},
"snaps": "de3bd2e2 23f28d96 7a63c0f2 28a9a6fe e6984bca 8eae402d 2b24991d c8788f23 5a001b21 0fed151a 87c013c7 b38057af 123bb336 e5b070fc 7fac01fd fb1d6e4d 2f2bc32b d3c63947 a34e6643 97d460d1 3c1349d8 783047e8 2de5f6f2"
},
"roster-035": {
"about": "pass 1 band 6: chess_char_6_03_a chess_char_6_06_a chess_char_6_07_a chess_char_5_22_b chess_char_5_23_b chess_char_6_01_a chess_char_6_02_a chess_char_6_04_a chess_char_6_05_a chess_char_6_08_a chess_char_6_09_a chess_char_6_11_a token_10017_skadi2_dedant token_10030_mlyss_wtrman",
@@ -56,6 +56,7 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
const h = bossPrep(4, 5102);
const m = h.m;
const b = place(h, 'p_1', chessOfTier(1)[1]);
const held = give(m, h.ps('p_0'), chessOfTier(1)[2]); // into p_0's hand
m.flush(true);
// p_3 (eliminated) scouts p_0: the scout shows p_0's half and p_1's board on the other half
h.ps('p_3').eliminate(13);
@@ -63,6 +64,9 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
let scout = h.lastTo('p_3', 'm.field');
assert.deepEqual([scout.kind, scout.side, scout.mate], ['boss', 'L', { playerId: 'p_1', side: 'R' }]);
assert.ok(scout.units.some((u) => u.uid === b.piece.uid && u.x >= 10), 'the partner\'s piece on the right half');
// the area tags survive the boss-half remap (the rows do not): the bond popup tells the bench from the board (GitHub #385)
assert.equal(scout.units.find((u) => u.uid === b.piece.uid).area, 'board');
assert.equal(scout.units.find((u) => u.uid === held.uid).area, 'hand', 'a held operator stays tagged as held');
// p_1 moves its piece: p_0's m.private and p_3's scout of p_0 follow
const ps1 = h.ps('p_1');
const to = legalTileFor(m, ps1, b.piece.id, new Set([`${b.tile[0]},${b.tile[1]}`]));
+7 -1
View File
@@ -37,6 +37,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
const u = first.units.find((x) => x.uid === piece.uid);
assert.ok(u, `${pid}: the held operator is a unit of the scout`);
assert.deepEqual([u.x, u.y], [0, GEO.HAND_ROW], `${pid}: standing on the first hand slot`);
assert.equal(u.area, 'hand', 'tagged with its area: the bond popup counts it as held, not in play (GitHub #385)');
assert.equal(u.kind, 'op');
assert.equal(u.items, undefined, 'no items equipped yet');
}
@@ -48,6 +49,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
const last = h.lastTo('p_0', 'm.field');
const u = last.units.find((x) => x.uid === piece.uid);
assert.deepEqual([u.x, u.y], [c, r], 'deployed onto the watched board');
assert.equal(u.area, 'board');
assert.equal(last.units.some((x) => x.uid === piece.uid && x.y === GEO.HAND_ROW), false, 'and gone from the hand row');
m.dispose();
});
@@ -74,6 +76,7 @@ test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped
assert.ok(iu, 'the held item is a unit of the scout');
assert.equal(iu.kind, 'item');
assert.equal(iu.y, GEO.HAND_ROW);
assert.equal(iu.area, 'hand');
// equip the item onto the operator (still in the hand): the operator's unit carries the item id
assert.deepEqual(m.handle('p_1', { t: 'g.equip', itemUid: item.uid, targetUid: op.uid }), { ok: true });
const after = h.lastTo('p_0', 'm.field');
@@ -109,7 +112,10 @@ test("PR #129 review: moving a hand piece to another slot is a scout change (x f
const first = h.lastTo('p_0', 'm.field');
const tu = first.units.find((x) => x.uid === c.uid);
assert.deepEqual([tu.x, tu.y], [GEO.TEMP_C0, GEO.TEMP_ROW], 'the temp piece scouts on the temp row (first slot, col 4)');
assert.equal(first.units.find((x) => x.uid === a.uid).x, 0);
assert.equal(tu.area, 'temp');
const hu = first.units.find((x) => x.uid === a.uid);
assert.equal(hu.x, 0);
assert.equal(hu.area, 'hand');
// a hand piece moved to another slot keeps uid and id — the slot must be in the signature for the push to fire
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: a.uid, to: { area: 'hand', idx: 3 } }), { ok: true });
+113
View File
@@ -0,0 +1,113 @@
// A Picture Scroll (画卷) gain can consume its own target in an immediate three-copy promotion (GitHub #389, PR #390).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, legalTileFor } from './harness.js';
const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report
const SCROLL = 'chess_item_6_02_m';
const HAMMER = 'chess_item_1_01_e_a';
const SHIELD = 'chess_item_1_02_e_a';
const FILLER = 'chess_item_6_09_e_a'; // non-mergeable
const OK = { ok: true };
function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 8 }).start();
t.after(() => h.m.dispose());
h.toPrep(1, { band: 'band_dusk' });
const m = h.m, ps = h.ps('p_0');
assert.equal(m.gd.mergeCount(OP), 3);
const id = golden ? m.gd.goldenIdOf(OP) : OP;
const target = ps.acquireChess(id);
const [row, col] = legalTileFor(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: target.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK);
for (let i = 1; i < copies; i++) ps.acquireChess(id);
for (const itemId of items) {
const item = ps.acquireItem(itemId);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }), OK);
}
const art = ps.hand.find((p) => p?.id === SCROLL);
assert.ok(art, 'Dusk grants the scroll in round 1');
h.invariants();
const cast = () => m.handle('p_0', { t: 'g.art', itemUid: art.uid, row, col, dir: 'RIGHT' });
return { h, m, ps, target, art, row, col, cast };
}
function itemIds(ps) {
return [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean)
.flatMap((p) => p.kind === 'item' ? [p.id] : (p.items || []).map((it) => it.id)).sort();
}
test('Picture Scroll: a third normal copy preserves the target equipment through promotion', (t) => {
const { h, m, ps, target, art, row, col, cast } = prep(t);
const funds = ps.funds;
assert.deepEqual(cast(), OK);
const elite = ps.board.get(`${row},${col}`);
assert.equal(elite.id, m.gd.goldenIdOf(OP));
assert.equal(elite.dir, 'RIGHT');
assert.equal(ps.find(target.uid), null, 'the original target was consumed');
assert.equal(ps.find(art.uid), null, 'the scroll was consumed');
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
assert.ok(ps.hand.some((p) => p?.id === m.gd.item(HAMMER).goldenId), 'the original and copied hammer merge into an advanced hammer in hand');
assert.deepEqual(elite.items, []);
assert.equal(ps.stats.merges, 1);
assert.equal(ps.stats.itemMerges, 1);
assert.equal(ps.funds, funds);
h.invariants();
});
for (const advanced of [false, true]) {
test(`Picture Scroll: promotion copies both ${advanced ? 'advanced' : 'normal'} equipped items, none of them worn by the elite`, (t) => {
const items = [HAMMER, SHIELD].map((id) => advanced ? id.replace(/_a$/, '_b') : id);
const { h, m, ps, row, col, cast } = prep(t, { items });
assert.deepEqual(cast(), OK);
const elite = ps.board.get(`${row},${col}`);
const upgraded = items.map((id) => m.gd.item(id).goldenId || id).sort();
// advanced items do not merge: the returned originals and the copies all wait in the hand ("获得的装备为未装备状态")
assert.deepEqual(itemIds(ps), advanced ? [...items, ...items].sort() : upgraded);
assert.deepEqual(elite.items, [], 'the promotion unequips, and the copies are not put back on the elite');
assert.equal([...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').length, advanced ? 4 : 2);
h.invariants();
});
}
for (const golden of [false, true]) {
test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target still copies its equipment without promotion`, (t) => {
const { h, m, ps, target, cast } = prep(t, { copies: 1, golden });
assert.deepEqual(cast(), OK);
assert.equal(ps.find(target.uid)?.piece, target);
assert.equal(ps.allChess().length, 2);
assert.equal(ps.allChess().filter((p) => p.id === target.id).length, 2);
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
assert.equal(ps.stats.merges, 0);
h.invariants();
});
}
test('Picture Scroll: a third copy without equipment still promotes normally', (t) => {
const { h, m, ps, row, col, cast } = prep(t, { items: [] });
assert.deepEqual(cast(), OK);
assert.equal(ps.board.get(`${row},${col}`).id, m.gd.goldenIdOf(OP));
assert.deepEqual(itemIds(ps), []);
h.invariants();
});
test('Picture Scroll: a promotion at full hand and temp still copies the hammer', (t) => {
const { h, m, ps, cast } = prep(t);
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
h.invariants();
assert.deepEqual(cast(), OK);
assert.deepEqual(itemIds(ps).filter((id) => id !== FILLER), [m.gd.item(HAMMER).goldenId]);
assert.equal(itemIds(ps).filter((id) => id === FILLER).length, 13);
h.invariants();
});
test('Picture Scroll: a full hand and temp without promotion keeps the refused scroll and target equipment', (t) => {
const { h, ps, target, art, cast } = prep(t, { copies: 1 });
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
const before = ps.privateView();
assert.equal(cast().error, 'HAND_FULL');
assert.ok(ps.find(art.uid));
assert.deepEqual(target.items.map((p) => p.id), [HAMMER]);
assert.deepEqual(ps.privateView(), before);
h.invariants();
});
+60
View File
@@ -0,0 +1,60 @@
// test/render/fxchill.test.js — the 'chill' fx of coldWind (render/fx/simfx.js + zones.js snowfall), PR #386 by
// @leiming2333: 谢拉格's 盟约寒风 blows a blizzard UP the field (the official direction, the owner's confirmation of
// 2026-10-08) with a stronger cold flash; an operator's own coldWind (灵知 S3, 麦哲伦 S1's pulse every 3 s — emitted with
// the unit's `id`) keeps the light downward flurry and the softer flash. Against the headless fake PIXI
// (test/render/fakepixi.js), like fxchen3wave.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const W = 1600, H = 900;
const cam = presetCamera('normal', { width: W, height: H });
function makeFx(quality = 'high') {
const P = fake.P;
const ctx = fakeViewCtx(P);
const unit = { id: 7, x: 5, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {} };
return new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: (id) => (id === unit.id ? unit : null),
screenSize: () => ({ width: W, height: H }), fieldTop: () => 120,
});
}
const live = (fx) => fx.parts.filter((p) => p.sp && p.sp.visible !== false);
describe('coldWind: 谢拉格\'s 盟约寒风 blows up the field, an operator\'s own cold keeps the light flurry', () => {
test('the bond\'s gust (the device emits no anchor unit): 48 flakes and 10 streaks rising from the bottom, the stronger flash', () => {
const fx = makeFx();
// server/sim/content/devices.js startColdWind: the field centre, the player, the cold stacks, the cold duration
fx.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
const ps = live(fx);
assert.equal(ps.length, 58, '48 flakes + 10 streaks at quality high');
assert.ok(ps.every((p) => p.vy < 0), 'every particle rises (screen y grows downward)');
assert.ok(ps.every((p) => p.vx > 0), 'on a crosswind');
assert.ok(ps.every((p) => p.y >= H * 0.55 - 1e-9), 'lifted off the lower part of the screen (some start below it)');
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 10, 'gust streaks');
assert.deepEqual([fx.tintA, fx.tintDur], [0.45, 1]);
const low = makeFx('low');
low.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
assert.equal(live(low).length, 26, 'quality low: 22 flakes + 4 streaks');
});
test('an operator\'s own coldWind (灵知 S3, 麦哲伦\'s pulse: `id` = the unit): the light flurry drifting down, the softer flash', () => {
const fx = makeFx();
fx.simFx('coldWind', 5, 10, { x: 5, y: 10, id: 7, n: 2 });
const ps = live(fx);
assert.equal(ps.length, 26);
assert.ok(ps.every((p) => p.vy > 0), 'falling as before');
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 0, 'no gust streaks');
assert.deepEqual([fx.tintA, fx.tintDur], [0.35, 0.8]);
});
});
+157
View File
@@ -0,0 +1,157 @@
// The facing wheel's keyboard in the mock match (headless Chrome, the DOM field — no art / WebGL needed): GitHub #394 /
// PR #395 (the drag / Tab flow of this file is the PR's, by @Xinglan233). Enter follows the focus while the wheel is
// open — the wheel takes the focus when it opens, so arrows + Enter confirm (also with a HUD button focused before the
// drop); Tab reaches ✕ first and Enter on it cancels; Enter on any other button (准备, 设置 …) does nothing; Esc cancels.
// Unit side: test/ui/feedback5-hotkeys.test.js (facingEnter).
// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/facing.e2e.test.js
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('facing wheel keyboard (mock match, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv, browser;
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 });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
});
after(async () => { await browser?.close(); await srv?.close(); });
async function setup(t) {
const page = await browser.newPage();
t.after(() => page.close());
await page.setViewport({ width: 1920, height: 1080 });
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.ff-piece');
const state = () => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const moves = () => page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([type]) => type === 'g.move'));
const sent = () => page.evaluate(() => globalThis.__MOCK__.S().requests.map(([type]) => type));
const initial = await state();
// room under the deploy cap (sell a board operator through the underframe), then a free legal tile
const sell = initial.board.find((p) => p.kind === 'chess' && !p.golden);
await page.click(`.ff-piece[data-uid="${sell.uid}"]`);
await page.waitForSelector('.uframe__btn--sell');
await page.click('.uframe__btn--sell');
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.board.some((p) => p.uid === uid), {}, sell.uid);
const ready = await state();
const piece = ready.hand.find((p) => p?.kind === 'chess');
const tile = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7]].find(([r, c]) => !ready.board.some((p) => p.row === r && p.col === c));
assert.ok(piece && tile, 'an operator and a free legal tile exist');
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const open = async () => {
const from = await center(`.ff-piece[data-uid="${piece.uid}"]`);
const to = await center(`.ff-tile[data-row="${tile[0]}"][data-col="${tile[1]}"]`);
await page.mouse.move(from.x, from.y); await page.mouse.down();
await page.mouse.move(from.x + 20, from.y, { steps: 4 }); // a drag also when the piece re-orients on its own tile
await page.mouse.move(to.x, to.y, { steps: 12 }); await sleep(80); await page.mouse.up();
await page.waitForSelector('.fwheel__dia');
await sleep(200);
};
const preview = async (key) => {
await page.keyboard.press(key);
await page.waitForSelector(`.fwheel.is-${key.slice(5).toLowerCase()}`);
};
const active = () => page.evaluate(() => {
const a = document.activeElement;
return { wheel: !!a?.matches('.fwheel'), cancel: !!a?.closest('.fwheel__cancel'), button: a?.tagName === 'BUTTON' && !a.closest('.fwheel'), cls: a?.className || a?.tagName };
});
const tabUntil = async (want) => {
for (let i = 0; i < 80; i++) {
if (want(await active())) return;
await page.keyboard.press('Tab');
}
assert.fail('Tab never reached the wanted element');
};
const isOpen = () => page.evaluate(() => !!document.querySelector('.fwheel'));
const closed = async () => { await page.waitForFunction(() => !document.querySelector('.fwheel')); await sleep(150); };
return { page, state, moves, sent, errors, ready, piece, tile, open, preview, active, tabUntil, isOpen, closed };
}
test('the wheel takes the focus: arrows + Enter confirm once in all four directions', async (t) => {
const h = await setup(t);
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
await h.open();
assert.equal((await h.active()).wheel, true, 'the wheel has the focus when it opens');
await h.page.keyboard.press('Enter');
await sleep(150);
assert.equal(await h.isOpen(), true, 'Enter with no direction previewed does nothing');
await h.preview(`Arrow${dir[0] + dir.slice(1).toLowerCase()}`);
const count = (await h.moves()).length;
await h.page.keyboard.press('Enter');
await h.closed();
assert.equal((await h.moves()).length, count + 1, 'one intent per confirmation');
assert.deepEqual((await h.moves()).at(-1)[1].dir, dir);
const board = (await h.state()).board.filter((p) => p.uid === h.piece.uid);
assert.deepEqual([board.length, board[0].row, board[0].col, board[0].dir], [1, ...h.tile, dir]);
}
assert.deepEqual(h.errors, []);
});
test('Tab reaches ✕ first and Enter on it cancels; Esc cancels; Space on ✕ stays swallowed', async (t) => {
const h = await setup(t);
await h.open();
await h.preview('ArrowRight');
await h.page.keyboard.press('Tab');
assert.equal((await h.active()).cancel, true, 'the first Tab from the wheel lands on ✕');
await h.page.keyboard.press('Space');
await sleep(150);
assert.equal(await h.isOpen(), true, 'Space does not activate the focused ✕ (no ready / shop keys while choosing)');
await h.page.keyboard.press('Enter');
await h.closed();
assert.deepEqual(await h.moves(), [], 'cancelling sends no deployment');
assert.deepEqual(await h.state(), h.ready, 'the operator stays where it was');
await h.open();
await h.preview('ArrowLeft');
await h.page.keyboard.press('Escape');
await h.closed();
assert.deepEqual(await h.moves(), []);
assert.deepEqual(h.errors, []);
});
test('Enter on another focused button (准备, 设置 …) does nothing while the wheel is open', async (t) => {
const h = await setup(t);
await h.open();
await h.preview('ArrowRight');
const before = await h.sent();
// walk the focus past ✕ to the HUD behind the wheel: every button reached gets an Enter
const seen = new Set();
for (let i = 0; i < 80 && seen.size < 6; i++) {
await h.page.keyboard.press('Tab');
const a = await h.active();
if (!a.button || seen.has(a.cls)) continue;
seen.add(a.cls);
await h.page.keyboard.press('Enter');
await sleep(120);
assert.equal(await h.isOpen(), true, `Enter on ${a.cls} keeps the wheel open`);
assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, `Enter on ${a.cls} opens nothing`);
}
assert.ok(seen.size >= 2, `Tab reached HUD buttons behind the wheel (${[...seen].join(' | ')})`);
assert.ok([...seen].some((c) => /readybtn|gm__gear/.test(c)), '准备 or 设置 among them');
assert.deepEqual(await h.sent(), before, 'no intent of any kind (no g.move, no ready, no shop)');
assert.equal((await h.state()).ready, h.ready.ready, 'still not ready');
await h.page.keyboard.press('Escape');
await h.closed();
assert.deepEqual(await h.moves(), []);
assert.deepEqual(h.errors, []);
});
test('a HUD button focused before the drop does not swallow the confirming Enter', async (t) => {
const h = await setup(t);
await h.page.focus('.gm__gear');
await h.open();
assert.equal((await h.active()).wheel, true);
await h.preview('ArrowDown');
await h.page.keyboard.press('Enter');
await h.closed();
assert.equal((await h.moves()).length, 1);
assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, 'the settings did not open');
assert.deepEqual(h.errors, []);
});
});
+54 -1
View File
@@ -11,7 +11,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
actionForKey, captureHotkey, facingSwallows, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
actionForKey, captureHotkey, facingSwallows, facingEnter, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
} from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -201,6 +201,59 @@ describe('the one lookup (actionForKey) and shortcutFor', () => {
assert.equal(facingSwallows({ key: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
});
// GitHub #394 / PR #395: Enter followed the previewed direction whatever had the focus — Tab to 准备 / 设置 / ✕ and
// Enter deployed. A tiny element tree whose closest() understands the selector forms facingEnter uses.
const el = (tag, attrs = {}, parent = null) => {
const self = {
tagName: tag.toUpperCase(), attrs, parent,
matches(sel) {
return sel.split(',').map((s) => s.trim()).some((s) => {
const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+(?:="[^"]*")?\])*)$/.exec(s);
if (!m) return false;
if (m[1] && m[1].toUpperCase() !== self.tagName) return false;
const classes = String(attrs.class || '').split(/\s+/);
if (m[2] && !m[2].slice(1).split('.').every((c) => classes.includes(c))) return false;
for (const [, k, v] of m[3].matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
if (!(k in attrs) || (v !== undefined && String(attrs[k]) !== v)) return false;
}
return !!(m[1] || m[2] || m[3]);
});
},
closest(sel) { for (let n = self; n; n = n.parent) if (n.matches(sel)) return n; return null; },
};
return self;
};
test('the facing wheel\'s Enter follows the focus: ✕ cancels, another control does nothing, elsewhere it commits', () => {
const body = el('body');
const wheel = el('div', { class: 'fwheel', role: 'dialog', tabindex: '-1' }, body);
const cancel = el('button', { class: 'fwheel__cancel' }, el('div', { class: 'fwheel__dia' }, wheel));
const cancelText = el('span', {}, cancel);
const ready = el('button', { class: 'ready' }, el('header', {}, body));
const gear = el('button', { class: 'gm__gear' }, body);
const card = el('div', { role: 'button', tabindex: '0' }, body);
const link = el('a', { href: '#' }, body);
const field = el('input', { type: 'text' }, body);
for (const chosen of [true, false]) {
assert.equal(facingEnter({ key: 'Enter', target: cancel }, chosen), 'cancel', 'Enter on ✕ cancels (a direction previewed or not)');
assert.equal(facingEnter({ key: 'Enter', target: cancelText }, chosen), 'cancel');
for (const t of [ready, gear, card, link, field]) assert.equal(facingEnter({ key: 'Enter', target: t }, chosen), 'swallow', 'another control does nothing');
}
assert.equal(facingEnter({ key: 'Enter', target: wheel }, true), 'commit', 'the wheel has the focus when it opens: Enter confirms');
assert.equal(facingEnter({ key: 'Enter', target: body }, true), 'commit');
assert.equal(facingEnter({ key: 'Enter', target: null }, true), 'commit');
assert.equal(facingEnter({ key: 'Enter', target: wheel }, false), 'swallow', 'nothing to confirm yet');
assert.equal(facingEnter({ key: ' ', target: cancel }, true), null, 'not Enter: Space stays with facingSwallows');
assert.equal(facingEnter({ key: 'ArrowUp', target: body }, true), null);
assert.equal(facingEnter(null, true), null);
});
test('the wheel wires it: Enter through facingEnter, the wheel focusable and focused when it opens', () => {
const src = read('public/js/ui/facingWheel.js');
assert.match(src, /const enter = facingEnter\(e, !!L\.dir\);/);
assert.doesNotMatch(src, /e\.key === 'Enter' && L\.dir/, 'no Enter that ignores the focus');
assert.match(src, /<div ref=\$\{rootRef\} tabIndex="-1" class=\$\{cx\('fwheel'/);
assert.match(src, /rootRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
});
});
describe('capturing a new key (the settings wait for a key)', () => {
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { startServer } from '../../server/index.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { spawnMapChar } from '../../server/sim/content/tokens.js';
const chrome = process.env.CHROME_PATH;
const enabled = process.env.SP_E2E === '1' && chrome && existsSync(chrome);
test('user Given a real battle with strategy medics When clicking units and closing details Then attack tiles follow selection',
{ skip: !enabled && 'Set SP_E2E=1 and CHROME_PATH', timeout: 60000 }, async () => {
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const puppeteer = (await import('puppeteer-core')).default;
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'] });
try {
for (const medicId of ['char_613_acmedc', 'char_605_cmedic']) {
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], autoFinish: false });
h.step();
assert.ok(spawnMapChar(h.b, h.b.players[0].playerId, medicId));
h.step();
const field = h.b.fieldMeta();
const snapshot = h.b.snapshot();
assert.equal(field.units.length, 2);
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.port}/dev/game-mock.html?render=fallback&shot=1&phase=PREP`);
await page.waitForSelector('.ff-piece');
await page.evaluate(async ({ field, snapshot }) => {
const { store } = await import('/js/store.js');
const { net } = await import('/js/net.js');
const pub = store.get().match.public;
store.patch('match', { public: { ...pub, phase: 'COMBAT', fields: [{ fieldId: field.fieldId, players: [pub.players[0].playerId] }] }, field });
// The local runner emits the snapshot synchronously, before the screen's field-entry effect.
net._emit('b.snap', snapshot);
}, { field, snapshot });
await page.waitForSelector('.ff-unit');
for (let i = 0; i < field.units.length; i++) {
const units = await page.$$('.ff-unit');
await units[i].click();
await page.waitForSelector('.dpanel');
await page.waitForSelector('.ff-tile.is-range');
const lit = await page.$$eval('.ff-tile.is-range', (els) => els.map((e) => [Number(e.dataset.row), Number(e.dataset.col)]));
const u = field.units[i];
if (u.defId === medicId) {
assert.ok(lit.some(([r, c]) => r === 10 && c === 5), 'medic reaches three columns right from the stage tile');
assert.ok(!lit.some(([, c]) => c === 6), 'inactive skill does not extend the base range');
}
// A new wire snapshot must refresh the open overlay, not just the unit sprite.
await page.evaluate(async ({ snapshot, id }) => {
const { net } = await import('/js/net.js');
net._emit('b.snap', { ...snapshot, units: snapshot.units.map((v) => v[0] === id ? [v[0], v[1] + 1, ...v.slice(2)] : v) });
}, { snapshot, id: u.id });
await page.waitForFunction((before) => {
const now = [...document.querySelectorAll('.ff-tile.is-range')].map((e) => [Number(e.dataset.row), Number(e.dataset.col)]);
return now.length === before.length && now.every(([r, c]) => before.some(([br, bc]) => br === r && bc + 1 === c));
}, {}, lit);
await page.evaluate(async () => {
const { store } = await import('/js/store.js');
store.patch('match', { public: { ...store.get().match.public, phase: 'SETTLE' } });
// Allow Preact's after-paint phase effects to run before asserting retained UI.
await new Promise((resolve) => setTimeout(resolve, 250));
});
assert.ok(await page.$('.dpanel'), 'same-field settlement keeps the inspected card');
assert.ok(await page.$('.ff-tile.is-range'), 'same-field settlement keeps its range');
await page.click('.dpanel__close');
await page.waitForFunction(() => !document.querySelector('.ff-tile.is-range'));
await page.evaluate(async (snapshot) => {
const { net } = await import('/js/net.js');
net._emit('b.snap', snapshot);
}, snapshot);
}
await (await page.$$('.ff-unit'))[0].click();
await page.waitForSelector('.ff-tile.is-range');
await page.evaluate(async ({ field, snapshot }) => {
const { store } = await import('/js/store.js');
const { net } = await import('/js/net.js');
const fieldId = `${field.fieldId}:next`;
store.patch('match', { field: { ...field, fieldId } });
net._emit('b.snap', { ...snapshot, fieldId });
}, { field, snapshot });
await page.waitForFunction(() => !document.querySelector('.dpanel') && !document.querySelector('.ff-tile.is-range'));
assert.deepEqual(errors, []);
await page.close();
}
} finally {
await browser.close();
await server.close();
}
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { inspectRange } from '../../public/js/screens/game/range.js';
import { renderInfo } from '../../public/js/render/app/info.js';
import { rangeTiles } from '../../public/js/ui/facing.js';
import { unitStatsEntry } from '../../shared/protocol.js';
import { makeBattle } from '../helpers/battleHarness.js';
const read = (name) => JSON.parse(readFileSync(new URL(`../../data/${name}.json`, import.meta.url), 'utf8'));
const chess = read('chess'), tokens = read('tokens');
const getChess = (id) => chess[id];
const medic = (id = 'char_613_acmedc', extra = {}) => renderInfo({ id: 71, defId: id, kind: 'token', side: 'ally', x: 5, y: 9, dir: 'UP', ...extra });
const args = (unit, extra = {}) => ({ target: { kind: 'unit', unit }, detail: { type: 'token', token: tokens[unit.defId] },
getChess, field: { prep: true }, ...extra });
test('user Given Jessica inspected beside her shield When it expires Then her open range turns back with her', () => {
const h = makeBattle({ autoFinish: false, units: [
{ uid: 1, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_1034_jesca2', skillIndex: 0 }, row: 10, col: 4 },
{ uid: 2, kind: 'token', tokenId: 'token_10032_jesca2_jckshd', ownerUid: 1, row: 11, col: 4 },
] });
h.step();
const u = h.unit(1);
const unit = h.b.fieldMeta().units.find((v) => v.id === u.id);
const input = { target: { kind: 'unit', unit }, detail: { type: 'chess', chess: u.def.raw, diy: true }, getChess, field: {} };
const tiles = () => {
const r = inspectRange({ ...input, live: unitStatsEntry(u, u.s), snapshot: h.b.snapshot().units.find((v) => v[0] === u.id) });
return rangeTiles(r.grid, r.row, r.col, r.dir);
};
assert.deepEqual(tiles(), [[10, 4], [11, 4], [12, 4]]);
h.b.retreat(h.unit(2), { reason: 'expired', permanent: true });
h.run(0.5);
assert.deepEqual(tiles(), [[10, 4], [10, 5], [10, 6]]);
});
test('user Given strategy medics When inspecting them Then their healing tiles rotate on the field', () => {
for (const id of ['char_613_acmedc', 'char_605_cmedic']) {
const r = inspectRange(args(medic(id)));
assert.equal(r.grid.length, 12);
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
assert.equal(tiles.length, 12);
assert.ok(tiles.some(([row, col]) => row === 12 && col === 4));
assert.ok(!tiles.some(([row]) => row === 8));
}
});
test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => {
const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } }));
assert.equal(r.grid.length, 18);
assert.deepEqual([r.row, r.col, r.dir], [10, 8, 'UP']);
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
assert.ok(tiles.some(([row, col]) => row === 15 && col === 9));
assert.ok(!tiles.some(([, col]) => col === 5));
});
test('user Given an enemy, scouted hand or absent battle unit When inspecting Then no attack tiles appear', () => {
for (const extra of [{ side: 'enemy' }, { area: 'hand' }, { area: 'temp' }, { kind: 'device' }]) {
assert.equal(inspectRange(args(medic(undefined, extra))), null);
}
assert.equal(inspectRange(args(medic(), { field: { prep: false } })), null);
assert.equal(inspectRange(args(medic(), { field: {}, snapshot: [71, 5, 9, 0, 200] })), null);
assert.equal(inspectRange(args(medic(), { showPrep: true })), null);
assert.equal(inspectRange(args(medic(), { live: { range: [] } })), null);
});
test('user Given an own board operator When its details open Then its range uses the board direction and clears off board', () => {
const piece = { kind: 'chess', id: 'chess_char_1_01_a', uid: 5, dir: 'LEFT' };
const input = { target: { kind: 'piece', uid: 5 }, detail: { type: 'chess', chess: chess[piece.id], piece },
pieces: new Map([[5, { piece, area: 'board', row: 11, col: 2 }]]), showPrep: true, getChess };
const r = inspectRange(input);
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
assert.ok(tiles.every(([, col]) => col >= 0 && col <= 2));
assert.ok(tiles.some(([, col]) => col === 0));
assert.equal(inspectRange({ ...input, pieces: new Map() }), null);
assert.equal(inspectRange({ ...input, target: { kind: 'chess', id: piece.id } }), null);
assert.equal(inspectRange({ ...input, showPrep: false }), null);
});
test('a scouted prep board (the server\'s m.field): the board operator draws its range, the bench (area hand / temp) none', async () => {
const { makeMatch, give, giveItem, legalTileFor, chessOfTier } = await import('../match/harness.js');
const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true }).start();
h.toPrep(1);
const m = h.m;
const b = h.ps('p_1');
m.handle('p_1', { t: 'g.ready', ready: false });
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) if (p.kind === 'chess') b.returnCopies(p);
b.board.clear(); b.hand.fill(null); b.temp.fill(null); b.recompute();
const [id1, id2, id3] = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
const deployed = give(m, b, id1);
const [r, c] = legalTileFor(m, b, id1);
m.handle('p_1', { t: 'g.move', uid: deployed.uid, to: { area: 'board', row: r, col: c }, dir: 'UP' });
const held = give(m, b, id2);
while (b.hand.some((x) => x == null)) giveItem(m, b, 'chess_item_6_09_e_a'); // a full hand keeps the temp piece in temp
const waiting = give(m, b, id3, 'temp');
m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' });
const field = h.lastTo('p_0', 'm.field');
const range = (piece) => {
const unit = renderInfo(field.units.find((u) => u.uid === piece.uid));
return inspectRange({ target: { kind: 'unit', unit }, detail: { type: 'chess', chess: chess[piece.id] }, field, getChess });
};
const own = range(deployed);
assert.deepEqual([own.row, own.col, own.dir], [r, c, 'UP']);
assert.equal(range(held), null, 'a held operator (area hand) has no field range');
assert.equal(range(waiting), null, 'nor one in the 临时整备区 (area temp)');
m.dispose();
});
+2 -1
View File
@@ -23,7 +23,8 @@ globalThis.fetch = async (url) => {
}
};
const { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js');
const { TokenDetail, summonDeployHint, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { tokenVariantFor } = await import('../../public/js/ui/gameLogic/loadout.js');
const { indexPieces } = await import('../../public/js/ui/gameLogic.js');
const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js');
const simTokens = await import('../../server/sim/content/tokens.js');
+34 -5
View File
@@ -281,16 +281,45 @@ describe('§20.15 the bonds: views + live layers', () => {
assert.deepEqual(mate.bonds.map((b) => [b.bondId, b.layers]), [['sargonShip', 44], ['kjeragShip', 7]], 'THEIR bonds, THEIR live count');
assert.deepEqual(playerBonds({ pub, priv: PRIV, myId: ME, ownerId: 'p3', live }).map((b) => b.layers), [120]);
});
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list (no hand)', () => {
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list; a scout\'s bench goes to hand / temp', () => {
const field = { fieldId: 'n:p2', prep: true, units: [
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a' },
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok' },
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a', area: 'board' },
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok', area: 'board' },
{ id: 3, kind: 'op', side: 'ally', ownerId: 'p3', defId: 'chess_other' },
{ id: 4, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'enemy_1' },
{ id: 5, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_03_a', area: 'hand' },
{ id: 6, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_2_04_a', area: 'temp' },
{ id: 7, kind: 'item', side: 'ally', ownerId: 'p2', defId: 'chess_item_1_01_e_a', area: 'hand' },
] };
assert.deepEqual(ownerBoard(field, 'p2'), { board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [], temp: [] });
assert.deepEqual(ownerBoard(field, 'p2'), {
board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [{ kind: 'chess', id: 'chess_char_1_03_a' }], temp: [{ kind: 'chess', id: 'chess_char_2_04_a' }],
});
// a unit without an area (a battle's, an older server's prep scout) is on the board
assert.deepEqual(ownerBoard({ units: [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_19_a' }] }, 'p2').board, [{ kind: 'chess', id: 'chess_char_1_19_a' }]);
assert.equal(ownerBoard(null, 'p2'), null);
});
test('GitHub #385: a scouted bench counts in the popup\'s 成员 header as in the player\'s own (hand for 远见 / 奇迹 / 投资人, temp never)', () => {
// 远见 (BOARD_AND_DECK, countsHand: the server's 在场 is 2 — 赫默 on the board, 初雪 in the hand; 伊内丝 waits in temp)
// 炎 (BOARD: 在场 1 — 小满; 琳琅诗怀雅 in the hand, 烛煌 in temp)
const u = (id, defId, area) => ({ id, kind: 'op', side: 'ally', ownerId: 'p2', defId, area });
const field = { fieldId: 'n:p2', prep: true, units: [
u(1, 'chess_char_2_02_a', 'board'), u(2, 'chess_char_2_04_a', 'board'),
u(3, 'chess_char_3_14_a', 'hand'), u(4, 'chess_char_3_04_a', 'hand'),
u(5, 'chess_char_4_04_a', 'temp'), u(6, 'chess_char_5_03_a', 'temp'),
] };
const head = (bondId) => {
const v = BondPopup({ bondId, entry: bond(bondId, 0, 0, 0), priv: ownerBoard(field, 'p2'), owner: '阿米娅', onClose() {} });
const h4 = [...walk(v)].find((x) => x.type === 'h4' && /成员/.test(textOf(x)));
const members = [...walk(v)].filter((x) => hasClass(x, 'bpop__member'));
return { head: textOf(h4).replace(/\s+/g, ''), on: members.filter((x) => hasClass(x, 'is-on')).length, owned: members.filter((x) => hasClass(x, 'is-owned')).length };
};
const visi = head('visiShip');
assert.match(visi.head, /^成员2\//, '远见: the board member and the hand member (含整备区), not the temp one');
assert.deepEqual([visi.on, visi.owned], [1, 2], 'only 赫默 is 在场; 初雪 and 伊内丝 are owned');
const yan = head('yanShip');
assert.match(yan.head, /^成员1\//, '炎: the board member only');
assert.deepEqual([yan.on, yan.owned], [1, 2]);
});
test('ownerBoard: under client-side combat the battle on screen\'s operators join (the runner\'s meta predates their deploy)', () => {
const meta = { fieldId: 'b1', local: true, units: [{ id: 90, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'boss' }] };
const ops = [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_09_a' }, { kind: 'op', ownerId: 'p2', defId: 'chess_char_1_03_a' }];
@@ -405,7 +434,7 @@ describe('§20.15 the popup of a card\'s bond chip: the UNIT owner\'s bond (a sh
assert.deepEqual([v.entry.bondId, v.entry.count, v.entry.active, v.entry.layers], ['sargonShip', 2, true, 55], 'the partner\'s count / tier, their live layers');
assert.notEqual(v.priv, PRIV, 'not the viewer\'s pieces');
assert.deepEqual(v.priv.board.map((x) => x.id).sort(), ['chess_char_1_09_a', 'chess_char_1_12_a'], 'the partner\'s operators on the field on screen');
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'their hand is never sent');
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'a battle field sends no bench');
});
test('popupView: your own bond (m.private entry with thresholds, your pieces, no name); a bond not listed → no entry; nothing open → null', () => {
const own = popupView({ open: { id: 'yanShip', ownerId: ME, from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: null });