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:
commit
2492966c4f
32 files changed
+913
-138
No files matched your search
@@ -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
@@ -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]}`]));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user