feedback7: merge fb7-ui-backlog — #229 device tooltips, #238 settings button in lobby / room, #138 准备 folds the shop, #120 in-match spectator roster (cap stays 2), PR #308 capsule counts (+ the shared-field progress fix), PR #333 empty new shop slot on 调度中心 upgrade
- Conflicts: the stats entry (merged fb7-stats) and the settings button both sit in the lobby / room top bars — both kept;
public/js/ui/settings.js (#321 diagnostics, voiceLang, SettingsButton); docs/PLAYING.md table rows resolved line by
line against the merge base (#368, #354, #333 edits kept); docs/SIM.md / design/network.md snapshot field lists
(resolved + ammo / wolves / neg / stand / standCut); design/client.md Screens line (#368's AI switch kept).
- Grok review: MERGE AFTER FIXES — the shared-field capsule finding fixed on the branch (1a880699).
- Golden: roster 23, bonds 2, fields 11, matches 18, diy 11 — #308 totals / snapshots and #333's shop RNG (union OK);
broad node --test 5473 (5472 pass, 0 fail, 1 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
commit
dc609ea13f
71 files changed
+3016
-806
No files matched your search
@@ -490,7 +490,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
|
||||
assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
|
||||
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
|
||||
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
|
||||
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
|
||||
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, resolved\?, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
|
||||
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
|
||||
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
|
||||
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
|
||||
|
||||
@@ -56,7 +56,9 @@ test('matches: the 补位 match\'s human seat fields stand-ins (listed per round
|
||||
assert.ok(dg, 'scenario present');
|
||||
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
|
||||
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
|
||||
assert.ok(new Set(fielded).size >= 3, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
|
||||
// (≥ 2 since a level-up leaves its new slot empty — GitHub #332, PR #333 by @2321Robin: this match's shop rolls, and with
|
||||
// them which stand-ins the human seat fields, part from the stream in which the upgrade drew a card into the new slot)
|
||||
assert.ok(new Set(fielded).size >= 2, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
|
||||
for (const f of fielded) {
|
||||
const [id, charId] = f.split('→');
|
||||
const c = CHESS[`chess_char_${id}`];
|
||||
|
||||
@@ -729,12 +729,12 @@
|
||||
"ticks": 1371,
|
||||
"reason": "cleared",
|
||||
"killed": 53,
|
||||
"total": 53,
|
||||
"total": 16,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 18,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":53,"total":53,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":30262,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":53,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":30262,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_a",2,null,2334,1,7,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_3_16_a",1,null,2173,1,8,0,0,0,0,0,0],
|
||||
@@ -752,7 +752,7 @@
|
||||
"hooks": [1,64,1371,110,110,180,180,0,53,53,13,10,0,7978,38,0,53,0,1,53,0,0,0],
|
||||
"events": {"atk":148,"deploy":9,"die":53,"dmg":180,"engage":7,"fx":25,"skill":23,"spawn":64,"status":59},
|
||||
"fx": {"copy":1,"dp":1,"sleep":3,"strike":2,"summon":16,"volley":2},
|
||||
"snaps": "e32ad9c0 c651ddcb ddac8e0f ee7dddc6 74ae6f3c"
|
||||
"snaps": "5ea42d46 9b9df7e7 90ea3365 87d00bc2 cdcf7237"
|
||||
},
|
||||
"bond-maniShip-high": {
|
||||
"about": "调和 high: tier 1 layers 999; chess_char_5_19_b chess_char_5_17_b chess_char_6_11_b chess_char_1_03_a chess_char_2_04_a chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b token_10030_mlyss_wtrman",
|
||||
@@ -1314,12 +1314,12 @@
|
||||
"ticks": 2730,
|
||||
"reason": "cleared",
|
||||
"killed": 66,
|
||||
"total": 66,
|
||||
"total": 21,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 48,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":66,"total":66,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":155889,"bossDmg":0,"heal":2860,"deaths":3,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":66,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":155889,"bossDmg":0,"heal":2860,"deaths":3,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,6,15,28905,0,0,35,2],
|
||||
["p1",2,"op","chess_char_1_08_b",1,"uniequip_002_texas",0,0,7,4,6105,3310,62,24,3],
|
||||
@@ -1344,7 +1344,7 @@
|
||||
"hooks": [1,79,2730,216,216,542,541,519,69,69,33,31,0,9330,93,15,66,0,1,79,0,0,1],
|
||||
"events": {"atk":313,"deploy":10,"die":69,"dmg":541,"engage":8,"fx":142,"heal":12,"skill":64,"spawn":77,"status":201},
|
||||
"fx": {"aoe":11,"crit":6,"dodge":1,"dp":3,"sp":3,"substitute":2,"summon":21,"swap":1,"weightless":3,"zone":91},
|
||||
"snaps": "0d5fce4b 008db012 69aeb33a 4ce7c8d2 b1642f13 224f1fa4 94c69830 28a9268a 8c7e5ae4 7bef3667"
|
||||
"snaps": "afb0ceff f21761d4 d7ea90d0 794e64bc 49e2ff22 cedf8fb7 032b46b6 d8e9a02c bff04f67 e4b0ff92"
|
||||
},
|
||||
"bond-steadShip-low": {
|
||||
"about": "坚守 low: tier 1 layers 1; chess_char_1_02_a chess_char_1_10_a chess_char_4_11_a chess_char_4_04_a chess_char_4_07_a chess_char_4_09_a chess_char_4_06_a chess_char_4_10_a token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
|
||||
+33
-33
@@ -1443,12 +1443,12 @@
|
||||
"ticks": 6115,
|
||||
"reason": "cleared",
|
||||
"killed": 106,
|
||||
"total": 223,
|
||||
"total": 99,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 526,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":106,"total":223,"perfect":false,"leaks":117,"leaksUncounted":0,"coins":0,"dmg":230330,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":106,"total":99,"perfect":false,"leaks":117,"leaksUncounted":0,"coins":0,"dmg":230330,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_chyue",2648.69,1,218,9,6652,5406,0,51,10],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_chyue",0,0,208,7,11931,8055,0,43,5],
|
||||
@@ -1477,7 +1477,7 @@
|
||||
"hooks": [1,257,6115,815,815,888,881,0,132,249,94,94,0,9703,3,76,223,117,1,132,0,0,7],
|
||||
"events": {"atk":815,"deploy":31,"die":132,"dmg":881,"engage":12,"fx":89,"leak":117,"skill":188,"spawn":238,"status":24},
|
||||
"fx": {"aoe":33,"buff":1,"dodge":7,"summon":48},
|
||||
"snaps": "3bbe012e d31f4ea8 cb8f67bb 242b9a30 f3ce30d3 e9830293 cf0729b3 988d2c83 02f42254 4f557eea 8e95bd4c 83c92c27 cefc8d7b 213319ae 9f9a26fc f4d90af3 71c1e322 dc08f466 512b9fd0 d6074647 bfccfff2"
|
||||
"snaps": "1cb8ac18 8da946b7 790e7a4a f39b06ca 2e66dea7 fc31cf4a 3bb31ee9 5259691f e5517687 117f3c7b f499ef53 72bad6b7 d2c5d36a 78b1823c a2ed3107 318dd5e1 39bf29d0 4cdd1f36 8280f0cc 423d96fb b1d5dadb"
|
||||
},
|
||||
"diy-037": {
|
||||
"about": "5_diy1_a=huang/S1 5_diy2_a=huang/S2 5_diy1_a=huang/S3 5_diy2_b=huang/S1 5_diy1_b=huang/S2 5_diy2_b=huang/S3 6_diy1_b=chyue/S1/002_chyue 6_diy2_b=chyue/S2/002_chyue 6_diy1_b=chyue/S3/002_chyue 6_diy2_b=chyue/S1/003_chyue 6_diy1_b=chyue/S2/003_chyue 6_diy2_b=chyue/S3/003_chyue",
|
||||
@@ -1612,12 +1612,12 @@
|
||||
"ticks": 6037,
|
||||
"reason": "cleared",
|
||||
"killed": 15,
|
||||
"total": 130,
|
||||
"total": 99,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 615,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":15,"total":130,"perfect":false,"leaks":115,"leaksUncounted":0,"coins":0,"dmg":611305,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":15,"total":99,"perfect":false,"leaks":115,"leaksUncounted":0,"coins":0,"dmg":611305,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shwaz",1815,1,5,6,30948,0,0,44,8],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shwaz",1815,1,7,3,48356,0,0,42,2],
|
||||
@@ -1644,7 +1644,7 @@
|
||||
"hooks": [1,146,6037,633,633,624,624,0,19,134,66,66,0,21155,181,8,130,115,1,19,0,0,0],
|
||||
"events": {"atk":633,"deploy":14,"die":19,"dmg":624,"engage":12,"fx":201,"leak":115,"skill":132,"spawn":144,"status":127},
|
||||
"fx": {"crit":189,"summon":12},
|
||||
"snaps": "38c74c5d e93e1fbc 17213ca8 9e5dbf4f aeb96f48 96b6b90b 4ba7be37 44efb9e4 36e8665c 4b6a1bb6 b75f695f 8f4aed34 d37bd760 f91ca2dd 2be01f90 6af5eeab addcfefb d9063e6f e5425fc1 63b87c62 54fc399d"
|
||||
"snaps": "9065d270 13f6ff85 b2a5c04c b102dd18 6e22ab90 6a03313c 44e746bf 098c8b1a f9ccc1cb c41a9dc4 adad77f1 fd868c79 ca018d6f 5405ea3e 17fad043 58ce5efb f52ab9b6 cdb17a71 9e5eb1f7 9b80db2e e8b19910"
|
||||
},
|
||||
"diy-041": {
|
||||
"about": "6_diy1_b=shwaz/S1/002_shwaz 6_diy2_b=shwaz/S2/002_shwaz 6_diy1_b=shwaz/S3/002_shwaz 6_diy2_b=shwaz/S1/003_shwaz 6_diy1_b=shwaz/S2/003_shwaz 6_diy2_b=shwaz/S3/003_shwaz 5_diy1_a=bgsnow/S1 5_diy2_a=bgsnow/S2 5_diy1_a=bgsnow/S3 5_diy2_b=bgsnow/S1 5_diy1_b=bgsnow/S2 5_diy2_b=bgsnow/S3",
|
||||
@@ -2269,12 +2269,12 @@
|
||||
"ticks": 6837,
|
||||
"reason": "cleared",
|
||||
"killed": 43,
|
||||
"total": 205,
|
||||
"total": 99,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":43,"total":205,"perfect":false,"leaks":162,"leaksUncounted":0,"coins":0,"dmg":375046,"bossDmg":0,"heal":2958,"deaths":16,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":43,"total":99,"perfect":false,"leaks":162,"leaksUncounted":0,"coins":0,"dmg":375046,"bossDmg":0,"heal":2958,"deaths":16,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,5,1,5895,2960,0,8,2],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,9,0,5444,2960,0,4,1],
|
||||
@@ -2302,7 +2302,7 @@
|
||||
"hooks": [1,227,6837,648,648,742,742,677,59,221,13,15,0,10521,812,69,205,162,1,59,0,0,0],
|
||||
"events": {"atk":648,"deploy":19,"die":59,"dmg":688,"engage":12,"fx":121,"leak":162,"skill":33,"spawn":220,"status":42},
|
||||
"fx": {"aoe":2,"crit":2,"shield":14,"summon":41,"thorns":62},
|
||||
"snaps": "e7738144 fa178bf1 4f3d484b b5d03ce8 afe1d843 99e0ea61 70f20deb b5ca27cc dc0aba99 af1b025f cfa5c424 f4e158c7 350c8cd1 69bf60f5 90608560 b84e08e9 2fd4f2c5 a130198a 00871290 afb866ce cc73d72e 8837eef9 5010bcac"
|
||||
"snaps": "e7738144 151f13c6 b636a6a5 d0703ed4 852ec2b3 6586b7a9 ece72287 c18ee8a6 f5803b7b d36bafc4 087320ac ed30204b 60b9e874 e2667db9 b7531c0a 91b4ff9b cb45d547 3210802b 095c2645 37708498 6ec41b79 fa8c03c9 695daa3e"
|
||||
},
|
||||
"diy-057": {
|
||||
"about": "5_diy1_b=judge/S1/002_judge 5_diy2_b=judge/S2/002_judge 5_diy1_b=judge/S3/002_judge 5_diy2_b=judge/S1/003_judge 5_diy1_b=judge/S2/003_judge 5_diy2_b=judge/S3/003_judge 6_diy1_a=judge/S1 6_diy2_a=judge/S2 6_diy1_a=judge/S3 6_diy2_b=judge/S1 6_diy1_b=judge/S2 6_diy2_b=judge/S3",
|
||||
@@ -2518,12 +2518,12 @@
|
||||
"ticks": 5825,
|
||||
"reason": "cleared",
|
||||
"killed": 17,
|
||||
"total": 117,
|
||||
"total": 75,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":17,"total":117,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":260434,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":17,"total":75,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":260434,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,103,2,8464,5596,0,19,4],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,2798,1,142,0,8787,5596,0,20,0],
|
||||
@@ -2550,7 +2550,7 @@
|
||||
"hooks": [1,142,5825,388,388,424,424,0,38,138,21,28,0,5293,0,35,117,100,1,42,0,0,0],
|
||||
"events": {"atk":388,"deploy":23,"die":38,"dmg":424,"engage":12,"fx":18,"leak":100,"skill":42,"spawn":131,"status":74},
|
||||
"fx": {"revive":4,"summon":14},
|
||||
"snaps": "638e6eb2 31544512 a15b078a 17800483 1b7dabbb ce2f306c 19062081 3a041721 39afa7a1 006a4df8 eae507b7 1fba63a7 bf273699 69d9619c 665866c4 88e8e4db 455dea8e 6e7ea6b6 95f2c90c 9a23a08a"
|
||||
"snaps": "638e6eb2 80afea17 3d1f4331 fb3d8acf 99046faa 8da15fd7 000f36ff b76690fc 2ea4822f 617fe41d ddad08cd 9389f818 e1357674 c10e8abd b4f4dd6b d6d77cf4 1a30174a 0af2ac29 d34b557b 112056b4"
|
||||
},
|
||||
"diy-063": {
|
||||
"about": "6_diy1_a=lessng/S1 6_diy2_a=lessng/S2 6_diy1_a=lessng/S3 6_diy2_b=lessng/S1 6_diy1_b=lessng/S2 6_diy2_b=lessng/S3 6_diy1_b=lessng/S1/002_lessng 6_diy2_b=lessng/S2/002_lessng 6_diy1_b=lessng/S3/002_lessng 6_diy2_b=lessng/S1/003_lessng 6_diy1_b=lessng/S2/003_lessng 6_diy2_b=lessng/S3/003_lessng",
|
||||
@@ -2911,12 +2911,12 @@
|
||||
"ticks": 6151,
|
||||
"reason": "cleared",
|
||||
"killed": 32,
|
||||
"total": 126,
|
||||
"total": 75,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":32,"total":126,"perfect":false,"leaks":94,"leaksUncounted":0,"coins":0,"dmg":794494,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":32,"total":75,"perfect":false,"leaks":94,"leaksUncounted":0,"coins":0,"dmg":794494,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_phenxi",1,1,4,2,114557,1795,0,73,3],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_phenxi",1,1,7,3,270924,1795,0,77,7],
|
||||
@@ -2941,7 +2941,7 @@
|
||||
"hooks": [1,145,6151,358,351,1134,5570,0,47,141,14,13,0,257,0,17,126,94,1,48,0,0,0],
|
||||
"events": {"atk":351,"deploy":19,"die":47,"dmg":1120,"engage":12,"fx":167,"leak":94,"skill":27,"spawn":138,"status":40},
|
||||
"fx": {"aoe":104,"ember":1,"revive":1,"shieldBreak":1,"summon":17,"telegraph":1,"zone":42},
|
||||
"snaps": "5e309964 e2b294a4 336cbe3c 6268007a 4bb2b146 9232c146 140b6fe6 cdbce0d8 5c252b94 99c7cbee 5d2d6af5 73dc4682 51ea8af4 342978eb 9036bbd3 105a2926 5cfb9b97 74a9fa53 7bdb61aa 3ec7691c 4a72e70c"
|
||||
"snaps": "cc2cf866 7add2346 2b333f0d 914dde3b 59a20841 00c1e926 2f835686 3d9bb179 c198b367 c151a38c aef71635 1771e992 1ff3d4e1 fdd4c4b8 55318fba d036a5b3 35fe084a ec50b0d0 a981982c aec453e7 db864bb0"
|
||||
},
|
||||
"diy-069": {
|
||||
"about": "6_diy1_b=phenxi/S1 6_diy2_b=phenxi/S2 6_diy1_b=phenxi/S3 6_diy2_b=phenxi/S1/002_phenxi 6_diy1_b=phenxi/S2/002_phenxi 6_diy2_b=phenxi/S3/002_phenxi 6_diy1_b=phenxi/S1/003_phenxi 6_diy2_b=phenxi/S2/003_phenxi 6_diy1_b=phenxi/S3/003_phenxi 6_diy2_b=phenxi/S1/004_phenxi 6_diy1_b=phenxi/S2/004_phenxi 6_diy2_b=phenxi/S3/004_phenxi",
|
||||
@@ -3128,12 +3128,12 @@
|
||||
"ticks": 6098,
|
||||
"reason": "cleared",
|
||||
"killed": 67,
|
||||
"total": 156,
|
||||
"total": 93,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 138,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":67,"total":156,"perfect":false,"leaks":89,"leaksUncounted":0,"coins":0,"dmg":877531,"bossDmg":0,"heal":8248,"deaths":18,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":67,"total":93,"perfect":false,"leaks":89,"leaksUncounted":0,"coins":0,"dmg":877531,"bossDmg":0,"heal":8248,"deaths":18,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_thorns",0,0,131,7,17174,5374,0,31,1],
|
||||
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_thorns",0,0,188,0,11587,8648,587,13,1],
|
||||
@@ -3160,7 +3160,7 @@
|
||||
"hooks": [1,188,6098,435,435,1218,1333,444,85,174,21,21,0,18383,8,44,156,89,1,91,0,0,4],
|
||||
"events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":1558,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9},
|
||||
"fx": {"blink":7,"chen3Wave":1050,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4},
|
||||
"snaps": "f1faec58 cbf72c28 58e3f561 b8182f46 b1f5b0f5 1de38a22 bc7c2a2b f233c949 01d56108 560ee3fc 8ec7a4b9 b95b08ce 1943beb7 c593b60a e179c3a0 4a2ad117 171d3fbd 1e1c4411 efd84577 32339940 33fdecb2"
|
||||
"snaps": "f1faec58 0d0a446f 117456bd e2110e41 645daba7 d3bdb7ad a53991f8 c5889bd7 5cb419cb 56b004a0 ec27a876 4404a392 f7f12c61 63afbbe1 a14b5e85 f3f42e12 efdf3148 2b91f091 b4e7c871 d82451f0 ba0e0a67"
|
||||
},
|
||||
"diy-074": {
|
||||
"about": "5_diy1_b=chen3/S1/002_chen3 5_diy2_b=chen3/S2/002_chen3 5_diy1_b=chen3/S3/002_chen3 6_diy1_a=chen3/S1 6_diy2_a=chen3/S2 6_diy1_a=chen3/S3 6_diy2_b=chen3/S1 6_diy1_b=chen3/S2 6_diy2_b=chen3/S3 6_diy1_b=chen3/S1/002_chen3 6_diy2_b=chen3/S2/002_chen3 6_diy1_b=chen3/S3/002_chen3",
|
||||
@@ -3542,12 +3542,12 @@
|
||||
"ticks": 5842,
|
||||
"reason": "cleared",
|
||||
"killed": 255,
|
||||
"total": 255,
|
||||
"total": 93,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 244,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":255,"total":255,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":623421,"bossDmg":0,"heal":0,"deaths":2,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":255,"total":93,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":623421,"bossDmg":0,"heal":0,"deaths":2,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,1875,1,1,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_diy2_a",1,null,1875,1,3,0,0,0,0,0,0],
|
||||
@@ -3572,7 +3572,7 @@
|
||||
"hooks": [1,269,5842,481,481,2496,1707,0,257,257,24,20,363,19695,0,18,255,0,1,297,0,0,0],
|
||||
"events": {"atk":1996,"deploy":14,"die":257,"dmg":1707,"engage":8,"fx":357,"skill":44,"spawn":267,"status":41},
|
||||
"fx": {"ember":40,"reload":52,"revive":1,"shieldBreak":153,"summon":54,"telegraph":40,"zone":17},
|
||||
"snaps": "30d0dbf8 070d8377 ba316257 264e2c33 72bddae0 d2e7b88a d1fbfd87 0c7c2947 f981c30d 910370b7 10f3a8d7 d122bb52 77e8a4e0 03c43958 9b1b9bcb d0c54363 314530e3 144be113 ea6b1e0b f121eccd"
|
||||
"snaps": "8a9af3b0 2299cb56 47ff12cb ceb52d71 7b0c947c 1dda5aa2 744bfdf3 17a9ef97 281f57a7 831dce1d e2331e7b 4dd31465 c27b30a6 be9f3593 fa841f00 7139d4df d42f76b9 c09066c0 f93531f5 db0cca6a"
|
||||
},
|
||||
"diy-084": {
|
||||
"about": "5_diy1_a=ray/S1 5_diy2_a=ray/S2 5_diy1_a=ray/S3 5_diy2_b=ray/S1 5_diy1_b=ray/S2 5_diy2_b=ray/S3 5_diy1_b=ray/S1/002_ray 5_diy2_b=ray/S2/002_ray 5_diy1_b=ray/S3/002_ray 5_diy2_b=ray/S1/003_ray 5_diy1_b=ray/S2/003_ray 5_diy2_b=ray/S3/003_ray",
|
||||
@@ -3872,12 +3872,12 @@
|
||||
"ticks": 6561,
|
||||
"reason": "cleared",
|
||||
"killed": 280,
|
||||
"total": 345,
|
||||
"total": 105,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":280,"total":345,"perfect":false,"leaks":65,"leaksUncounted":0,"coins":0,"dmg":905052,"bossDmg":0,"heal":61143,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":280,"total":105,"perfect":false,"leaks":65,"leaksUncounted":0,"coins":0,"dmg":905052,"bossDmg":0,"heal":61143,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_b",0,"none",3288,1,5,80,5829,0,0,135,11],
|
||||
["p1",2,"op","chess_char_6_diy2_b",1,"none",3288,1,7,19,1634,0,0,159,1],
|
||||
@@ -3906,7 +3906,7 @@
|
||||
"hooks": [1,360,6561,1458,1458,3548,3548,181,280,345,34,30,79,12661,83,102,345,65,1,280,0,0,0],
|
||||
"events": {"atk":1458,"deploy":12,"die":280,"dmg":3429,"engage":6,"fx":201,"heal":181,"leak":65,"skill":64,"spawn":360,"status":185},
|
||||
"fx": {"aoe":74,"shield":22,"summon":105},
|
||||
"snaps": "c440567a f5cbc1c4 c3321a04 8118710d 1d76d702 8b67ece4 3f62819e 179d97ba cef3a956 cdab6acc c097c581 f7b3c60a 05792236 a8a63e3a 081c51c2 c08a2e26 f4b512af 6aab85bc 79cb0a6a 2e6effc0 f37d30cd e45ba017"
|
||||
"snaps": "c440567a f5cbc1c4 db941e1c 98161e86 7c25733a 412e9f93 345064da 20ef84d4 ddced398 f23be251 68250ac2 38b6fa07 5b3e4205 2d12aba9 a0fd9884 2c69428a 72adafce 537f7ae5 e303df67 2f32732e 76f5e13a a1202906"
|
||||
},
|
||||
"diy-092": {
|
||||
"about": "5_diy1_b=kalts/S1/002_kalts 5_diy2_b=kalts/S2/002_kalts 5_diy1_b=kalts/S3/002_kalts 5_diy2_b=kalts/S1/003_kalts 5_diy1_b=kalts/S2/003_kalts 5_diy2_b=kalts/S3/003_kalts 5_diy1_b=kalts/S1/004_kalts 5_diy2_b=kalts/S2/004_kalts 5_diy1_b=kalts/S3/004_kalts 6_diy1_a=kalts/S1 6_diy2_a=kalts/S2 6_diy1_a=kalts/S3",
|
||||
@@ -4041,12 +4041,12 @@
|
||||
"ticks": 6603,
|
||||
"reason": "cleared",
|
||||
"killed": 49,
|
||||
"total": 171,
|
||||
"total": 93,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":49,"total":171,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":206701,"bossDmg":0,"heal":12234,"deaths":19,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":49,"total":93,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":206701,"bossDmg":0,"heal":12234,"deaths":19,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,161,4,8407,4272,1594,50,3],
|
||||
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,127,5,10691,2678,0,44,2],
|
||||
@@ -4073,7 +4073,7 @@
|
||||
"hooks": [1,198,6603,873,873,913,886,2062,68,190,36,34,0,33122,4712,70,171,122,1,74,0,0,0],
|
||||
"events": {"atk":873,"deploy":25,"die":68,"dmg":886,"engage":12,"fx":125,"leak":122,"skill":70,"spawn":185,"status":327},
|
||||
"fx": {"aoe":13,"dp":40,"ember":6,"revive":5,"shieldBreak":5,"summon":26,"telegraph":6,"zone":24},
|
||||
"snaps": "74b9ab2e fcc89fa5 251ebfac fd5390f8 4210aa71 cfea27b1 61084509 0c2dd520 b4b6fe6b 3299f55e 612cb6a9 da52c793 abdcdde3 6f440b10 8bc0f32b 9dc74346 477b2d00 654418b2 ec216448 d0d7e05c ee88e2cf ec291971"
|
||||
"snaps": "74b9ab2e d85db0c0 d1c3bdb8 be104e4a c91c1023 0e638fa7 bd6273fc 6c18e1be 29b67037 ca353f68 1cf8ad1b ae165281 73511a7a 35610d7c 128ab73f 59029157 cb4fe6d3 36e9cd0b 96b30c61 bca5c5f8 c103a041 c0f160cb"
|
||||
},
|
||||
"diy-096": {
|
||||
"about": "5_diy1_a=closur/S1 5_diy2_a=closur/S2 5_diy1_a=closur/S3 5_diy2_b=closur/S1 5_diy1_b=closur/S2 5_diy2_b=closur/S3 5_diy1_b=closur/S1/002_closur 5_diy2_b=closur/S2/002_closur 5_diy1_b=closur/S3/002_closur 6_diy1_a=closur/S1 6_diy2_a=closur/S2 6_diy1_a=closur/S3",
|
||||
@@ -4874,12 +4874,12 @@
|
||||
"ticks": 6179,
|
||||
"reason": "cleared",
|
||||
"killed": 106,
|
||||
"total": 189,
|
||||
"total": 93,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 8,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":106,"total":189,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":604810,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":106,"total":93,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":604810,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,162,10,98104,3768,0,58,2],
|
||||
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,202,16,43127,3768,0,51,2],
|
||||
@@ -4911,7 +4911,7 @@
|
||||
"hooks": [1,219,6179,587,587,2744,2520,0,124,211,20,18,61,13129,23,42,189,83,1,137,0,0,0],
|
||||
"events": {"atk":973,"deploy":27,"die":128,"dmg":2520,"engage":11,"fx":145,"leak":83,"skill":38,"spawn":208,"status":442},
|
||||
"fx": {"aoe":4,"dash":3,"displace":8,"ember":13,"pull":8,"shieldBreak":36,"summon":36,"takeoff":9,"telegraph":13,"volley":15},
|
||||
"snaps": "39df8f19 dc0b29a2 9e238c17 43dd1e57 a6a34d5f a555ab38 25a0c85d 1ad33783 acbe4560 13b28161 da34a727 b95db8d6 23e509f7 faaca8f2 9f5d7dc2 a8cc015f 4ced24ff 3dece084 287324b5 6dbabf08 a99101bd"
|
||||
"snaps": "216e6311 22bbd174 e2861a2f db9fa2cb 9ccc6a30 fe834987 143b56d4 3e1bf123 0a9fb02b eb5b35d6 033fff75 60f27492 bf703ce7 93e8e5aa eb86edae eeb8fb64 a361acb7 20b6a8cd f300097c f0f3ad12 0ab29755"
|
||||
},
|
||||
"diy-115": {
|
||||
"about": "5_diy1_b=ebnhlz/S1/002_ebnhlz 5_diy2_b=ebnhlz/S2/002_ebnhlz 5_diy1_b=ebnhlz/S3/002_ebnhlz 5_diy2_b=ebnhlz/S1/003_ebnhlz 5_diy1_b=ebnhlz/S2/003_ebnhlz 5_diy2_b=ebnhlz/S3/003_ebnhlz 5_diy1_b=ebnhlz/S1/004_ebnhlz 5_diy2_b=ebnhlz/S2/004_ebnhlz 5_diy1_b=ebnhlz/S3/004_ebnhlz 6_diy1_a=ebnhlz/S1 6_diy2_a=ebnhlz/S2 6_diy1_a=ebnhlz/S3",
|
||||
@@ -4926,12 +4926,12 @@
|
||||
"ticks": 6168,
|
||||
"reason": "cleared",
|
||||
"killed": 154,
|
||||
"total": 232,
|
||||
"total": 96,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":154,"total":232,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":511834,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":154,"total":96,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":511834,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_ebnhlz",1570,1,10,4,57606,0,0,47,5],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ebnhlz",1570,1,11,7,31893,0,0,29,0],
|
||||
@@ -4978,7 +4978,7 @@
|
||||
"hooks": [1,273,6168,461,461,2423,2548,0,164,261,49,47,0,30421,0,28,232,78,1,164,0,0,0],
|
||||
"events": {"atk":461,"deploy":41,"die":183,"dmg":2548,"engage":11,"fx":193,"leak":78,"skill":96,"spawn":265},
|
||||
"fx": {"aoe":13,"displace":17,"pull":17,"summon":82,"volley":64},
|
||||
"snaps": "948c5168 c32d266d 43ba134b 4372fae1 6e4bf334 4c5fe4c1 6dfbc99b bc7d903c 631d9949 3a438571 2e0c2541 2a1dae92 a90b81e6 ca4ec2b1 454f0153 6e8de2d3 0dd5e1fa d3cd56d3 feeb4a26 da6eab43 8a27c708"
|
||||
"snaps": "25481013 3b3236fa f6b7b1d9 80f81d61 34ef1943 fc11110a a08ee007 369e405c 41ff7208 5e487926 dafe54d3 1b0b63a8 f995283d 0cacb5c4 02b48bb8 4fb3f62d 355c0b0f e3d263b8 07e90d84 8b402749 0f3160ec"
|
||||
},
|
||||
"diy-116": {
|
||||
"about": "6_diy1_b=ebnhlz/S1 6_diy2_b=ebnhlz/S2 6_diy1_b=ebnhlz/S3 6_diy2_b=ebnhlz/S1/002_ebnhlz 6_diy1_b=ebnhlz/S2/002_ebnhlz 6_diy2_b=ebnhlz/S3/002_ebnhlz 6_diy1_b=ebnhlz/S1/003_ebnhlz 6_diy2_b=ebnhlz/S2/003_ebnhlz 6_diy1_b=ebnhlz/S3/003_ebnhlz 6_diy2_b=ebnhlz/S1/004_ebnhlz 6_diy1_b=ebnhlz/S2/004_ebnhlz 6_diy2_b=ebnhlz/S3/004_ebnhlz",
|
||||
|
||||
+33
-33
@@ -21,13 +21,13 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 20,
|
||||
"total": 20,
|
||||
"total": 16,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 860227,
|
||||
"rngDraws": 465,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":20,"total":20,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1654181,"bossDmg":1231515,"heal":19498,"deaths":1,"layerGains":{}},
|
||||
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1654181,"bossDmg":1231515,"heal":19498,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1792443,"bossDmg":1508258,"heal":66028,"deaths":1,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
@@ -82,7 +82,7 @@
|
||||
"hooks": [1,72,6000,1206,1206,3099,3496,3972,31,41,95,89,9,39824,2903,15,29,0,1,32,0,23,0],
|
||||
"events": {"atk":1542,"deploy":37,"die":41,"dmg":3212,"engage":18,"fx":658,"heal":385,"skill":184,"spawn":69,"status":710},
|
||||
"fx": {"airstrike":5,"beam":54,"blessing":1,"blink":1,"bombard":9,"bombardShell":9,"burst":23,"copy":3,"dp":4,"drone":335,"droneLock":17,"drones":3,"finale":2,"firewall":1,"garrisonGrant":1,"lightning":124,"link":1,"lock":9,"radiantSword":4,"shell":8,"slash":2,"snow":5,"split":3,"storm":10,"summon":8,"talent":4,"tide":5,"wanted":7},
|
||||
"snaps": "4b23de24 b826ead4 8478a42a 5f198411 d8f2bcc3 1887ff31 4c303f89 eb6f8150 4dfd4f69 9992c24d 2addc854 8d1008cc 5356698a b471ba51 316422fb c3bf9985 bfae7486 4e4d06f2 d87e4df8 2727e182"
|
||||
"snaps": "44bc2b70 b3daaf96 baf6929d 143779f3 c1387369 80ca050e 4e208395 6728039d 417e6ec7 e83606e2 06a61aec 78738a35 a6c6ae9c c7844f12 05ba7c16 89e6563a eafb1f1c 5f815a0a f048b25a cef59d00"
|
||||
},
|
||||
"boss-boss_1-solo": {
|
||||
"about": "假想敌:胄 solo pool 1800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -97,12 +97,12 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 17,
|
||||
"total": 17,
|
||||
"total": 13,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 1047606,
|
||||
"rngDraws": 51,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":966812,"bossDmg":752394,"heal":37356,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":17,"total":13,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":966812,"bossDmg":752394,"heal":37356,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,3,1,7118,3554,0,5,1],
|
||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,5,0,0,4111,23085,0,35],
|
||||
@@ -125,7 +125,7 @@
|
||||
"hooks": [1,36,6000,693,693,1031,1120,3142,24,24,60,59,168,20939,2386,0,26,0,1,24,0,0,0],
|
||||
"events": {"atk":709,"deploy":10,"die":24,"dmg":1060,"engage":6,"fx":278,"heal":148,"skill":119,"spawn":36,"status":286},
|
||||
"fx": {"anchor":3,"beam":54,"camouflage":1,"healAoe":35,"overload":1,"shell":8,"splash":168,"substitute":2,"summon":4,"swap":2},
|
||||
"snaps": "e67f9eea 2430c808 1d53d0cb 883a39b4 e2e83b6d 8d8ac019 679768b3 0e10a007 f2a7dd03 e508cc03 00b8aaac c75a3d01 837a2744 4e854516 4dc32e71 f923cb9e f9c21aa0 de1d717c 454f544e ab149f66"
|
||||
"snaps": "5160aa85 7e265445 0be2d2e9 6c0188d5 103cf813 9aace512 f579cdb4 8ee9aa58 8005f42d ba1509c1 5738e08d a56aea09 c76253e8 4d964f19 99f8d5c9 4cc5c3f9 9ea422bb 63e8d010 53339522 4b7760db"
|
||||
},
|
||||
"boss-boss_2-pair": {
|
||||
"about": "假想敌:铳 pair pool 6000000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
@@ -262,13 +262,13 @@
|
||||
"ticks": 5803,
|
||||
"reason": "cleared",
|
||||
"killed": 19,
|
||||
"total": 21,
|
||||
"total": 6,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 1423,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":19,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1572377,"bossDmg":1451138,"heal":8263,"deaths":4,"layerGains":{}},
|
||||
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1572377,"bossDmg":1451138,"heal":8263,"deaths":4,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2612061,"bossDmg":2548862,"heal":37169,"deaths":3,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
@@ -360,7 +360,7 @@
|
||||
"tide": 3,
|
||||
"wanted": 1
|
||||
},
|
||||
"snaps": "8c6eb9d3 f5f9a5b4 160c191e 00bb0683 7dd5622f 4b0281e0 6b9388d5 d238f18e 0d5c7c32 c5a0818c 04311046 f68b2536 1eb1629f b8281779 5c9abae1 b74b5a58 4ca28388 6c1d2341 c8e29d0c 0892f5e8"
|
||||
"snaps": "8c6eb9d3 796da093 9e3c16b7 fa7000d5 b0ba5df0 c13bfdd0 b5a04ea3 1a096951 99f7023b ee7eca69 27c012ea 189a5d94 675b6a80 c16a2d26 fb45381b dc80cb4d 62258e5c f5fd826c 841da98a c3777617"
|
||||
},
|
||||
"boss-boss_3-solo": {
|
||||
"about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -375,12 +375,12 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 15,
|
||||
"total": 15,
|
||||
"total": 9,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 1281945,
|
||||
"rngDraws": 11,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":15,"total":15,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":790763,"bossDmg":718055,"heal":12252,"deaths":2,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":15,"total":9,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":790763,"bossDmg":718055,"heal":12252,"deaths":2,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,11,0,187454,8125,0,133,4],
|
||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,9,0,0,0,8390,0,18],
|
||||
@@ -411,7 +411,7 @@
|
||||
"hooks": [1,42,6000,403,403,736,1597,1098,20,24,64,63,21,16163,1212,2,19,0,1,153,0,1,0],
|
||||
"events": {"atk":422,"deploy":17,"die":24,"dmg":716,"engage":8,"fx":217,"heal":42,"skill":127,"spawn":36,"status":180},
|
||||
"fx": {"anchor":1,"aoe":3,"beam":6,"burst":1,"candle":3,"ember":3,"explode":132,"healAoe":18,"overload":4,"phase":13,"revive":1,"splash":21,"substitute":1,"summon":6,"swap":1,"telegraph":1,"teleport":2},
|
||||
"snaps": "edad47f7 eb7e962c 901e9a90 f6931b7e ebcc98aa 25e8d278 8613cfef ec8ed003 b9a58927 9405e611 adc77694 58659919 2f682a2d 7386dac2 87591558 f5f68337 ad5b46f9 a460afb6 0337e937 99d41b9c"
|
||||
"snaps": "edad47f7 646819de f70d4209 623036bb 007bda8c c32ef17d 45a767ca ba1d4895 692a0b5b 00e8b931 0257c6ee b3448033 79f115e6 61c7911a e82a3934 95da73a6 72fc7d06 d6376b51 d8d5a6a7 2e2cb739"
|
||||
},
|
||||
"boss-boss_4-pair": {
|
||||
"about": "盐风主教昆图斯 pair pool 8400000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
@@ -553,13 +553,13 @@
|
||||
"ticks": 3856,
|
||||
"reason": "cleared",
|
||||
"killed": 18,
|
||||
"total": 19,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 1055,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":18,"total":19,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1203964,"bossDmg":884209,"heal":18083,"deaths":2,"layerGains":{}},
|
||||
"p1": {"killed":18,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1203964,"bossDmg":884209,"heal":18083,"deaths":2,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1184403,"bossDmg":675791,"heal":65228,"deaths":1,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
@@ -642,7 +642,7 @@
|
||||
"tide": 3,
|
||||
"wanted": 7
|
||||
},
|
||||
"snaps": "7db84769 07f86d88 93f09fef c359f639 8c1c3866 2e4a5893 f241408f 1d2dafff 2e0c6f08 0716004e 656c6621 e7fc5f6b 5fdc9566"
|
||||
"snaps": "7db84769 ccd2ecf5 49d646d8 1185f93a 64db5dca 1a9e67db 3a4e3045 5b3ba069 0dcc32cf 7d6016ed 4061bb14 3c99a82f a50f8c81"
|
||||
},
|
||||
"boss-boss_5-solo": {
|
||||
"about": "卢西恩,“猩红血钻” solo pool 780000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -657,12 +657,12 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 10,
|
||||
"total": 11,
|
||||
"total": 5,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 466087,
|
||||
"rngDraws": 291,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":10,"total":11,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":476733,"bossDmg":313913,"heal":44154,"deaths":2,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":10,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":476733,"bossDmg":313913,"heal":44154,"deaths":2,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",0,0,2,1,103410,6835,4077,56,3],
|
||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,1,0,0,0,12704,0,25],
|
||||
@@ -689,7 +689,7 @@
|
||||
"hooks": [1,30,6000,357,357,687,1120,4237,14,18,58,57,92,17108,995,17,14,0,1,60,0,5,47],
|
||||
"events": {"atk":367,"deploy":16,"die":18,"dmg":806,"engage":9,"fx":221,"heal":249,"skill":115,"spawn":27,"status":191},
|
||||
"fx": {"anchor":1,"aoe":1,"blink":6,"bloodBattle":1,"burst":5,"candle":2,"dodge":47,"ember":1,"explode":24,"healAoe":25,"overload":3,"shieldBreak":2,"splash":92,"summon":8,"telegraph":1,"teleport":2},
|
||||
"snaps": "20a08a18 81858159 9443afc2 37c4a489 c77051bb 940bfbc5 d27ac583 503cf292 a5771a92 598f6f84 ce5a0bfd bd96c315 8f1d4a4b 387b9dbd 9e4cb6fb e7146e90 3a787983 7725c0a5 b8f07b0b a90fc040"
|
||||
"snaps": "20a08a18 e7c4e5c0 19953744 0b473fe3 5361c85f cb383aa0 6804cf6e 34e854d8 abc09b9d 4729ee83 d3e3e78f bf8d4ce2 824cc2db fcf4e1ba 663a28e6 358410c3 de4f40de 6c169100 7b105e25 70e4dc0f"
|
||||
},
|
||||
"boss-boss_6-pair": {
|
||||
"about": "阿利斯泰尔,帝国余晖 pair pool 7960000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
@@ -797,12 +797,12 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 5,
|
||||
"total": 10,
|
||||
"total": 4,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 3874806,
|
||||
"rngDraws": 45,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":5,"total":10,"perfect":false,"leaks":5,"leaksUncounted":0,"coins":0,"dmg":152625,"bossDmg":105194,"heal":1783,"deaths":16,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":5,"total":4,"perfect":false,"leaks":5,"leaksUncounted":0,"coins":0,"dmg":152625,"bossDmg":105194,"heal":1783,"deaths":16,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3513,1,10,2,8,0,0,8,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",6104,1,11,0,6,0,0,6,1],
|
||||
@@ -833,7 +833,7 @@
|
||||
"hooks": [1,49,6000,179,179,275,472,1334,29,34,20,20,0,6463,12,1,19,5,1,21,0,0,3],
|
||||
"events": {"atk":205,"deploy":24,"die":29,"dmg":201,"engage":7,"fx":91,"leak":5,"skill":42,"spawn":35,"status":73},
|
||||
"fx": {"dodge":3,"equip":9,"featherArrow":7,"flame":3,"garrisonGrant":1,"jungleSoul":2,"mote":8,"riposte":3,"rockslide":1,"sandChains":3,"slash":29,"summon":2,"sword":3,"telegraph":10,"truesilver":4,"vest":3},
|
||||
"snaps": "3e08ed3d 2a259699 77c4f1c2 a547c8e8 80cdbe94 5387ac7c ac8acb26 e405b502 b8cb4679 62dee17d 6a983199 49974232 ee10619f d218a36a 29eef739 77b2f124 b45c9755 95f6faee a7fcd01a edbf901f"
|
||||
"snaps": "3e08ed3d b0f5c6bf 12616b1d 9cce9eed afcfc076 fb56bae7 617f9500 b046bb87 398d9618 80f1089f 99802228 823c06f9 a478439e 71b3d152 452c5be2 683a7bbf 1f49e639 2d0e0575 72ea261c 8fc27088"
|
||||
},
|
||||
"boss-boss_7-pair": {
|
||||
"about": "“萨米的意志” pair pool 4000000: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b token_10017_skadi2_dedant token_10030_mlyss_wtrman | chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_6_17_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b",
|
||||
@@ -957,13 +957,13 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 16,
|
||||
"total": 18,
|
||||
"total": 14,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 12585770,
|
||||
"rngDraws": 666,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":16,"total":18,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2677042,"bossDmg":1550396,"heal":69312,"deaths":17,"layerGains":{}},
|
||||
"p1": {"killed":16,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2677042,"bossDmg":1550396,"heal":69312,"deaths":17,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":560253,"bossDmg":262771,"heal":9942,"deaths":25,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
@@ -1044,7 +1044,7 @@
|
||||
"weightless": 3,
|
||||
"zone": 180
|
||||
},
|
||||
"snaps": "cac31b2c d1e28667 87626189 11a56b43 640cf8cf 9a92be69 12784d9a 195ea951 99de9374 65451b7e 90846f89 9bcf64aa 0cf3dfb9 5737ca93 68f962d8 98c8303b e5af0a0e 3c947908 ce14aa80 61dc2e23"
|
||||
"snaps": "0499777c 66895c47 00cd2e8d 02a8aa95 5d3f3829 3d4bf1c3 1ecb5a32 b9d715e5 54e94d88 7c9b3f95 de81172e 15cddd71 78890306 d4586c67 72920db0 61a8a2c6 4a931d91 d389bff6 1cdd3841 f76bc0a3"
|
||||
},
|
||||
"hidden-boss_8-solo": {
|
||||
"about": "假想敌:胄 solo pool 7200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
||||
@@ -1059,12 +1059,12 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 3,
|
||||
"total": 13,
|
||||
"total": 6,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 6797394,
|
||||
"rngDraws": 227,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":3,"total":13,"perfect":false,"leaks":10,"leaksUncounted":0,"coins":0,"dmg":450453,"bossDmg":402606,"heal":28293,"deaths":23,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":3,"total":6,"perfect":false,"leaks":10,"leaksUncounted":0,"coins":0,"dmg":450453,"bossDmg":402606,"heal":28293,"deaths":23,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",0,0,6,0,264,7875,0,4,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",0,0,32,1,13954,18678,6470,10,5],
|
||||
@@ -1094,7 +1094,7 @@
|
||||
"hooks": [1,56,6000,303,303,564,576,1204,37,47,17,17,0,5895,50,3,28,10,1,29,0,0,0],
|
||||
"events": {"atk":360,"deploy":24,"die":37,"dmg":569,"engage":5,"fx":188,"heal":76,"leak":10,"skill":35,"spawn":42,"status":149},
|
||||
"fx": {"bandage":1,"beam":34,"blink":11,"block":6,"charge":6,"explode":11,"garrisonGrant":1,"healField":6,"mote":4,"redistribute":5,"reweave":1,"rockslide":5,"sandChains":1,"shell":7,"shieldBreak":7,"slash":58,"spGift":2,"summon":5,"telegraph":3,"thorns":11,"truesilver":3},
|
||||
"snaps": "cf292ec5 7036981d ce112fc3 25f0eb93 7e29d0f3 a4667973 30a2224e 8827f472 5ed6aeed 09c8a1e1 a4c3c74f 484ec38d cf5610d2 28371e20 bee6b488 d24d7a86 3e0c2516 47741f73 949b25ac 7b412345"
|
||||
"snaps": "9b5e58cf c4e06fea a40166a3 0e30f917 42214d72 ebc20f66 7258ad62 49f2a70f 0b93ecfb 8a8ebc4e 4bf3c623 15ee0c07 41d58e70 7b48444f 6d3425f8 2d1f93c0 a755c2ad 76e23872 1016f3ce e5423b09"
|
||||
},
|
||||
"hidden-boss_9-pair": {
|
||||
"about": "假想敌:铳 pair pool 5600000: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b token_10017_skadi2_dedant token_10030_mlyss_wtrman | chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_6_17_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b",
|
||||
@@ -1272,13 +1272,13 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 10,
|
||||
"total": 12,
|
||||
"total": 4,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 14635261,
|
||||
"rngDraws": 918,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":10,"total":12,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":459267,"bossDmg":234580,"heal":65462,"deaths":5,"layerGains":{}},
|
||||
"p1": {"killed":10,"total":4,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":459267,"bossDmg":234580,"heal":65462,"deaths":5,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":520827,"bossDmg":330160,"heal":13987,"deaths":24,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
@@ -1360,7 +1360,7 @@
|
||||
"weightless": 2,
|
||||
"zone": 200
|
||||
},
|
||||
"snaps": "f891236c f6672d61 695d2e11 dd4444a7 42309327 9cec6e57 b8fe3b31 cfaa0d6b 4be820e6 693491bb cddbaa3a 1e1a7871 945cbd12 d9b820e0 034525ef 35395d78 56b4abc5 8f47eaee d52f39d7 520c8b02"
|
||||
"snaps": "f891236c f6672d61 695d2e11 dd4444a7 2a7dde13 b3213880 431cd2c4 6abcdf88 03c938b6 2adbab93 89502717 3fc3d404 0c63a20c 8d49e8fc f8eb3fc4 640373e1 0243b53a 3b3694f0 569f5d6c 6ebfcb06"
|
||||
},
|
||||
"hidden-boss_10-solo": {
|
||||
"about": "假想敌:管 solo pool 7600000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
||||
@@ -1375,12 +1375,12 @@
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 11,
|
||||
"total": 13,
|
||||
"total": 5,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 7323878,
|
||||
"rngDraws": 102,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":11,"total":13,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":430741,"bossDmg":276122,"heal":148595,"deaths":10,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":11,"total":5,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":430741,"bossDmg":276122,"heal":148595,"deaths":10,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3123.26,1,29,5,45,3903,0,19,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",1092.31,1,24,0,36,24428,13312,31,6],
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"hooks": [1,33,6000,650,650,2402,2852,7246,21,22,33,33,0,9568,105,16,15,1,1,65,0,27,2],
|
||||
"events": {"atk":973,"deploy":18,"die":21,"dmg":2838,"engage":7,"fx":844,"heal":298,"leak":1,"skill":69,"spawn":25,"status":303},
|
||||
"fx": {"bandage":9,"beam":21,"block":14,"burst":27,"dodge":2,"ember":2,"explode":500,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":3,"mote":52,"phase":55,"redistribute":15,"reweave":1,"riposte":1,"rockslide":6,"sandChains":1,"sandChainsCharged":3,"shieldBreak":21,"slash":40,"spGift":2,"summon":8,"telegraph":2,"thorns":47,"truesilver":2,"undying":1},
|
||||
"snaps": "65ab7720 4d0f9bc6 b175a7ad decfc6d9 b1fdbc1b 7b25f64c 67b47291 502d1b71 8861598b 7112c963 3170b708 8e72387d c384da93 0d0d33a6 19ea6be5 8daa9858 108f2f2f 471283be f8dc4d37 8c04e516"
|
||||
"snaps": "65ab7720 4d0f9bc6 b175a7ad decfc6d9 d1ad8895 b6add8db 8b187de4 1339864b 84136f5a 963ae966 244b6531 f0d249a1 55dc7a49 f44509fa 5ba1fdbd c65a6ccf 00d4edb3 c6669080 45cab92a 414911cc"
|
||||
},
|
||||
"unite-1": {
|
||||
"about": "联防 1 helper(s), 11 leaked: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b token_10017_skadi2_dedant",
|
||||
|
||||
+507
-513
File diff suppressed because it is too large.
Load diff
+69
-69
@@ -79,12 +79,12 @@
|
||||
"ticks": 3300,
|
||||
"reason": "timeout",
|
||||
"killed": 29,
|
||||
"total": 44,
|
||||
"total": 43,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 88,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":29,"total":44,"perfect":false,"leaks":15,"leaksUncounted":0,"coins":8,"dmg":264848,"bossDmg":0,"heal":6152,"deaths":14,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":29,"total":43,"perfect":false,"leaks":15,"leaksUncounted":0,"coins":8,"dmg":264848,"bossDmg":0,"heal":6152,"deaths":14,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_1_02_b",1,"uniequip_002_yak",3645.23,1,63,2,9693,5979,0,26,1],
|
||||
["p1",2,"op","chess_char_1_20_a",1,null,2857.4,1,1,0,0,0,0,0,0],
|
||||
@@ -121,7 +121,7 @@
|
||||
"hooks": [1,64,3300,376,376,473,517,3286,43,54,16,18,48,7606,34,12,44,11,1,44,0,0,0],
|
||||
"events": {"atk":398,"bounty":4,"deploy":20,"die":43,"dmg":517,"engage":10,"fx":83,"heal":36,"leak":11,"skill":37,"spawn":56,"status":68},
|
||||
"fx": {"aoe":5,"counter":14,"crit":23,"displace":10,"dp":3,"ember":1,"pulse":22,"revive":1,"sp":1,"summon":1,"taunt":2},
|
||||
"snaps": "e780a460 61ee47ae bdf76d96 06fbd6a7 1037bf32 a42ebfa2 12a813ce ef2d4564 747331aa a207b3fb d814a43b"
|
||||
"snaps": "e780a460 61ee47ae bdf76d96 06fbd6a7 1037bf32 f06a06c4 cbd23de7 17d297e3 95dffa16 fe0088d3 7c8c9766"
|
||||
},
|
||||
"roster-003": {
|
||||
"about": "pass 0 band 2: chess_char_1_10_b chess_char_1_12_b chess_char_1_20_b chess_char_1_18_b chess_char_1_19_b chess_char_1_13_b chess_char_1_14_b chess_char_1_17_b chess_char_2_01_a chess_char_2_02_a chess_char_2_04_a chess_char_2_05_a token_10000_silent_healrb",
|
||||
@@ -136,13 +136,13 @@
|
||||
"ticks": 3300,
|
||||
"reason": "timeout",
|
||||
"killed": 83,
|
||||
"total": 85,
|
||||
"total": 34,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 59,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":83,"total":85,"perfect":false,"leaks":2,"leaksUncounted":0,"coins":14,"dmg":119766,"bossDmg":0,"heal":20323,"deaths":3,"layerGains":{"preciShip":3}}
|
||||
"p1": {"killed":83,"total":34,"perfect":false,"leaks":2,"leaksUncounted":0,"coins":14,"dmg":119766,"bossDmg":0,"heal":20323,"deaths":3,"layerGains":{"preciShip":3}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_1_10_b",0,"uniequip_002_sunbr",2493,1,5,0,0,0,288,2,2],
|
||||
@@ -184,7 +184,7 @@
|
||||
"hooks": [1,105,3300,579,579,660,773,241,86,90,20,19,0,15985,207,29,85,0,1,92,1,0,0],
|
||||
"events": {"atk":656,"bounty":7,"deploy":17,"die":90,"dmg":749,"engage":9,"fx":101,"heal":212,"layer":1,"skill":39,"spawn":101,"status":228},
|
||||
"fx": {"aoe":1,"blink":1,"displace":8,"ember":1,"explode":9,"garrison":1,"heal":2,"phase":1,"pulse":41,"reveal":1,"sleep":7,"summon":25,"undying":1,"zone":2},
|
||||
"snaps": "ffe12829 556d50e1 da7db459 81849f4b 4e232e69 eedbef98 c39ed31b ac57d325 1cfe2ef9 4afd989a 6671745c"
|
||||
"snaps": "23086eb2 62198379 4013be03 54f09692 fac5503d 7def8563 16aff8bb 1c932c52 15f57f1a 500e8654 2f228080"
|
||||
},
|
||||
"roster-004": {
|
||||
"about": "pass 0 band 2: chess_char_2_08_a chess_char_2_17_a chess_char_2_18_a chess_char_2_07_a chess_char_2_09_a chess_char_2_11_a chess_char_2_13_a chess_char_2_16_a chess_char_2_12_a chess_char_2_06_a chess_char_2_10_a chess_char_2_14_a",
|
||||
@@ -386,12 +386,12 @@
|
||||
"ticks": 4200,
|
||||
"reason": "timeout",
|
||||
"killed": 110,
|
||||
"total": 163,
|
||||
"total": 71,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 63,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":110,"total":163,"perfect":false,"leaks":53,"leaksUncounted":1,"coins":4,"dmg":209618,"bossDmg":0,"heal":1552,"deaths":11,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":110,"total":71,"perfect":false,"leaks":53,"leaksUncounted":1,"coins":4,"dmg":209618,"bossDmg":0,"heal":1552,"deaths":11,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_a",2,null,2334,1,185,2,2951,2334,989,14,1],
|
||||
["p1",2,"op","chess_char_3_16_a",1,null,2173,1,189,0,455,3725,564,1,1],
|
||||
@@ -434,7 +434,7 @@
|
||||
"hooks": [1,189,4200,602,602,858,862,392,122,176,30,29,0,18633,16,36,164,53,1,129,0,0,0],
|
||||
"events": {"atk":734,"bounty":2,"deploy":23,"die":123,"dmg":852,"engage":14,"fx":133,"heal":2,"leak":53,"skill":59,"spawn":180,"status":55},
|
||||
"fx": {"aoe":2,"camouflage":1,"displace":22,"dp":2,"explode":21,"phase":1,"pull":22,"revive":2,"sp":1,"strike":3,"summon":44,"volley":7,"wolfShadow":2,"wolfShadowLost":3},
|
||||
"snaps": "b9af8173 9dbbce46 0545bdc2 a248c945 96d564d2 dfdab949 5c15b625 9b9cfc78 8c803cc4 a8a11123 6ce4d37a 806b7f61 023220d2 bccf5abb"
|
||||
"snaps": "8447aca6 0085b7c9 33307063 cd2bf975 e6c8eeb1 7331d165 340230f4 4e983e4a fd8f005c e400c6cd 0e8b1aa8 9330488b d2efc4c0 40e02a59"
|
||||
},
|
||||
"roster-008": {
|
||||
"about": "pass 0 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b",
|
||||
@@ -449,7 +449,7 @@
|
||||
"ticks": 5100,
|
||||
"reason": "timeout",
|
||||
"killed": 45,
|
||||
"total": 87,
|
||||
"total": 83,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 17,
|
||||
@@ -457,7 +457,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 45,
|
||||
"total": 87,
|
||||
"total": 83,
|
||||
"perfect": false,
|
||||
"leaks": 42,
|
||||
"leaksUncounted": 1,
|
||||
@@ -530,7 +530,7 @@
|
||||
"hooks": [1,137,5100,539,539,1062,1157,387,60,130,21,23,0,8443,80,45,88,39,1,66,12,12,0],
|
||||
"events": {"atk":624,"bounty":4,"deploy":49,"die":91,"dmg":1084,"engage":12,"fx":231,"heal":386,"layer":12,"leak":39,"skill":47,"spawn":128,"status":130},
|
||||
"fx": {"aoe":20,"blink":3,"buff":1,"burst":12,"coin":29,"displace":32,"explode":34,"garrison":8,"layer":4,"pull":32,"revive":6,"shield":1,"shieldBreak":1,"strike":9,"summon":29,"telegraph":1,"volley":9},
|
||||
"snaps": "17903402 6c8167ba 07381013 82bb886b 608b7ec4 249d7653 0d98c4e2 486693a5 84927b5d 4ec21382 2bb263b6 816b9d4f 845b7cfc 1702154b d531c7dc 0cff75b4 79d1c073"
|
||||
"snaps": "17903402 6c8167ba 07381013 82bb886b 608b7ec4 49a9f161 e85b61cf 22016225 ee0925e6 4ee5eeb2 8a500100 aa9abb07 2ce0b42a fcb5b9cb d8b6506e b8cd651a b26cce51"
|
||||
},
|
||||
"roster-009": {
|
||||
"about": "pass 0 band 4: chess_char_3_16_b chess_char_4_01_a chess_char_3_18_b chess_char_4_04_a chess_char_3_13_b chess_char_3_14_b chess_char_3_17_b chess_char_3_19_b chess_char_3_20_b chess_char_3_21_b chess_char_4_02_a chess_char_4_06_a token_10028_vigil_wolf",
|
||||
@@ -545,12 +545,12 @@
|
||||
"ticks": 4674,
|
||||
"reason": "cleared",
|
||||
"killed": 171,
|
||||
"total": 171,
|
||||
"total": 63,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 148,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":171,"total":171,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":12,"dmg":279620,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":171,"total":63,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":12,"dmg":279620,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_16_b",1,"uniequip_002_snakek",5363,1,3,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_4_01_a",2,null,4563.74,1,6,0,0,0,0,0,0],
|
||||
@@ -591,7 +591,7 @@
|
||||
"hooks": [1,187,4674,713,713,1306,1290,0,172,172,63,61,0,21782,106,10,171,0,1,179,0,0,0],
|
||||
"events": {"atk":886,"bounty":6,"deploy":14,"die":172,"dmg":1290,"engage":11,"fx":176,"skill":124,"spawn":186,"status":238},
|
||||
"fx": {"camouflage":1,"crit":24,"displace":30,"dp":5,"explode":4,"phase":1,"ripple":8,"shadowWeave":24,"shield":2,"stealth":4,"summon":51,"telegraph":6,"volley":8,"wolfShadow":4,"wolfShadowLost":3,"zone":1},
|
||||
"snaps": "aea843d4 76d68647 dede7c8e 5e9b6d5c 2a73c288 94334408 2debb788 3158593d c2e8e21e 178efcca 33143e68 c91245db 8d2f8f1e 0134b665 955f8917 a00328a1"
|
||||
"snaps": "69a7f9d4 a676cad6 57a90446 c208123b ae74e9b8 1b323369 8b5283e9 4c70fd14 717d3193 f69fe3aa 1e6c1532 ddb7d885 f774a464 57e74ab4 20bd8a39 ac5fa54d"
|
||||
},
|
||||
"roster-010": {
|
||||
"about": "pass 0 band 4: chess_char_4_17_a chess_char_4_18_a chess_char_4_11_a chess_char_4_19_a chess_char_4_07_a chess_char_4_16_a chess_char_4_09_a chess_char_4_10_a chess_char_4_12_a chess_char_4_13_a chess_char_4_14_a chess_char_4_20_a token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
@@ -606,13 +606,13 @@
|
||||
"ticks": 5100,
|
||||
"reason": "timeout",
|
||||
"killed": 45,
|
||||
"total": 102,
|
||||
"total": 69,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 99,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":45,"total":102,"perfect":false,"leaks":57,"leaksUncounted":0,"coins":4,"dmg":442763,"bossDmg":0,"heal":3335,"deaths":23,"layerGains":{"preciShip":12}}
|
||||
"p1": {"killed":45,"total":69,"perfect":false,"leaks":57,"leaksUncounted":0,"coins":4,"dmg":442763,"bossDmg":0,"heal":3335,"deaths":23,"layerGains":{"preciShip":12}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_a",1,null,0,0,5,0,3672,2849,0,7,0],
|
||||
@@ -654,7 +654,7 @@
|
||||
"hooks": [1,132,5100,457,457,1127,1077,332,71,119,25,26,0,8109,376,12,102,51,1,200,3,0,41],
|
||||
"events": {"atk":557,"bounty":2,"deploy":27,"die":68,"dmg":985,"engage":12,"fx":195,"heal":7,"layer":3,"leak":51,"skill":52,"spawn":119,"status":335},
|
||||
"fx": {"block":9,"catShield":2,"charge":1,"dilemma":1,"dodge":41,"explode":18,"explosion":2,"featherArrow":1,"flame":1,"frostNova":4,"garrison":3,"garrisonGrant":1,"overclock":1,"phase":1,"pull":1,"riposte":2,"rockslide":4,"shieldBreak":5,"slippery":1,"summon":11,"swordRain":46,"swordStorm":8,"telegraph":2,"thorns":16,"tornado":1,"tornadoPulse":5,"undying":7},
|
||||
"snaps": "06e15d5c 9c6e5b81 2ecc1e6a 71fee850 b79ab0d2 457ad226 ff2b9874 4cc2db7e f0f9ed59 2f89b40a aad31065 cbcace62 0b121c2c 860ca274 3c5123e2 f265b649 88aa7607"
|
||||
"snaps": "06e15d5c 9c6e5b81 2ecc1e6a 71fee850 b79ab0d2 3601a029 af7e3130 8290988c ef603826 41e4badb 232f36b3 92680cda 18b57768 3253586c 584ddab4 65fdbd7c 43bb928d"
|
||||
},
|
||||
"roster-011": {
|
||||
"about": "pass 0 band 5: chess_char_4_23_a chess_char_4_01_b chess_char_4_22_a chess_char_4_04_b chess_char_4_07_b chess_char_4_09_b chess_char_4_21_a chess_char_4_24_a chess_char_4_25_a chess_char_4_26_a chess_char_4_02_b chess_char_4_06_b",
|
||||
@@ -669,13 +669,13 @@
|
||||
"ticks": 5700,
|
||||
"reason": "timeout",
|
||||
"killed": 78,
|
||||
"total": 82,
|
||||
"total": 78,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 231,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":78,"total":82,"perfect":false,"leaks":4,"leaksUncounted":0,"coins":12,"dmg":698950,"bossDmg":0,"heal":15928,"deaths":5,"layerGains":{"visiShip":8}}
|
||||
"p1": {"killed":78,"total":78,"perfect":false,"leaks":4,"leaksUncounted":0,"coins":12,"dmg":698950,"bossDmg":0,"heal":15928,"deaths":5,"layerGains":{"visiShip":8}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_23_a",2,null,4358,1,99,11,140344,15336,0,111,3],
|
||||
@@ -714,7 +714,7 @@
|
||||
"hooks": [1,101,5700,972,972,2317,2716,1581,83,83,48,44,30,29691,498,34,82,0,1,84,2,0,1],
|
||||
"events": {"atk":1788,"bounty":6,"deploy":17,"die":83,"dmg":2486,"engage":9,"fx":280,"heal":79,"layer":2,"skill":92,"spawn":96,"status":1157},
|
||||
"fx": {"camouflage":2,"charge":17,"counter":30,"crit":38,"dilemma":6,"displace":7,"dodge":1,"explode":1,"garrison":2,"healField":2,"jungleSoul":3,"mote":26,"quadShot":1,"redistribute":30,"revive":1,"reweave":2,"ripple":15,"sandChains":5,"sermon":1,"shadowWeave":35,"shield":4,"slash":36,"stealth":6,"summon":1,"telegraph":6,"truesilver":2},
|
||||
"snaps": "4ec6c58b 31b6e4f7 b3a1832d 1d3aef5f fadc09c0 7c30f46f 4b78bbe9 86d9c187 89912cdc 65c1ecc2 769a2481 b2b9d6bb 1247c905 a2a0f97e ad63e000 ffd73b97 41a6748a 0b473fca 661ca1b1"
|
||||
"snaps": "4ec6c58b 31b6e4f7 b3a1832d 1d3aef5f a87a8d54 03884138 51cdef78 9ed2fd94 6dc7967a 5abbf904 1c8c11b7 1d22075e 459d6f35 c29e281e 36befd7b 59fdbfb6 ba6c43fa eafb4ef7 2ce67130"
|
||||
},
|
||||
"roster-012": {
|
||||
"about": "pass 0 band 5: chess_char_4_17_b chess_char_4_18_b chess_char_4_11_b chess_char_4_19_b chess_char_4_22_b chess_char_4_16_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b chess_char_4_20_b chess_char_4_21_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
@@ -729,13 +729,13 @@
|
||||
"ticks": 4847,
|
||||
"reason": "cleared",
|
||||
"killed": 99,
|
||||
"total": 100,
|
||||
"total": 96,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 175,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":99,"total":100,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":10,"dmg":998389,"bossDmg":0,"heal":23063,"deaths":10,"layerGains":{"preciShip":8}}
|
||||
"p1": {"killed":99,"total":96,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":10,"dmg":998389,"bossDmg":0,"heal":23063,"deaths":10,"layerGains":{"preciShip":8}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3473.8,1,7,0,296,39,0,19,0],
|
||||
@@ -777,7 +777,7 @@
|
||||
"hooks": [1,125,4847,485,485,1427,1424,821,110,113,83,82,0,17042,1514,29,100,1,1,119,1,0,3],
|
||||
"events": {"atk":636,"bounty":5,"deploy":22,"die":112,"dmg":1415,"engage":10,"fx":275,"heal":212,"layer":1,"leak":1,"skill":167,"spawn":117,"status":1018},
|
||||
"fx": {"beam":6,"blink":2,"block":2,"displace":11,"dodge":3,"explode":6,"explosion":23,"featherArrow":4,"flame":3,"frostNova":35,"garrison":1,"garrisonGrant":1,"healField":2,"overclock":1,"palsy":1,"pull":2,"revive":4,"riposte":2,"rockslide":4,"shieldBreak":9,"slash":30,"summon":1,"swordRain":28,"swordStorm":6,"telegraph":3,"thorns":1,"tornado":2,"tornadoPulse":10,"truesilver":3,"zone":69},
|
||||
"snaps": "ccfbde35 4d2aba8a 8dfe85b0 528967cd 3dbb0c86 c73d7931 d025c97a c9dab1f0 ecebc7c3 77ac09b9 fe0c30b6 3bcdfc52 7d4f4208 bdb5d1fa 39999c60 d73cc178 9d069a0a"
|
||||
"snaps": "ccfbde35 4d2aba8a 8dfe85b0 528967cd 3dbb0c86 c06ed10c 2e96bd48 7b9b35a5 b7b36032 323ec922 4199cd1a 0940cade b87b745a ff75d11f 64cca2ce b9bb5bac fc4d294c"
|
||||
},
|
||||
"roster-013": {
|
||||
"about": "pass 0 band 5: chess_char_4_23_b chess_char_5_08_a chess_char_5_01_a chess_char_5_05_a chess_char_5_06_a chess_char_5_07_a chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b chess_char_5_02_a chess_char_5_03_a chess_char_5_10_a",
|
||||
@@ -792,7 +792,7 @@
|
||||
"ticks": 5645,
|
||||
"reason": "cleared",
|
||||
"killed": 99,
|
||||
"total": 104,
|
||||
"total": 102,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 53,
|
||||
@@ -800,7 +800,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 99,
|
||||
"total": 104,
|
||||
"total": 102,
|
||||
"perfect": false,
|
||||
"leaks": 5,
|
||||
"leaksUncounted": 0,
|
||||
@@ -848,7 +848,7 @@
|
||||
"hooks": [1,124,5645,628,628,1890,2641,19730,108,114,26,25,18,27889,1089,33,110,5,1,163,29,2,0],
|
||||
"events": {"atk":1099,"bounty":5,"deploy":14,"die":109,"dmg":2218,"engage":8,"fx":297,"heal":65,"layer":29,"leak":5,"skill":51,"spawn":122,"status":801},
|
||||
"fx": {"anchor":3,"aoe":1,"bandage":2,"beam":3,"burst":2,"candle":6,"downed":1,"ember":1,"explode":1,"garrison":29,"garrisonGrant":1,"jungleSoul":2,"mote":96,"overload":2,"redistribute":40,"revive":1,"reweave":3,"sandChains":3,"sandChainsCharged":1,"spGift":44,"splash":18,"summon":1,"telegraph":1,"wake":35},
|
||||
"snaps": "14f005fc 39387feb a7349b84 20afb02c a4a1bc3c b2ee7185 6c44cc59 e221af55 b857607b 43c6569b 5c18ca91 7353ffa7 c2202075 ba12516c 0b0ebd92 57f918d9 fc6e30e4 df0b767f 1f363bf1"
|
||||
"snaps": "14f005fc 39387feb a7349b84 20afb02c d0ced437 9221ce4b f3059442 d779613c b02261a1 aa86888c 027f54e2 c5539bb8 ca44f291 62ae4698 8893fcdd c4700cc1 6fe6886f 682dbfd2 35a8b2dc"
|
||||
},
|
||||
"roster-014": {
|
||||
"about": "pass 0 band 6: chess_char_5_11_a chess_char_5_19_a chess_char_5_13_a chess_char_5_14_a chess_char_5_01_b chess_char_5_17_a chess_char_5_12_a chess_char_5_15_a chess_char_5_20_a chess_char_5_21_a chess_char_5_22_a chess_char_5_23_a",
|
||||
@@ -936,7 +936,7 @@
|
||||
"ticks": 5425,
|
||||
"reason": "cleared",
|
||||
"killed": 107,
|
||||
"total": 107,
|
||||
"total": 105,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 200,
|
||||
@@ -944,7 +944,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 107,
|
||||
"total": 107,
|
||||
"total": 105,
|
||||
"perfect": true,
|
||||
"leaks": 0,
|
||||
"leaksUncounted": 0,
|
||||
@@ -1002,7 +1002,7 @@
|
||||
"hooks": [1,142,5425,343,343,2083,2705,4099,110,120,153,151,0,15568,4219,52,110,0,1,225,2,0,0],
|
||||
"events": {"atk":402,"bounty":6,"deploy":29,"die":120,"dmg":2098,"engage":10,"fx":465,"heal":1553,"layer":2,"skill":304,"spawn":131,"status":720},
|
||||
"fx": {"anchor":6,"aoe":45,"candle":3,"ember":2,"garrison":2,"healAoe":79,"overload":4,"revive":1,"sp":101,"substitute":1,"summon":3,"swap":1,"telegraph":1,"teleport":9,"zone":207},
|
||||
"snaps": "22cf0582 8a0c0b85 516b60a1 84aa4163 e05892d0 18489b1b 648324df 80fac9f4 376b8bee 85dbe4cb e627378e d788a444 4b8ef6d8 e2cf34c7 e37bfa3f c7e3226a 4c5d887f 61f8d5f0"
|
||||
"snaps": "22cf0582 8a0c0b85 516b60a1 c00ea058 dc8d82f9 9ea7f69d 76fe7830 9f220f9d 40bd8a20 94e08671 39d32dd9 963505f6 3e70ec19 de5a5755 6b3cf9e2 fe38d43e ecb66d06 6bd2841f"
|
||||
},
|
||||
"roster-016": {
|
||||
"about": "pass 0 band 6: chess_char_5_19_b chess_char_6_03_a chess_char_6_06_a chess_char_5_17_b chess_char_5_20_b chess_char_5_21_b 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 token_10017_skadi2_dedant",
|
||||
@@ -1078,7 +1078,7 @@
|
||||
"ticks": 5965,
|
||||
"reason": "cleared",
|
||||
"killed": 174,
|
||||
"total": 174,
|
||||
"total": 171,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 384,
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 174,
|
||||
"total": 174,
|
||||
"total": 171,
|
||||
"perfect": true,
|
||||
"leaks": 0,
|
||||
"leaksUncounted": 0,
|
||||
@@ -1135,7 +1135,7 @@
|
||||
"hooks": [1,193,5965,895,895,1366,1742,349,184,186,46,43,123,35603,1303,179,174,0,1,193,3,3,0],
|
||||
"events": {"atk":910,"bounty":6,"deploy":19,"die":186,"dmg":1518,"engage":11,"fx":201,"heal":138,"layer":3,"skill":89,"spawn":188,"status":918},
|
||||
"fx": {"airstrike":33,"beam":6,"burst":3,"cleanse":3,"displace":1,"dp":3,"drone":50,"droneLock":21,"drones":1,"emergency":7,"explode":16,"finale":4,"garrison":3,"link":2,"scorchBurst":18,"slash":17,"steal":3,"summon":3,"sunBurst":2,"talent":3,"undying":2},
|
||||
"snaps": "43b03df4 4fcc619d 2be2121d 28e92f35 ef487f7c 940fa13a 63b8128f 2dbfde08 4bbc92c9 5ff16546 090219f9 b0cab1a7 1d90c054 357d876a f60e8ae5 ae2c1c51 a715ad2a 4c2e4416 9b88218f 226d981f"
|
||||
"snaps": "43b03df4 4fcc619d 2be2121d b478fa2a 1c29c601 b33d14b7 0584e2d0 82b225f7 09241fe7 4f2805dd 3462bfac 3ffdbd77 73f9a044 2a84c2d8 8d905cc5 3ebb29ff 02a7318c 9bab956c 7db8bfe9 5cc46ec7"
|
||||
},
|
||||
"roster-018": {
|
||||
"about": "pass 0 band 7: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b chess_char_6_12_b token_10017_skadi2_dedant token_10030_mlyss_wtrman",
|
||||
@@ -1150,7 +1150,7 @@
|
||||
"ticks": 5849,
|
||||
"reason": "cleared",
|
||||
"killed": 120,
|
||||
"total": 120,
|
||||
"total": 117,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 483,
|
||||
@@ -1158,7 +1158,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 120,
|
||||
"total": 120,
|
||||
"total": 117,
|
||||
"perfect": true,
|
||||
"leaks": 0,
|
||||
"leaksUncounted": 0,
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"hooks": [1,145,5849,513,513,1336,1487,3519,124,130,34,30,37,24659,381,5,122,0,1,124,11,6,0],
|
||||
"events": {"atk":606,"bounty":6,"deploy":21,"die":130,"dmg":1407,"engage":12,"fx":260,"heal":185,"layer":11,"skill":64,"spawn":145,"status":385},
|
||||
"fx": {"beam":4,"bombard":31,"bombardShell":31,"burst":6,"camouflage":2,"copy":1,"dp":4,"explode":1,"garrison":8,"lightning":87,"link":1,"lock":37,"lure":2,"scorchBurst":17,"snow":7,"split":4,"storm":7,"summon":2,"talent":1,"tide":4,"wanted":3},
|
||||
"snaps": "19eb7843 2593e6ad ced96303 5ffc34ca 8cbd7231 1e7d8197 948c256c bd58965c b2457d86 151be715 19fcaae7 098875f8 0e3baca8 3c739f96 4210ffa1 2d576f76 35e8b3e6 e853bd87 a170c428 32d5be0d"
|
||||
"snaps": "19eb7843 2593e6ad ced96303 9384a4db 45274713 75c80112 cd1ef1a1 6ff0d830 14dbf244 b55f9101 060019d3 8b4d624b 170d8186 4e4fbe70 f9844c3a d8a1becb eec26a32 99466fc4 2cf9d317 4ac77e1f"
|
||||
},
|
||||
"roster-019": {
|
||||
"about": "pass 0 band 7: chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_6_17_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b",
|
||||
@@ -1303,12 +1303,12 @@
|
||||
"ticks": 2700,
|
||||
"reason": "timeout",
|
||||
"killed": 30,
|
||||
"total": 43,
|
||||
"total": 38,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 93,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":30,"total":43,"perfect":false,"leaks":13,"leaksUncounted":0,"coins":8,"dmg":169863,"bossDmg":0,"heal":20420,"deaths":6,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":30,"total":38,"perfect":false,"leaks":13,"leaksUncounted":0,"coins":8,"dmg":169863,"bossDmg":0,"heal":20420,"deaths":6,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_1_02_a",0,null,6437.42,1,3,0,1704,1216,282,11,1],
|
||||
["p1",2,"op","chess_char_1_04_a",0,null,4318.46,1,5,3,8847,1618,0,36,1],
|
||||
@@ -1341,7 +1341,7 @@
|
||||
"hooks": [1,57,2700,527,526,673,721,5609,36,39,26,24,6,12672,50,35,43,3,1,36,0,3,0],
|
||||
"events": {"atk":597,"bounty":4,"deploy":12,"die":36,"dmg":703,"engage":12,"fx":48,"heal":53,"leak":3,"skill":50,"spawn":57,"status":149},
|
||||
"fx": {"buff":1,"burst":3,"crit":28,"dp":2,"explode":10,"summon":2,"telegraph":2},
|
||||
"snaps": "7569fa79 a7c3130a 6e861c2b f81be7ba 7f7d11f4 b000e94a a637c5f7 adaab972 f533cb58"
|
||||
"snaps": "7569fa79 a7c3130a 6e861c2b f81be7ba 7f7d11f4 1838edf3 c81da2a1 0cddd171 e575a9d9"
|
||||
},
|
||||
"roster-021": {
|
||||
"about": "pass 1 band 2: chess_char_1_02_b chess_char_1_20_a chess_char_1_04_b chess_char_1_08_b chess_char_1_18_a chess_char_1_19_a chess_char_1_17_a chess_char_1_01_b chess_char_1_03_b chess_char_1_06_b chess_char_1_07_b chess_char_1_09_b",
|
||||
@@ -1462,12 +1462,12 @@
|
||||
"ticks": 3300,
|
||||
"reason": "timeout",
|
||||
"killed": 33,
|
||||
"total": 36,
|
||||
"total": 35,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":33,"total":36,"perfect":false,"leaks":3,"leaksUncounted":0,"coins":6,"dmg":156300,"bossDmg":0,"heal":21166,"deaths":4,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":33,"total":35,"perfect":false,"leaks":3,"leaksUncounted":0,"coins":6,"dmg":156300,"bossDmg":0,"heal":21166,"deaths":4,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_2_08_a",0,null,1209.08,1,7,3,11496,6440,380,12,1],
|
||||
["p1",2,"op","chess_char_2_17_a",0,null,0,0,11,5,14348,6625,2659,15,3],
|
||||
@@ -1502,7 +1502,7 @@
|
||||
"hooks": [1,53,3300,397,397,859,960,3173,37,37,21,19,0,10920,67,46,36,0,1,37,0,3,0],
|
||||
"events": {"atk":577,"bounty":3,"deploy":14,"die":37,"dmg":879,"engage":10,"fx":55,"heal":97,"skill":40,"spawn":51,"status":183},
|
||||
"fx": {"beam":42,"buff":3,"burst":3,"explode":1,"summon":1,"takeoff":1,"telegraph":4},
|
||||
"snaps": "ac6bb3ba c0659ec1 ae5b2e72 9a4892cf dcee00e3 e6820472 6acace6e 585a9080 190d8eac c4627eb2 95fb3d5c"
|
||||
"snaps": "ac6bb3ba c0659ec1 ae5b2e72 9a4892cf dcee00e3 e6820472 6acace6e 585a9080 190d8eac 52c080f3 2bc23cb2"
|
||||
},
|
||||
"roster-024": {
|
||||
"about": "pass 1 band 3: chess_char_2_07_b chess_char_2_08_b chess_char_2_09_b chess_char_2_11_b chess_char_2_12_b chess_char_2_19_a chess_char_2_01_b chess_char_2_02_b chess_char_2_04_b chess_char_2_05_b chess_char_2_06_b chess_char_2_10_b",
|
||||
@@ -1517,12 +1517,12 @@
|
||||
"ticks": 4200,
|
||||
"reason": "timeout",
|
||||
"killed": 167,
|
||||
"total": 193,
|
||||
"total": 67,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 11,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":167,"total":193,"perfect":false,"leaks":26,"leaksUncounted":0,"coins":6,"dmg":198262,"bossDmg":0,"heal":21090,"deaths":2,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":167,"total":67,"perfect":false,"leaks":26,"leaksUncounted":0,"coins":6,"dmg":198262,"bossDmg":0,"heal":21090,"deaths":2,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_2_07_b",0,"none",0,0,3,54,81792,7383,4670,60,2],
|
||||
["p1",2,"op","chess_char_2_08_b",0,"none",3193,1,1,0,0,0,0,0,0],
|
||||
@@ -1556,7 +1556,7 @@
|
||||
"hooks": [1,205,4200,764,764,1112,1178,2273,169,192,67,65,0,13967,1101,46,193,23,1,176,0,0,0],
|
||||
"events": {"atk":978,"bounty":3,"deploy":12,"die":169,"dmg":1114,"engage":8,"fx":79,"heal":146,"leak":23,"skill":132,"spawn":205,"status":533},
|
||||
"fx": {"beam":3,"buff":5,"phase":1,"summon":58,"undying":1,"zone":11},
|
||||
"snaps": "1fdc139e b67d37a5 27cdbfd2 91118bdf 5bc23ae1 dae0fe50 cdd53ba4 ff5c2f87 6658a90d 16089f28 0066b1b1 8273a045 1b52d504 45f8843e"
|
||||
"snaps": "1fdc139e 5e66695d 23864921 b3d77d2d 9375efa4 7e27f254 3d501adb ccd8238b 93471fba 03de623d 3b85f08c 6b325e0e bfacea34 155ec05b"
|
||||
},
|
||||
"roster-025": {
|
||||
"about": "pass 1 band 3: chess_char_2_17_b chess_char_2_18_b chess_char_3_06_a chess_char_2_13_b chess_char_2_16_b chess_char_3_02_a chess_char_3_03_a chess_char_3_04_a chess_char_3_05_a chess_char_2_14_b chess_char_2_19_b chess_char_3_01_a",
|
||||
@@ -1571,13 +1571,13 @@
|
||||
"ticks": 4200,
|
||||
"reason": "timeout",
|
||||
"killed": 65,
|
||||
"total": 65,
|
||||
"total": 64,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 154,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":65,"total":65,"perfect":true,"leaks":0,"leaksUncounted":1,"coins":6,"dmg":412510,"bossDmg":0,"heal":30454,"deaths":0,"layerGains":{"steadShip":1}}
|
||||
"p1": {"killed":65,"total":64,"perfect":true,"leaks":0,"leaksUncounted":1,"coins":6,"dmg":412510,"bossDmg":0,"heal":30454,"deaths":0,"layerGains":{"steadShip":1}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_2_17_b",0,"none",12144.76,1,4,0,0,4149,4149,0,6],
|
||||
@@ -1612,7 +1612,7 @@
|
||||
"hooks": [1,81,4200,876,876,1291,1295,8512,65,65,136,135,0,8514,337,96,66,0,1,123,1,0,0],
|
||||
"events": {"atk":929,"bounty":3,"deploy":12,"die":65,"dmg":1295,"engage":8,"fx":177,"heal":230,"layer":1,"skill":273,"spawn":81,"status":249},
|
||||
"fx": {"buff":1,"coin":2,"ember":58,"garrison":1,"revive":1,"sp":95,"summon":1,"takeoff":1,"zone":17},
|
||||
"snaps": "93455703 0f041837 3bae9724 5e0316f5 8ed5e397 724be440 817d612f 08b77e5e 84166039 862c8938 f534d19b d3c927ca de1e8a39 a7c4f443"
|
||||
"snaps": "93455703 0f041837 3bae9724 5e0316f5 8ed5e397 724be440 6dbe253b d1c21d2a cf2cc6da feba90e7 fd2030a8 5e9f571d 0b4e33a8 06fbbefa"
|
||||
},
|
||||
"roster-026": {
|
||||
"about": "pass 1 band 3: chess_char_3_12_a chess_char_3_16_a chess_char_3_18_a chess_char_3_08_a chess_char_3_09_a chess_char_3_10_a chess_char_3_11_a chess_char_3_13_a chess_char_3_14_a chess_char_3_17_a chess_char_3_19_a chess_char_3_20_a token_10028_vigil_wolf",
|
||||
@@ -1627,12 +1627,12 @@
|
||||
"ticks": 4200,
|
||||
"reason": "timeout",
|
||||
"killed": 18,
|
||||
"total": 73,
|
||||
"total": 71,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 164,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":18,"total":73,"perfect":false,"leaks":55,"leaksUncounted":0,"coins":6,"dmg":183879,"bossDmg":0,"heal":1883,"deaths":21,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":18,"total":71,"perfect":false,"leaks":55,"leaksUncounted":0,"coins":6,"dmg":183879,"bossDmg":0,"heal":1883,"deaths":21,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_a",0,null,0,0,96,1,7787,4668,1883,19,4],
|
||||
["p1",2,"op","chess_char_3_16_a",0,null,0,0,6,1,3499,2173,0,12,1],
|
||||
@@ -1669,7 +1669,7 @@
|
||||
"hooks": [1,102,4200,383,383,1447,1501,12,40,94,30,30,0,9243,22,42,73,53,1,43,0,0,0],
|
||||
"events": {"atk":436,"bounty":3,"deploy":26,"die":41,"dmg":1499,"engage":14,"fx":27,"heal":11,"leak":53,"skill":60,"spawn":90,"status":63},
|
||||
"fx": {"dp":5,"explode":4,"liberate":1,"phase":1,"revive":4,"shield":2,"sp":1,"strike":1,"summon":2,"telegraph":3,"wolfShadow":1,"wolfShadowLost":2},
|
||||
"snaps": "1a7d12d1 7955f440 d99919a7 ba142976 2092d384 8dfb7d8e b96cb02d f9c0f4f2 731ec520 3fc5716e da654f48 c224a8bc e3c0b86b 51db902d"
|
||||
"snaps": "1a7d12d1 7955f440 d99919a7 09818fe9 5d85594c eb4b0484 1591b58e c034a33b 2c27bf89 7a9f0e18 14dd1382 37e5dbf7 b8884791 2ad0053c"
|
||||
},
|
||||
"roster-027": {
|
||||
"about": "pass 1 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b",
|
||||
@@ -1684,13 +1684,13 @@
|
||||
"ticks": 5100,
|
||||
"reason": "timeout",
|
||||
"killed": 41,
|
||||
"total": 57,
|
||||
"total": 53,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 143,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":41,"total":57,"perfect":false,"leaks":16,"leaksUncounted":0,"coins":8,"dmg":451319,"bossDmg":0,"heal":38831,"deaths":7,"layerGains":{"raidShip":18}}
|
||||
"p1": {"killed":41,"total":53,"perfect":false,"leaks":16,"leaksUncounted":0,"coins":8,"dmg":451319,"bossDmg":0,"heal":38831,"deaths":7,"layerGains":{"raidShip":18}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_06_b",0,"none",0,0,2,0,1394,6130,0,11,1],
|
||||
@@ -1723,7 +1723,7 @@
|
||||
"hooks": [1,71,5100,765,765,2076,2076,69,48,64,151,151,0,10608,52,29,57,14,1,48,3,0,0],
|
||||
"events": {"atk":950,"bounty":4,"deploy":14,"die":50,"dmg":1979,"engage":12,"fx":118,"heal":69,"layer":3,"leak":14,"skill":305,"spawn":69,"status":185},
|
||||
"fx": {"blink":1,"buff":1,"coin":32,"explode":15,"garrison":3,"shield":18,"shieldBreak":1,"strike":13,"summon":1,"volley":7,"zone":26},
|
||||
"snaps": "5cda3b22 5fca5f0c d70d8372 4fe579a4 2c9b6956 8d5ffb46 9aeb8795 69cfc5d4 e7047e1f 38960979 63e2a094 700bd4c4 0fc17381 24617d77 874750a0 4b37c245 395b3b9a"
|
||||
"snaps": "5cda3b22 5fca5f0c d70d8372 4fe579a4 dac79dc9 4a2e282a 6a55c54b 86e02720 9144eaf2 1bf08368 f064ba8f 2fb3e530 9274187f a89b50de 49881a0a be34c52c 49de8118"
|
||||
},
|
||||
"roster-028": {
|
||||
"about": "pass 1 band 4: chess_char_3_16_b chess_char_4_01_a chess_char_3_18_b chess_char_4_04_a chess_char_3_13_b chess_char_3_14_b chess_char_3_17_b chess_char_3_19_b chess_char_3_20_b chess_char_3_21_b chess_char_4_02_a chess_char_4_06_a token_10028_vigil_wolf",
|
||||
@@ -2268,7 +2268,7 @@
|
||||
"ticks": 6492,
|
||||
"reason": "cleared",
|
||||
"killed": 51,
|
||||
"total": 131,
|
||||
"total": 86,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 136,
|
||||
@@ -2276,7 +2276,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 51,
|
||||
"total": 131,
|
||||
"total": 86,
|
||||
"perfect": false,
|
||||
"leaks": 80,
|
||||
"leaksUncounted": 0,
|
||||
@@ -2331,7 +2331,7 @@
|
||||
"hooks": [1,175,6492,504,504,2453,2631,28400,82,166,61,59,39,10872,1110,32,131,80,1,57,1,2,0],
|
||||
"events": {"atk":605,"bounty":2,"deploy":41,"die":86,"dmg":2534,"engage":15,"fx":82,"heal":20,"layer":1,"leak":80,"skill":120,"spawn":156,"status":656},
|
||||
"fx": {"airstrike":11,"aoe":8,"burst":2,"copy":5,"explode":29,"garrison":1,"heal":4,"healField":2,"link":2,"revive":1,"split":1,"stone":1,"summon":15},
|
||||
"snaps": "e7f57cb9 5d2f7fdf a34e9406 8a58323a 69de9406 d21d1321 1a7dd414 1453d4cf 75734598 fe883da1 9cea1cbf d7499dcf 74a67785 3dae277f 03326b45 4d301f69 b4e3db34 2f746db2 a0ce697f 30caa953 92e4bdb1 c3ddaeb8"
|
||||
"snaps": "e7f57cb9 5d2f7fdf a34e9406 8a58323a 7d07bc24 abc407ce 319e7ebf 95f85fd9 9a3a7827 7b2bbf31 12b76db4 084b6d1d ed65c31c dbb67422 b1a1c69a 25e7a551 0c4ac123 51e0bc76 65d11431 1570d59d 03fbdd0e 985fd819"
|
||||
},
|
||||
"roster-038": {
|
||||
"about": "pass 1 band 7: chess_char_5_08_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_5_06_b chess_char_6_17_b chess_char_5_07_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b",
|
||||
@@ -2763,7 +2763,7 @@
|
||||
"ticks": 5511,
|
||||
"reason": "cleared",
|
||||
"killed": 68,
|
||||
"total": 68,
|
||||
"total": 67,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 38,
|
||||
@@ -2771,7 +2771,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 68,
|
||||
"total": 68,
|
||||
"total": 67,
|
||||
"perfect": true,
|
||||
"leaks": 0,
|
||||
"leaksUncounted": 0,
|
||||
@@ -2822,7 +2822,7 @@
|
||||
"hooks": [1,91,5511,386,379,786,1232,8816,69,74,13,10,34,23943,622,17,68,0,1,69,4,52,0],
|
||||
"events": {"atk":446,"bounty":4,"deploy":20,"die":74,"dmg":1049,"engage":8,"fx":162,"layer":4,"skill":23,"spawn":90,"status":1246},
|
||||
"fx": {"airstrike":10,"anchor":1,"burst":52,"crit":14,"dp":3,"expose":6,"garrison":3,"link":2,"lock":14,"snow":5,"summon":2,"summonStun":2,"talent":1,"wanted":47},
|
||||
"snaps": "120f2050 546489af 7dcae1cb ba57ea31 14949879 f5908ce9 602d5c12 63bba2cf a8e27e28 9ffc8315 03831118 1358bb3c 17c12939 59ead8d8 4c60897d 078878c9 827f7455 b7ae543f 912fccf8"
|
||||
"snaps": "120f2050 546489af cf372130 f94ff0af c7481d33 d45316d4 4f6aa634 25182725 e35a21e0 eef0037b 837c3a5d a6636a5e 1e78c189 0b81163f 723d13fc 6d325c2f 4e7ece19 c7ca24b8 06924ce2"
|
||||
},
|
||||
"roster-046": {
|
||||
"about": "pass 2 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b token_10017_skadi2_dedant",
|
||||
@@ -2892,12 +2892,12 @@
|
||||
"ticks": 6312,
|
||||
"reason": "cleared",
|
||||
"killed": 66,
|
||||
"total": 142,
|
||||
"total": 139,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 257,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":66,"total":142,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":6,"dmg":348541,"bossDmg":0,"heal":10968,"deaths":28,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":66,"total":139,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":6,"dmg":348541,"bossDmg":0,"heal":10968,"deaths":28,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_06_b",1,"none",0,0,5,11,42583,2639,0,22,2],
|
||||
["p1",2,"op","chess_char_6_15_b",1,"none",0,0,151,4,39276,6912,0,29,2],
|
||||
@@ -2937,7 +2937,7 @@
|
||||
"hooks": [1,182,6312,730,723,806,909,111,97,177,14,14,90,10310,718,53,142,76,1,97,0,0,0],
|
||||
"events": {"atk":737,"bounty":3,"deploy":37,"die":101,"dmg":824,"engage":12,"fx":52,"heal":19,"leak":76,"skill":28,"spawn":164,"status":153},
|
||||
"fx": {"airstrike":29,"anchor":2,"appear":1,"blink":1,"copy":1,"disappear":1,"explode":1,"garrisonGrant":2,"heal":2,"link":1,"revive":3,"summon":2,"summonStun":2,"sunBurst":1,"sword":2,"zone":1},
|
||||
"snaps": "3c9f691f 34faf375 087176f6 1c545a7a 0aa65801 59083ac8 08f660b0 d5292070 05ddc682 c1288138 21433f07 92e5a16c 7bfac0b9 0e1d7724 c53060df a263901a 9433d9ba 6046dcd9 3521e34a 4ddff910 6f17f8e1"
|
||||
"snaps": "3c9f691f 34faf375 b7085c9e ad0ffeeb a5b75ffe d6e8b1d3 035b35db 66381fb1 b9877510 753ba510 04252066 ed355da1 5c82f27b 1be63ee2 55031d43 90c8bd6f b369a095 593b947c be089a07 29e3c756 28b150ee"
|
||||
},
|
||||
"roster-048": {
|
||||
"about": "pass 2 band 7: chess_char_6_19_b chess_char_5_13_b chess_char_6_20_b chess_char_5_12_b chess_char_5_20_b chess_char_6_05_b",
|
||||
@@ -2952,7 +2952,7 @@
|
||||
"ticks": 6164,
|
||||
"reason": "cleared",
|
||||
"killed": 34,
|
||||
"total": 87,
|
||||
"total": 86,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 145,
|
||||
@@ -2960,7 +2960,7 @@
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 34,
|
||||
"total": 87,
|
||||
"total": 86,
|
||||
"perfect": false,
|
||||
"leaks": 53,
|
||||
"leaksUncounted": 0,
|
||||
@@ -2998,7 +2998,7 @@
|
||||
"hooks": [1,103,6164,249,249,2817,2826,30376,41,94,66,66,0,9498,399,25,87,53,1,74,9,1,0],
|
||||
"events": {"atk":463,"bounty":1,"deploy":13,"die":41,"dmg":2826,"engage":5,"fx":129,"heal":44,"layer":9,"leak":53,"skill":132,"spawn":96,"status":835},
|
||||
"fx": {"burst":1,"dollEnd":1,"explode":1,"garrison":8,"layer":1,"lightning":58,"shield":5,"slash":11,"sp":28,"storm":3,"substitute":3,"summon":1,"swap":2,"telegraph":1,"weightless":5},
|
||||
"snaps": "114138f7 91b6a2fa cc32ef2d c73b8b92 cfc9b573 b1f8d939 67a68be3 fbe5df15 f7521760 ef229a79 2c9fc754 fd4c140e 7704bbb0 20c916c8 38931f1b 403b9470 3735233a 40c9ddae c1216447 f0ba8a3b addc333e"
|
||||
"snaps": "114138f7 91b6a2fa cc32ef2d c73b8b92 cfc9b573 7b3dd24c 0c53a125 af35b361 6fca06d6 2c88ae52 e6b5584c b5db6ac3 b1fe0c6c 1b308f78 394e7946 d6b4633c 08432cc7 25ebf66b 22366e40 448fd11e e921151e"
|
||||
},
|
||||
"roster-049": {
|
||||
"about": "pass 3 band 5: chess_char_4_23_b chess_char_3_04_b chess_char_4_09_b chess_char_3_21_b chess_char_4_13_b",
|
||||
@@ -3013,12 +3013,12 @@
|
||||
"ticks": 5700,
|
||||
"reason": "timeout",
|
||||
"killed": 28,
|
||||
"total": 64,
|
||||
"total": 62,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":28,"total":64,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":6,"dmg":430513,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":28,"total":62,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":6,"dmg":430513,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_23_b",2,"none",0,0,1,0,0,2661,0,0,0],
|
||||
["p1",2,"op","chess_char_3_04_b",1,"none",3289.5,1,74,1,17288,6579,0,88,0],
|
||||
@@ -3043,7 +3043,7 @@
|
||||
"hooks": [1,74,5700,336,336,1757,1757,0,34,69,31,32,0,3916,725,3,64,35,1,35,0,0,0],
|
||||
"events": {"atk":389,"bounty":3,"deploy":10,"die":34,"dmg":1756,"engage":4,"fx":47,"leak":35,"skill":67,"spawn":69,"status":415},
|
||||
"fx": {"coin":9,"dilemma":3,"explode":2,"frostNova":25,"revive":1,"shield":2,"shieldBreak":1,"summon":1,"telegraph":3},
|
||||
"snaps": "22feba20 4bc82bff c2ef8225 6fc18bf4 0986acbf 61e7f956 eb664c7f db99546b 96c386fa 8701aeb3 c2cb18a0 f881beec 96991826 da3b1101 c2a76f22 f89bc3d9 a6d90c11 4ec4cbb4 0b2fb538"
|
||||
"snaps": "22feba20 4bc82bff 64cc615e 21da6144 58240a76 f0531e80 a0205176 3d77feab 942ccd22 485f3fd3 56333d9f 02add93c cbc1098a dd516fb0 72fb6266 e093854d 6173f536 de8c5328 1f0a2be6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,8 +366,15 @@ export function checkInvariants(b) {
|
||||
for (const pr of b.projectiles.list) if (![pr.x, pr.y, pr.tx, pr.ty].every(Number.isFinite)) bad(`projectile at ${pr.x},${pr.y} → ${pr.tx},${pr.ty}`);
|
||||
if (b._emitDepth) bad(`hook emit depth ${b._emitDepth} between steps`);
|
||||
for (const pp of Object.values(b._perPlayer ?? {})) {
|
||||
for (const k of ['killed', 'total', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
|
||||
for (const k of ['killed', 'total', 'killedInTotal', 'leakedInTotal', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
|
||||
// The capsule's counters are attributed differently on purpose: a knock-out goes to the enemy's owner (as its `total`
|
||||
// did), while a leak goes to the player whose half the enemy reached (`_recordLeak`) — in a 联防 field an enemy
|
||||
// spawned on one half and leaked on the other bills the leaker, so per player only `killedInTotal ≤ total` holds
|
||||
// (BattleResult's per-player `resolved` clamps the sum to `total`, and the battle-level sum is exact)
|
||||
if (pp.killedInTotal > pp.total) bad(`capsule ${pp.killedInTotal} > ${pp.total}`);
|
||||
if (pp.total < 0 || pp.killedInTotal < 0 || pp.leakedInTotal < 0) bad('negative capsule counter');
|
||||
}
|
||||
if (b.killedInTotal + b.leakedInTotal > b.total) bad(`battle capsule ${b.killedInTotal}+${b.leakedInTotal} > ${b.total}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,14 @@ test('COMBAT: humans get their own spec (authoritative), bots are simulated by t
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
const pub = m.publicView();
|
||||
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, total: 9, done: false });
|
||||
// `resolved` is null while the authority has not reported one — never 0, so the client's `resolved ?? killed` fallback
|
||||
// holds (a report without `resolved` must read as "unknown", not as "nothing resolved")
|
||||
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: null, total: 9, done: false });
|
||||
// a report that carries `resolved` is adopted, a reported 0 included (PR #157 capsule numerator)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
const pub2 = m.publicView();
|
||||
assert.deepEqual(pub2.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: 0, total: 9, done: false });
|
||||
// a stale / foreign report is ignored (never an error toast)
|
||||
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: human.battleId, gt: 9, killed: 9, total: 9 }), { ok: true });
|
||||
assert.equal(human.progress.killed, 3);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
|
||||
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
|
||||
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
|
||||
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
|
||||
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
|
||||
// GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
|
||||
// upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
|
||||
// it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
|
||||
// tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
|
||||
// (item 19 of the community report of 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark);
|
||||
// before that the shop kept its old slot count until the next roll. Either way the upgrade leaves the cards shown where they are.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, DATA } from './harness.js';
|
||||
@@ -11,7 +12,7 @@ import { GameData } from '../../server/match/gamedata.js';
|
||||
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
|
||||
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
|
||||
|
||||
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
|
||||
test('a level-up opens the new level\'s operator slot EMPTY; the cards shown stay in place; a refresh fills it', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
@@ -20,35 +21,37 @@ test('a level-up opens the new level\'s operator slot at once with a new card; t
|
||||
const s1 = ps.shop.slots.slice();
|
||||
assert.equal(s1.length, 4);
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
||||
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
|
||||
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), the new slot empty
|
||||
assert.equal(ps.shop.level, 2);
|
||||
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
|
||||
assert.equal(ps.shop.slots.length, 5);
|
||||
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
|
||||
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
|
||||
const added = ps.shop.slots[3];
|
||||
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
|
||||
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
|
||||
assert.equal(added.frozen, false);
|
||||
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
|
||||
// the player sees it right away (m.private shop)
|
||||
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
|
||||
// 2 → 3 keeps 4 operator slots: nothing rerolled
|
||||
// a manual refresh fills every slot at the new level (the empty one included)
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||
assert.equal(ps.shop.slots.length, 5);
|
||||
for (const s of ps.shop.slots) assert.ok(s && !s.sold, 'every slot carries a fresh card after the refresh');
|
||||
for (const s of ps.shop.slots) if (s.kind === 'chess') assert.ok(tierOf(s.id) >= 1 && tierOf(s.id) <= 2, `drawn at level 2 (tier ${tierOf(s.id)})`);
|
||||
// 2 → 3 keeps 4 operator slots: nothing rerolled, nothing added
|
||||
const s2 = ps.shop.slots.slice();
|
||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||
assert.equal(ps.shop.level, 3);
|
||||
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
|
||||
// 3 → 4: the fifth operator slot
|
||||
// 3 → 4: the fifth operator slot, empty again
|
||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||
assert.equal(ps.shop.level, 4);
|
||||
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
|
||||
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
|
||||
assert.equal(ps.shop.slots[5], s2[4]);
|
||||
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
|
||||
assert.equal(ps.shop.slots[4], null, 'the new slot is open and empty');
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
|
||||
test('a bought card stays sold; the empty new slot has nothing to freeze; the round start fills the shop to the new level', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
@@ -58,35 +61,43 @@ test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED]
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
|
||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
|
||||
const added = ps.shop.slots[3];
|
||||
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
|
||||
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
|
||||
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
|
||||
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
|
||||
// the next round start keeps every frozen card (in place) and rolls only the sold slot
|
||||
assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
|
||||
// the round start fills every slot of the new level: frozen cards kept in place, the sold and the empty slot rolled
|
||||
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
|
||||
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
|
||||
assert.equal(ps.shop.slots.length, 5);
|
||||
for (const s of ps.shop.slots) assert.ok(s, 'the round start leaves no empty slot');
|
||||
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id).slice(0, 2), keep.slice(0, 2), 'the frozen operator cards kept');
|
||||
assert.equal(ps.shop.slots[4].id, keep[2], 'the frozen item kept after the operator slots');
|
||||
assert.notEqual(ps.shop.slots[0].id, undefined, 'the sold slot rerolled');
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
|
||||
test('every mode\'s slot table: a level-up opens exactly the new positions, empty (标准: 3,4,4,4,4,5)', () => {
|
||||
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
|
||||
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
const gd = h.m.gd;
|
||||
ps.funds = 500;
|
||||
let empty = 0;
|
||||
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
|
||||
const before = ps.shop.slots.slice();
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
||||
const { chess, item } = gd.shopSlots(lv);
|
||||
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
|
||||
const grew = chess - gd.shopSlots(lv - 1).chess;
|
||||
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
|
||||
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
|
||||
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
|
||||
const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
|
||||
const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
|
||||
assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
|
||||
// the empty positions accumulate: nothing fills them between the upgrades (no refresh / round start here)
|
||||
empty += grew;
|
||||
assert.equal(ps.shop.slots.filter((s) => !s).length, empty, `${mode} ${difficulty} L${lv}: the new positions are open and empty`);
|
||||
}
|
||||
// one refresh fills everything at the final level
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||
for (const s of ps.shop.slots) assert.ok(s, `${mode} ${difficulty}: the refresh leaves no empty slot`);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// test/match/hud-capsule-resolved.test.js — the HUD capsule's server half (PR #157): m.public.fields[].progress carries
|
||||
// the field's own `resolved` (knocked out + leaked among the enemies the round scheduled), and
|
||||
// * a bot / server-run field has no authority reporting b.progress — its capsule must read the battle's own counters
|
||||
// (`timelineSample` → Battle.resolved), not the report-driven `progress.leaks`, which stays 0 forever (maintainer
|
||||
// review point ①);
|
||||
// * `progress.resolved` starts at **null**, never 0: only a real reported number is adopted, so the client's
|
||||
// `resolved ?? killed` fallback keeps working (a `Number(null) === 0` would defeat it) — and a reported 0 is a real
|
||||
// value, told apart from "not reported" (review point ②);
|
||||
// * a finished two-half field (联防 with two helpers, the boss pair) publishes the FIELD's own `resolved` — the capsule's
|
||||
// number — never the sum of the players' own clamps: an enemy that spawns on one half and leaks on the other is
|
||||
// billed to one player's `total` and to the other's `leakedInTotal`, so both players' min(total, …) read 0 for it
|
||||
// while the field resolved it (found reviewing the capsule port, 2026-10-08).
|
||||
// Run: node --test test/match/hud-capsule-resolved.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch, DATA } from './harness.js';
|
||||
import { timelineSample, timelineAt, validateClientResult, runHeadless } from '../../server/match/fields.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, compactResult, battleProgress } from '../../server/sim/spec.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { MatchViews } from '../../server/match/match/views.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { teammateProgress } from '../../public/js/battle/observe.js';
|
||||
import { uniteLeftMark } from '../../public/js/battle/runner.js';
|
||||
|
||||
const fields = (h) => h.m.fields;
|
||||
const progressOf = (m, fieldId) => (m.publicView().fields.find((f) => f.fieldId === fieldId) || {}).progress;
|
||||
|
||||
test('① 机器人战场 (mode server, 无上报): 胶囊读该战斗自己的计数器,不是停在 0 的 progress.leaks', () => {
|
||||
// no FakeBattle: the bot's field runs the real sim on the server (HeadlessJob + timeline), and nobody reports b.progress
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, instant: false, clientCombat: true, clients: false }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const f = fields(h).find((x) => x.fieldId === 'n:ai_0');
|
||||
assert.equal(f.mode, 'server', 'the bot field runs on the server');
|
||||
assert.equal(f.authority, null, 'no authority ever reports it');
|
||||
assert.ok(f.timeline && f.timeline.length > 1, 'it has a progress timeline');
|
||||
assert.equal(f.progress.leaks, 0, 'nothing was reported: progress.leaks stays 0');
|
||||
assert.equal(f.progress.resolved, null, 'and progress.resolved stays null (never a fabricated 0)');
|
||||
|
||||
let filled = 0;
|
||||
for (let i = 0; i < 20 && !f.done; i++) {
|
||||
h.sched.advance(2000);
|
||||
const pr = progressOf(m, 'n:ai_0');
|
||||
// the capsule reads the battle's own counters, sampled on the field clock
|
||||
assert.equal(pr.resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'resolved = the timeline sample');
|
||||
assert.ok(pr.resolved != null && pr.resolved <= pr.total, 'a real number, never above the denominator');
|
||||
if (pr.resolved > 0) { filled = pr.resolved; break; }
|
||||
}
|
||||
assert.ok(filled > 0, 'the bot field\'s capsule fills up (a report-driven number would have stayed at 0)');
|
||||
// the sample is composed of the battle's own two counters — the field's own scheduled enemies that were resolved
|
||||
assert.equal(timelineSample(f.battle)[3], Math.min(f.battle.total, f.battle.killedInTotal + f.battle.leakedInTotal));
|
||||
assert.equal(progressOf(m, 'n:ai_0').resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'clamped by the field clock');
|
||||
});
|
||||
|
||||
test('① 一场只漏不杀的机器人战场: resolved 由 leakedInTotal 顶起来 (不是 0)', () => {
|
||||
// three walkers, nobody to stop them: the field's own enemies leak — leakedInTotal is what the capsule must show
|
||||
const h = makeBattle({ seed: 5, enemies: [{ key: 'enemy_1005_yokai', count: 3 }], content: 'full' });
|
||||
h.runToEnd(120);
|
||||
assert.equal(h.b.total, 3);
|
||||
assert.equal(h.b.leakedInTotal, 3, 'the field leaked all three of its own enemies');
|
||||
assert.equal(h.b.leakedCount, 3, 'counted leaks — the LP charge, unchanged from master');
|
||||
const sample = timelineSample(h.b);
|
||||
assert.equal(sample[3], 3, 'the timeline sample carries the capsule numerator');
|
||||
assert.equal(battleProgress(h.b).resolved, 3, 'b.progress too (what a teammate HUD reads)');
|
||||
assert.equal(battleProgress(h.b).total, 3);
|
||||
assert.equal(battleProgress(h.b).killed, 0);
|
||||
});
|
||||
|
||||
test('② 上报没有 resolved ⇒ progress.resolved = null,客户端回落到 killed;上报 resolved: 0 ⇒ 采用 0', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9103, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 6 }) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||
assert.equal(human.mode, 'client');
|
||||
assert.equal(human.progress.resolved, null, 'a fresh field: resolved unknown, not 0');
|
||||
|
||||
// a report without `resolved` (an older client / a field that cannot count it) leaves it unknown
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: null, total: 9, done: false });
|
||||
// … and the teammate HUD falls back to `killed` (master's display: resolved ?? killed)
|
||||
const mine = (pub) => teammateProgress(pub, 'p_1').find((x) => x.playerId === 'p_0');
|
||||
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 3, total: 9, done: false });
|
||||
|
||||
// a reported 0 is a real value: adopted, and the HUD shows 0 (not the fallback)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: 0, total: 9, done: false });
|
||||
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 0, total: 9, done: false });
|
||||
});
|
||||
|
||||
test('a done field\'s m.public progress carries the reported resolved (never a fabricated one)', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9104, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 2 }) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||
// a result carrying the capsule numbers: 5 counted knock-outs (a split child included), 3 of the round's own resolved
|
||||
const result = {
|
||||
reason: 'cleared', time: 6, killed: 5, total: 3, resolved: 3,
|
||||
perPlayer: { p_0: { killed: 5, total: 3, resolved: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] } },
|
||||
errors: 0,
|
||||
};
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: human.battleId, result }), { ok: true });
|
||||
m.flush(true);
|
||||
const pr = progressOf(m, 'n:p_0');
|
||||
assert.equal(pr.done, true);
|
||||
assert.equal(pr.killed, 5, '`killed` keeps the counted reading (may exceed the denominator)');
|
||||
assert.equal(pr.total, 3, 'the denominator is the round\'s own list');
|
||||
assert.equal(pr.resolved, 3, 'the capsule numerator of the finished field');
|
||||
});
|
||||
|
||||
test('a 联防 battle\'s live-counter mark still moves for a spawn — `total` no longer does (a split child / a summon is outside the capsule)', () => {
|
||||
// the runner re-reads the leakers' enemies still standing (uniteLeft) only when this mark moves; before the capsule
|
||||
// counted the round's own enemies only, every counted spawn raised `total` and so moved it
|
||||
const h = makeBattle({ seed: 41, autoFinish: false, timeLimit: 60, enemies: [{ key: 'enemy_1195_sfyin', pos: [9, 6] }] });
|
||||
h.step();
|
||||
const m0 = uniteLeftMark(h.b);
|
||||
assert.equal(h.b.total, 1);
|
||||
assert.equal(uniteLeftMark(h.b), m0, 'nothing happened: the same mark');
|
||||
const child = h.spawn('enemy_1196_msfyin', { pos: [9, 4] }); // a runtime spawn: counted, outside the denominator
|
||||
assert.ok(child && child.counted && !child.inTotal);
|
||||
assert.equal(h.b.total, 1, 'the denominator did not move');
|
||||
assert.notEqual(uniteLeftMark(h.b), m0, 'the mark did: the spawn is a standing enemy the counter must read');
|
||||
const m1 = uniteLeftMark(h.b);
|
||||
h.b.kill(child, null);
|
||||
assert.notEqual(uniteLeftMark(h.b), m1, 'a knock-out moves it');
|
||||
});
|
||||
|
||||
// ---- an enemy that crosses the midline of a two-half field -------------------------------------------------------------
|
||||
//
|
||||
// The capsule counts per FIELD (Battle.resolved = min(total, killedInTotal + leakedInTotal)), but the sim also keeps every
|
||||
// player's own counters: `total` and a knock-out belong to the half the enemy spawned on, a leak to the half whose tile it
|
||||
// reached (deploy.js _recordLeak). A lane that runs through both halves — the 联防 lane (col 18 → col 2), the boss pair's
|
||||
// crossing routes — bills one enemy's `total` to one player and its leak to the other, so each player's own
|
||||
// min(total, killedInTotal + leakedInTotal) clamps it away: 0 and 0 for an enemy the field resolved. A finished field must
|
||||
// publish the field's own number (what the capsule showed), never the sum of the players' clamps.
|
||||
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
/** m.public.fields[].progress of a finished client-combat field (the done branch of Match._fieldProgress). */
|
||||
const doneView = (result, progress = { killed: 0, total: 0, leaks: 0, resolved: null }) => MatchViews.prototype._fieldProgress.call({}, { cc: true, done: true, result, progress });
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
/** A 联防 spec of two helpers who fielded nothing, and one escaped enemy that walks the whole lane (col 18 → col 2). */
|
||||
function uniteCrossSpec() {
|
||||
return buildBattleSpec({
|
||||
battleId: 'mid-u', fieldId: 'u', kind: 'unite', seed: 11, modeId: 'mode_multi_normal', round: 1, stageId: 'act1autochess_m02',
|
||||
rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, timeLimit: 90,
|
||||
players: [
|
||||
{ playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} }, // the half the lane starts on (col 18)
|
||||
{ playerId: 'p_2', seat: 2, side: 'L', colOffset: 0, units: [], bonds: {} }, // the half it leaves on (col 2)
|
||||
],
|
||||
routes: [{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] }],
|
||||
spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 1, sourcePlayerId: 'p_0', mods: { hpMul: 0.8, atkMul: 0.8, speedMul: 1, slot: 'N' } }],
|
||||
flags: { layerGainsEnabled: false, dpInit: 10, dpPerSec: 1, dpMax: 99 },
|
||||
});
|
||||
}
|
||||
const newBattle = (spec) => createBattleFromSpec(spec, null, { recordEvents: false, quiet: true });
|
||||
|
||||
test('联防 whose escaped enemies enter on one helper\'s half and leak on the other\'s: the finished field publishes the capsule\'s own 4/4, not 0/4', () => {
|
||||
let ran = null;
|
||||
// every client runs the 联防 spec with the REAL sim (the fake battles only stand in for the normal round); the helpers
|
||||
// fielded nothing, so the leaker's four escaped enemies walk the whole lane and leak on the far half
|
||||
const realUnite = (result, spec) => {
|
||||
if (spec.kind !== 'unite') return result;
|
||||
const b = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true });
|
||||
const res = b.runToEnd(4000);
|
||||
ran = { res, capsule: battleProgress(b) };
|
||||
return compactResult(res);
|
||||
};
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
|
||||
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}),
|
||||
perPlayer: Object.fromEntries(['p_0', 'p_1', 'p_2'].map((pid) => [pid, { tamper: realUnite }])) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const unite = () => fields(h).find((x) => x.fieldId === 'u');
|
||||
assert.ok(h.drive(() => (unite() && unite().done) || h.ended != null) && unite().done, 'the 联防 field finished');
|
||||
const f = unite();
|
||||
assert.equal(f.resultSource, 'client', 'the authority\'s own result was accepted');
|
||||
assert.ok(ran, 'the authority ran the 联防 spec with the real sim');
|
||||
|
||||
// the premise: the enemies crossed the midline — `total` on the half they entered, the leaks on the half they left
|
||||
const per = Object.values(ran.res.perPlayer);
|
||||
assert.deepEqual(per.map((p) => p.total).sort(), [0, 4], 'every total is billed to the half the enemies spawned on');
|
||||
assert.deepEqual(per.map((p) => p.leaked.length).sort(), [0, 4], 'every leak to the half they reached');
|
||||
assert.deepEqual(per.map((p) => p.resolved), [0, 0], 'so each player\'s own min(total, …) reads 0');
|
||||
assert.equal(ran.capsule.resolved, 4, 'while the field\'s capsule read 4/4');
|
||||
assert.equal(ran.res.resolved, 4);
|
||||
|
||||
// what the server keeps and publishes is the field's number
|
||||
assert.equal(f.result.resolved, 4, 'the validated result carries the field-level numerator');
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'u'), { killed: 0, resolved: 4, total: 4, done: true }, 'm.public: the capsule\'s own 4/4');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the boss pair: an enemy spawned on one half that leaks on the other half\'s goal reads 1/1 in the published progress too', () => {
|
||||
const PAIR = [
|
||||
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} },
|
||||
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {} },
|
||||
];
|
||||
// route 3 of the flat boss stage: the centre gate (col 10, the left half's) → the right goal (col 18, the right half's)
|
||||
const h = makeBattle({ kind: 'boss', players: PAIR, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 3 }], timeLimit: Infinity, content: 'none', autoFinish: true });
|
||||
h.runToEnd(120);
|
||||
const res = h.result();
|
||||
assert.equal(res.reason, 'cleared');
|
||||
assert.equal(res.perPlayer.L.total, 1, 'billed to the half it spawned on');
|
||||
assert.equal(res.perPlayer.R.leaked.length, 1, 'leaked on the other half');
|
||||
assert.deepEqual([res.perPlayer.L.resolved, res.perPlayer.R.resolved], [0, 0], 'each player\'s own clamp reads 0');
|
||||
assert.equal(res.resolved, 1, 'the field resolved it');
|
||||
|
||||
const spec = buildBattleSpec({ battleId: 'mid-b', fieldId: 'b:pair', kind: 'boss', seed: 3, round: 8, players: PAIR,
|
||||
spawns: [{ time: 0, enemyKey: 'enemy_1007_slime', routeIndex: 3, count: 1 }], flags: { layerGainsEnabled: false } });
|
||||
const v = validateClientResult(spec, compactResult(res), { gd });
|
||||
assert.ok(v.ok, v.reason);
|
||||
assert.equal(v.result.resolved, 1);
|
||||
assert.deepEqual(doneView(v.result), { killed: 0, resolved: 1, total: 1, done: true });
|
||||
});
|
||||
|
||||
test('a server-run 联防 field (a bot, a takeover) reads the same: Battle.result()\'s field-level number, and so does its timeline', () => {
|
||||
const spec = uniteCrossSpec();
|
||||
const run = runHeadless(newBattle(spec), { players: spec.players.map((p) => p.playerId) });
|
||||
const res = run.result;
|
||||
assert.equal(res.reason, 'cleared');
|
||||
assert.deepEqual(Object.values(res.perPlayer).map((p) => [p.total, p.leaked.length, p.resolved]).sort(), [[0, 1, 0], [1, 0, 0]], 'the lane crossed the midline');
|
||||
assert.equal(res.resolved, 1);
|
||||
assert.deepEqual(doneView(res), { killed: 0, resolved: 1, total: 1, done: true }, 'the done field of a server run');
|
||||
assert.equal(run.timeline[run.timeline.length - 1][3], 1, 'the live timeline sample of that field read 1 too');
|
||||
// SP_VERIFY compares a client result with the server's run: both go through the same validation
|
||||
assert.equal(validateClientResult(spec, compactResult(res), { gd }).result.resolved, 1);
|
||||
});
|
||||
|
||||
test('validateClientResult: the field-level resolved is kept as reported (clamped to the field\'s total); the players\' own numbers are only the fallback', () => {
|
||||
const spec = uniteCrossSpec();
|
||||
const raw = compactResult(newBattle(spec).runToEnd(200));
|
||||
assert.equal(raw.resolved, 1);
|
||||
const checked = (edit) => {
|
||||
const r = clone(raw);
|
||||
edit(r);
|
||||
const v = validateClientResult(spec, r, { gd });
|
||||
assert.ok(v.ok, v.reason);
|
||||
return v.result;
|
||||
};
|
||||
assert.equal(checked(() => {}).resolved, 1, 'as the client reported it (the per-player numbers are both 0 here)');
|
||||
assert.equal(checked((r) => { r.resolved = 0; }).resolved, 0, 'a reported 0 is a value');
|
||||
assert.equal(checked((r) => { r.resolved = 99; }).resolved, 1, 'never above the field\'s total');
|
||||
// an older client (or a hand-made result) that reports no field-level number: the players' own, when every one has one
|
||||
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 1; }).resolved, 1, 'the fallback is the sum of the players\' own numbers');
|
||||
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 5; r.perPlayer.p_2.resolved = 5; }).resolved, 1, 'the fallback too is clamped to the total');
|
||||
// a number that is not a non-negative integer counts as not reported
|
||||
for (const junk of [-1, 0.5, '1', null, NaN]) assert.equal(checked((r) => { r.resolved = junk; r.perPlayer.p_1.resolved = 1; }).resolved, 1, `junk ${String(junk)} → the fallback`);
|
||||
// nothing reported anywhere, or one player without a number: absent — the teammate UI falls back to `killed`, never to a fabricated 0
|
||||
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; }), false);
|
||||
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_2.resolved; }), false);
|
||||
assert.equal(doneView(checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; })).resolved, null, 'm.public: null, not 0');
|
||||
});
|
||||
|
||||
test('a finished field\'s progress: the result\'s field-level numerator wins, the players\' own are the fallback, a synthetic result keeps the field\'s live report', () => {
|
||||
const pp = (o) => ({ killed: 0, total: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, ...o });
|
||||
// the field-level number wins over the players' own (a crossing field sums to less than it resolved)
|
||||
assert.deepEqual(doneView({ perPlayer: { a: pp({ total: 3, resolved: 0 }), b: pp({ total: 0, resolved: 0 }) }, resolved: 2 }), { killed: 0, resolved: 2, total: 3, done: true });
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 9 }).resolved, 3, 'clamped to the published total');
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 0 }).resolved, 0, 'a reported 0 is a value');
|
||||
// no field-level number: the sum of the players' own when every one reported, else unknown (never a fabricated 0)
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({ resolved: 2 }) } }).resolved, 3);
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({}) } }).resolved, null);
|
||||
assert.equal(doneView({ perPlayer: { a: pp({}) } }).resolved, null);
|
||||
// a synthetic result (the field could not run: zeroes everywhere) shows the last live report instead
|
||||
const synthetic = { synthetic: true, resolved: 0, perPlayer: { a: pp({ total: 0, resolved: 0 }) } };
|
||||
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: 3 }), { killed: 2, resolved: 3, total: 5, done: true });
|
||||
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: null }), { killed: 2, resolved: null, total: 5, done: true });
|
||||
});
|
||||
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
|
||||
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
|
||||
// time") but the lobby tests only removed one in the lobby: this is the running match.
|
||||
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
|
||||
FakeBattle.reset();
|
||||
FakeBattle.script = () => ({ duration: 1 });
|
||||
const watcher = async (name) => {
|
||||
const srvNow = await server();
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
|
||||
clients.push(c);
|
||||
const w = await c.hello(name);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
};
|
||||
const a = await player('A');
|
||||
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
const st = await a.waitFor('room.state');
|
||||
await ok(a, { t: 'room.addBot' });
|
||||
const s1 = await watcher('Spec1');
|
||||
const s2 = await watcher('Spec2');
|
||||
await ok(s1, { t: 'room.spectate', code: st.code });
|
||||
await ok(s2, { t: 'room.spectate', code: st.code });
|
||||
await ok(a, { t: 'room.start' });
|
||||
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await pickCoopBands([a]);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
|
||||
|
||||
// who may remove: the host only, and only a spectator of this room
|
||||
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
|
||||
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
|
||||
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
|
||||
|
||||
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
|
||||
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
|
||||
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
|
||||
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
|
||||
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
|
||||
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
|
||||
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
|
||||
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
|
||||
|
||||
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
|
||||
const mark = s1.log.length;
|
||||
s2.log.length = 0;
|
||||
await ok(a, { t: 'g.ready', ready: true });
|
||||
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
|
||||
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
|
||||
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
|
||||
|
||||
// the freed seat can be taken again (no ban list: the key is all it takes)
|
||||
const s3 = await watcher('Spec3');
|
||||
await ok(s3, { t: 'room.spectate', code: st.code });
|
||||
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
|
||||
assert.deepEqual(errors, []);
|
||||
await ok(a, { t: 'g.leave' });
|
||||
});
|
||||
|
||||
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
|
||||
FakeBattle.reset();
|
||||
FakeBattle.script = () => ({ duration: 1 });
|
||||
|
||||
@@ -22,7 +22,12 @@ test('observeTarget: prep, own battle running / over, boss pairs, eliminated pla
|
||||
assert.deepEqual(observeTarget(players[1], combat(true), 'a', { ownDone: true }), { fieldId: 'n:b' }, 'the local battle already ended');
|
||||
assert.deepEqual(observeTarget(players[1], combat(false), 'a'), { fieldId: 'n:b' });
|
||||
assert.deepEqual(observeTarget(players[0], combat(false), 'a', { observing: true }), { back: true });
|
||||
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, total: 8, done: false }]);
|
||||
// the teammate's capsule reads `resolved` (the field's own enemies knocked out or leaked); a report without one falls
|
||||
// back to `killed` (public/js/battle/observe.js teammateProgress)
|
||||
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, resolved: 3, total: 8, done: false }]);
|
||||
const reported = pubOf('COMBAT', [{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true, progress: { killed: 5, resolved: 3, total: 8, done: false } }], players);
|
||||
assert.deepEqual(teammateProgress(reported, 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 5, resolved: 3, total: 8, done: false }],
|
||||
'a reported `resolved` wins over `killed` (5 counted knock-outs — splits included —, 3 of the round\'s own enemies resolved)');
|
||||
const fa = pubOf('FINAL_ASSAULT', [
|
||||
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
|
||||
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
|
||||
|
||||
@@ -430,7 +430,13 @@ test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter
|
||||
seen.push(p.uniteLeft);
|
||||
h.sched.advance(500);
|
||||
}
|
||||
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
|
||||
// the split children are runtime spawns: they enter neither part of the HUD capsule (PR #157: the denominator counts
|
||||
// only what the 联防 scheduled — `b.total` — and the numerator only that set's own knock-outs / leaks), while the
|
||||
// 联防 live counter (m.public uniteLeft) still bills them to the leaker
|
||||
const kids = b.units.filter((u) => u.side === 'enemy' && !u.inTotal).length;
|
||||
assert.ok(kids > 0, `the 磨砻 split into ${kids} runtime children`);
|
||||
assert.equal(b.total, SENT, `the children stay out of the capsule's denominator (total = the ${SENT} the 联防 scheduled)`);
|
||||
assert.equal(b.resolved, Math.min(SENT, b.killedInTotal + b.leakedInTotal), 'the capsule numerator counts only the 联防\'s own enemies');
|
||||
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
|
||||
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
|
||||
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
|
||||
|
||||
@@ -129,6 +129,8 @@ export class SimClient {
|
||||
_progress(e) {
|
||||
const p = battleProgress(e.battle);
|
||||
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
|
||||
// the HUD capsule's numerator, like public/js/battle/runner.js (kept only when the battle reports one)
|
||||
if (Number.isFinite(p.resolved)) msg.resolved = Math.min(p.resolved, p.total);
|
||||
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
|
||||
if (bossLike) {
|
||||
const pool = e.battle.sharedBoss;
|
||||
|
||||
@@ -298,7 +298,12 @@ test('real data: R5 template on act1 m04 (crates) with a real lineup runs to the
|
||||
assert.ok(h.b.allyUnits.some((u) => u.kind === 'device'), 'm04 crates present');
|
||||
const r = h.result();
|
||||
const p = r.perPlayer.p1;
|
||||
assert.equal(p.killed + p.leaked.filter((l) => l.counted).length, p.total, 'every counted enemy is killed or leaked');
|
||||
// the capsule identity (PR #157): this round's own scheduled enemies are all 已解决 — knocked down or leaked. It
|
||||
// replaces `killed + counted leaks = total`, which mixed the counted reading (runtime splits / summons included) with
|
||||
// the scheduled denominator: `killed` may now exceed `total`, so only `≥` holds for it, while the capsule pair is exact
|
||||
assert.ok(p.killed + p.leaked.filter((l) => l.counted).length >= p.total, 'every counted enemy is killed or leaked');
|
||||
assert.equal(p.killedInTotal + p.leakedInTotal, p.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
|
||||
assert.equal(p.resolved, p.total);
|
||||
});
|
||||
|
||||
test('generic kit never throws and yields finite numbers for every real chess (normal & elite)', () => {
|
||||
|
||||
+14
-2
@@ -106,9 +106,21 @@ test('fuzz: 200 random battles on real data run to completion with invariants',
|
||||
const r = b.result();
|
||||
stats[r.reason]++;
|
||||
for (const [pid, pp] of Object.entries(r.perPlayer)) {
|
||||
assert.ok(pp.killed <= pp.total || kind === 'unite', `${pid} killed ≤ total`);
|
||||
// the capsule's own pair: `total` counts only the enemies this field scheduled (`inTotal`), and its numerator
|
||||
// never exceeds it. `killed` deliberately keeps the `counted` reading — a runtime split child / summon knocked
|
||||
// down counts there — so `killed > total` is legal since PR #157's capsule semantics (a 4-磨砻 round killed 4
|
||||
// plus a child reads 5/4 on nothing but `killed/total`; the capsule reads resolved/total = 4/4)
|
||||
assert.ok(pp.killedInTotal <= pp.total, `${pid} killedInTotal ≤ total`);
|
||||
assert.ok(pp.resolved <= pp.total, `${pid} resolved ≤ total`);
|
||||
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
|
||||
if (r.reason === 'cleared' && kind === 'normal') assert.equal(pp.killed + pp.leaked.filter((l) => l.counted).length, pp.total, 'every counted enemy killed or leaked');
|
||||
// the capsule identity: every one of the field's own enemies is 已解决 — knocked down (killedInTotal) or leaked
|
||||
// (leakedInTotal). It replaces the old `killed + counted leaks = total`, which mixed the counted reading with the
|
||||
// scheduled denominator and no longer holds (killed counts runtime children, total does not)
|
||||
if (r.reason === 'cleared' && kind === 'normal') {
|
||||
assert.ok(pp.killed + pp.leaked.filter((l) => l.counted).length >= pp.total, 'every counted enemy killed or leaked');
|
||||
assert.equal(pp.killedInTotal + pp.leakedInTotal, pp.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
|
||||
assert.equal(pp.resolved, pp.total, 'a cleared normal field resolves its own list');
|
||||
}
|
||||
}
|
||||
JSON.stringify(r);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// test/sim/hud-capsule-resolved.test.js — the HUD capsule's own counters (PR #157, maintainer-reviewed direction; the
|
||||
// bounty / kill semantics stay master's):
|
||||
// resolved = min(total, killedInTotal + leakedInTotal) — the field's OWN scheduled enemies (`inTotal`) that are
|
||||
// 已解决: knocked down or leaked. The official example: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
|
||||
// A runtime spawn — a split child, a summon (a boss's included), a part — is in neither the numerator nor the
|
||||
// denominator, while `counted` (the LP / 完美作战 / `killed` reading) keeps counting it exactly as master did.
|
||||
// Run: node --test test/sim/hud-capsule-resolved.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { setupMatchWaves, buildBossWave } from '../../server/match/waves.js';
|
||||
import { createRng, deriveSeed } from '../../server/sim/rng.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {}, log() {}, debug() {} };
|
||||
/** The real mode config: the per-round LP cap settlement charges min(cap, counted leaks) with (match/settle.js). */
|
||||
const gd = new GameData(getData({ log: QUIET }), 'mode_multi_normal');
|
||||
|
||||
const MOLANG = 'enemy_1195_sfyin'; // 磨砻 — 被击倒后生成2个<木制瑞印> (data talents DeadSpawn)
|
||||
const MOLANG_CHILD = 'enemy_1196_msfyin'; // 木制瑞印
|
||||
const APOSTLE = 'enemy_1321_wdarft'; // 枯朽萃聚使徒 — 召唤数个<枯朽之种> while it lives
|
||||
const SEED = 'enemy_1269_nhfly'; // 枯朽之种
|
||||
const done = (h) => {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
};
|
||||
const capsule = (b) => ({ total: b.total, killedInTotal: b.killedInTotal, leakedInTotal: b.leakedInTotal, resolved: b.resolved });
|
||||
|
||||
test('官方例子: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3 (分裂子体不进胶囊)', () => {
|
||||
const h = makeBattle({
|
||||
seed: 11, autoFinish: false, timeLimit: 300,
|
||||
enemies: [
|
||||
{ key: 'enemy_1195_sfyin', pos: [9, 6], count: 1 },
|
||||
{ key: 'enemy_1005_yokai', pos: [9, 8], count: 2 },
|
||||
],
|
||||
});
|
||||
h.step();
|
||||
const molang = h.b.enemies.find((e) => e.defId === MOLANG);
|
||||
const yokai = h.b.enemies.filter((e) => e.defId === 'enemy_1005_yokai');
|
||||
assert.equal(h.b.total, 3, '分母 = 本关自己排定的 3 个敌人');
|
||||
assert.ok(molang.inTotal && yokai.every((e) => e.inTotal), '排定的敌人 inTotal');
|
||||
assert.equal(h.b.resolved, 0, '0/3');
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.resolved, 0, 'b.snap carries the capsule numerator');
|
||||
assert.equal(snap.total, 3);
|
||||
|
||||
h.b.leak(yokai[0]); // 漏一个
|
||||
assert.equal(h.b.resolved, 1, '1/3');
|
||||
|
||||
h.b.kill(yokai[1], null); // 打死一个
|
||||
assert.equal(h.b.resolved, 2, '2/3');
|
||||
|
||||
h.b.kill(molang, null); // 打死会分裂的
|
||||
assert.equal(h.b.killedInTotal + h.b.leakedInTotal, 3, '三个排定的敌人都已解决 (2 击倒 + 1 漏掉)');
|
||||
assert.equal(h.b.killedInTotal, 2);
|
||||
assert.equal(h.b.leakedInTotal, 1);
|
||||
assert.equal(h.b.resolved, 3, '3/3');
|
||||
assert.equal(h.b.total, 3, '分裂出来的子体没有抬高分母');
|
||||
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
|
||||
assert.equal(kids.length, 2, '磨砻 split into 2 木制瑞印');
|
||||
for (const k of kids) assert.ok(!k.inTotal && k.counted, 'a split child counts for LP, not for the capsule');
|
||||
assert.equal(h.b.resolved, 3, 'the children move no capsule number');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('磨砻 4 个全被击倒 → 胶囊 4/4;它们 8 个子体全漏 → leakedInTotal 仍 0、counted 口径的 LP 照扣 min(10, 8) = 8', () => {
|
||||
const h = makeBattle({ seed: 12, autoFinish: false, timeLimit: 300, enemies: [{ key: MOLANG, pos: [9, 6], count: 4 }] });
|
||||
h.step();
|
||||
const molang = h.b.enemies.filter((e) => e.defId === MOLANG && e.alive);
|
||||
assert.equal(molang.length, 4);
|
||||
assert.equal(h.b.total, 4, '分母 = 排定的 4 个磨砻');
|
||||
for (const e of molang) h.b.kill(e, null);
|
||||
assert.equal(h.b.killedInTotal, 4);
|
||||
assert.equal(h.b.resolved, 4, '胶囊 4/4 — 击倒的是本关自己的敌人');
|
||||
assert.equal(h.b.total, 4, '子体不进分母');
|
||||
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
|
||||
assert.equal(kids.length, 8, '四个磨砻各分裂出 2 个木制瑞印');
|
||||
assert.equal(h.b.leakedInTotal, 0);
|
||||
|
||||
for (const k of kids) h.b.leak(k);
|
||||
// the capsule: the leaked children are no enemies of the stage's own list — the numerator stays at 4/4
|
||||
assert.deepEqual(capsule(h.b), { total: 4, killedInTotal: 4, leakedInTotal: 0, resolved: 4 });
|
||||
// the LP reading is master's, unchanged: every leaked child is a counted leak, and settlement charges min(cap, 8)
|
||||
// (server/match/settle.js `Math.min(cap, counted)`); leakedCount is the count the LP meter reads
|
||||
assert.equal(h.b.leakedCount, 8);
|
||||
const r = h.result();
|
||||
const countedLeaks = r.perPlayer.p1.leaked.filter((l) => l.counted !== false).length;
|
||||
assert.equal(countedLeaks, 8, '8 counted leaks — the children each cost LP as before');
|
||||
assert.equal(Math.min(gd.lpCapPerRound, countedLeaks), 8, 'the settled charge is min(10, 8) = 8, exactly as master');
|
||||
assert.equal(r.perPlayer.p1.perfect, false, 'a counted leak still breaks 完美作战');
|
||||
assert.equal(r.perPlayer.p1.resolved, 4, 'BattleResult carries the capsule numerator');
|
||||
assert.equal(r.resolved, 4);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('分裂子体被击倒: 胶囊数字不动,`killed` 仍按 counted 口径 +1 (master 的击杀语义不变)', () => {
|
||||
const h = makeBattle({ seed: 13, autoFinish: false, timeLimit: 300, enemies: [{ key: MOLANG, pos: [9, 6] }] });
|
||||
h.step();
|
||||
const molang = h.b.enemies.find((e) => e.defId === MOLANG);
|
||||
h.b.kill(molang, null);
|
||||
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
|
||||
assert.equal(kids.length, 2);
|
||||
const before = capsule(h.b);
|
||||
const killedBefore = h.b.killed;
|
||||
const coinsBefore = h.result().perPlayer.p1.coins;
|
||||
h.b.kill(kids[0], null);
|
||||
assert.deepEqual(capsule(h.b), before, 'a knocked-down split child moves no capsule number');
|
||||
assert.equal(h.b.killed, killedBefore + 1, '`killed` counts every counted knock-out (master semantics, unchanged)');
|
||||
assert.equal(h.result().perPlayer.p1.coins, coinsBefore, 'no bounty for a split child');
|
||||
h.b.leak(kids[1]);
|
||||
assert.deepEqual(capsule(h.b), before, 'a leaked split child moves no capsule number either');
|
||||
assert.equal(h.b.leakedCount, 1, '… but it is a counted leak (LP / 完美作战)');
|
||||
assert.equal(h.b.resolved, 1, 'the capsule stands at 1/1 — the 磨砻 itself');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('枯朽萃聚使徒: 召唤出的枯朽之种出生 / 被击倒 / 漏掉都不动胶囊,本体照旧结算 (悬赏口径不变)', () => {
|
||||
const card = { id: 'enemyeffect_10_8', card: { payout: 'kill', coin: 2, enemyKey: APOSTLE } };
|
||||
const h = makeBattle({
|
||||
seed: 14, autoFinish: false, timeLimit: 300,
|
||||
enemies: [{ key: APOSTLE, route: 2, mods: { hpMul: 1, bountyId: card.id }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
|
||||
});
|
||||
assert.ok(h.runUntil(() => h.b.enemies.filter((e) => e.alive && e.defId === SEED).length >= 2, 30), 'BornBugs cast');
|
||||
const seeds = h.b.enemies.filter((e) => e.alive && e.defId === SEED);
|
||||
assert.equal(h.b.total, 1, '分母只有排定的本体 — 召唤物不进分母');
|
||||
for (const s of seeds) {
|
||||
assert.ok(!s.inTotal, 'a summoned 枯朽之种 is not in the capsule');
|
||||
assert.ok(s.counted, '… but it counts for LP / 完美作战 (master)');
|
||||
assert.equal(s.bounty, null, '悬赏只在召唤物本体上 (master rule, untouched)');
|
||||
}
|
||||
const before = capsule(h.b);
|
||||
const killedBefore = h.b.killed;
|
||||
h.b.kill(seeds[0], null);
|
||||
assert.deepEqual(capsule(h.b), before, 'the summoned seed\'s knock-out moves no capsule number');
|
||||
assert.equal(h.b.killed, killedBefore + 1, '`killed` still counts it (master)');
|
||||
assert.deepEqual(h.eventsOf('bounty'), [], 'a summoned seed pays no bounty');
|
||||
h.b.leak(seeds[1]);
|
||||
assert.deepEqual(capsule(h.b), before, 'the summoned seed\'s leak moves no capsule number');
|
||||
assert.equal(h.b.leakedCount, 1, 'counted leak: the LP charge is unchanged from master');
|
||||
// the body itself is the field's own scheduled enemy: its knock-out resolves the capsule and pays once
|
||||
const body = h.b.enemies.find((e) => e.alive && e.defId === APOSTLE);
|
||||
assert.ok(body && body.inTotal);
|
||||
h.b.kill(body, null);
|
||||
assert.deepEqual(h.eventsOf('bounty'), [['bounty', 'p1', 2]], 'the body pays once (master rule)');
|
||||
assert.equal(h.b.resolved, h.b.total, 'the field\'s own list is resolved');
|
||||
done(h);
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// A real Boss round: a boss summon is a runtime spawn too. The 假想敌:管 (boss_3, act1autochess_h07_03) summons a
|
||||
// 余音 (enemy_9023_acdums) every 40 game s (20 below half HP); its wave ALSO schedules 2 余音 of its own — those are
|
||||
// the round's own enemies and do enter the capsule, the summoned ones do not.
|
||||
|
||||
test('Boss 召唤物 (假想敌:管的余音) 出生 / 被击倒 / 漏掉都不动胶囊;Boss 死后 resolved === total', () => {
|
||||
const bossId = 'boss_3';
|
||||
const ECHO = 'enemy_9023_acdums'; // 余音 — 管 summons it every 40 game s (20 s below half HP)
|
||||
const seed = deriveSeed(20261005, `boss:${bossId}:p`);
|
||||
const wave = buildBossWave(gd, createRng(deriveSeed(seed, 'waves')), setupMatchWaves(gd, createRng(deriveSeed(seed, 'setup'))), gd.bossRound, { bossId, solo: false });
|
||||
const h = makeBattle({
|
||||
kind: 'boss', seed: 21, autoFinish: false,
|
||||
routes: wave.routes,
|
||||
enemies: wave.spawns.map((s) => ({ key: s.enemyKey, time: s.time, route: s.routeIndex, count: s.count, interval: s.interval, mods: s.mods, tag: s.tag, countInTotal: s.countInTotal })),
|
||||
});
|
||||
// the round's own list: 2 疏术士 + 2 疏den + 2 余音 (the wave's own 余音, scheduled 40 s apart) — the 领袖 (tag 'boss')
|
||||
// and everything 管 summons are outside it. 会召唤的 Boss 关胶囊 therefore fills up now (the old `total++` of a runtime
|
||||
// spawn left it short forever).
|
||||
const scheduled = wave.spawns.filter((s) => s.tag !== 'boss' && s.countInTotal !== false).reduce((n, s) => n + s.count, 0);
|
||||
assert.equal(scheduled, 6);
|
||||
assert.equal(h.b.total, scheduled, `分母 = 本关排定且该计入的 ${scheduled} 个敌人`);
|
||||
h.run(3); // the 领袖 comes in at t = 1 s, the wave's first 余音 at t = 0
|
||||
const leader = h.b.enemies.find((e) => e.defId === 'enemy_9021_acduml');
|
||||
assert.ok(leader, 'the 领袖 is on the field');
|
||||
assert.ok(!leader.inTotal && !leader.counted, 'the 领袖 (tag boss) is in no capsule part');
|
||||
assert.equal(h.b.units.filter((u) => u.side === 'enemy' && u.inTotal && u.defId === ECHO).length, 1, 'the wave scheduled its own 余音');
|
||||
assert.equal(h.b.total, scheduled, 'the 领袖 does not add to the denominator (tag boss ⇒ not counted)');
|
||||
|
||||
// the boss summons one of its own (pipeCore, bosses.js: `spawnEnemy` — never the spawn queue)
|
||||
const runtime = () => h.b.units.find((u) => u.side === 'enemy' && u.alive && !u.inTotal && u.defId === ECHO);
|
||||
assert.ok(h.runUntil(() => !!runtime(), 90), 'the boss summoned a 余音');
|
||||
const first = runtime();
|
||||
assert.ok(first.counted, 'the summon still counts for LP (master)');
|
||||
assert.equal(h.b.total, scheduled, 'the summoned 余音 never raised the denominator (the old defect: an unfillable capsule)');
|
||||
const before = capsule(h.b);
|
||||
const killedBefore = h.b.killed;
|
||||
h.b.kill(first, null);
|
||||
assert.deepEqual(capsule(h.b), before, 'a knocked-down boss summon moves no capsule number');
|
||||
assert.equal(h.b.killed, killedBefore + 1, '`killed` counts it (master semantics unchanged)');
|
||||
|
||||
assert.ok(h.runUntil(() => !!runtime(), 120), 'a second summon');
|
||||
const beforeLeak = capsule(h.b);
|
||||
const leakedBefore = h.b.leakedCount;
|
||||
h.b.leak(runtime());
|
||||
assert.deepEqual(capsule(h.b), beforeLeak, 'a leaked boss summon moves no capsule number');
|
||||
assert.equal(h.b.leakedCount, leakedBefore + 1, 'a counted leak — the LP charge follows the real data (`counted`), as master');
|
||||
|
||||
// the leader itself: tag 'boss' ⇒ counted false ⇒ in no capsule number, and killing it is no `killed` either (master)
|
||||
const killedBeforeLeader = h.b.killed;
|
||||
h.b.kill(leader, null);
|
||||
assert.equal(h.b.killed, killedBeforeLeader, 'the leader is not a counted knock-out (master reading)');
|
||||
// every one of the round's own enemies resolved ⇒ the capsule is full
|
||||
for (const e of h.b.enemies.filter((e) => e.alive && e.inTotal)) h.b.kill(e, null);
|
||||
for (const e of h.b.enemies.filter((e) => e.alive && e.inTotal)) h.b.leak(e);
|
||||
assert.equal(h.b.killedInTotal + h.b.leakedInTotal, h.b.total);
|
||||
assert.equal(h.b.resolved, h.b.total, 'Boss 死后 resolved === total — the summons never pushed the denominator up');
|
||||
assert.equal(h.b.total, scheduled, 'nor did they move it at the end');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('kept honest: a helper enemy record the tests build is a scheduled enemy (inTotal) while spawnEnemy is not', () => {
|
||||
const h = makeBattle({
|
||||
seed: 31, autoFinish: false, timeLimit: 60,
|
||||
defs: { enemies: { enemy_helper: enemyRec({ key: 'enemy_helper', hp: 10, speed: 0 }) } },
|
||||
enemies: [{ key: 'enemy_helper' }],
|
||||
});
|
||||
h.step();
|
||||
assert.equal(h.b.total, 1);
|
||||
assert.equal(h.b.enemies[0].inTotal, true);
|
||||
const runtime = h.spawn('enemy_helper', { pos: [9, 4] });
|
||||
assert.equal(h.b.total, 1, 'a runtime spawnEnemy is not in the denominator');
|
||||
assert.ok(!runtime.inTotal && runtime.counted, '… and not in the capsule, while counted stays master\'s');
|
||||
const explicit = h.b.spawnEnemy('enemy_helper', { pos: [9, 3], inTotal: true }); // the content back door
|
||||
assert.equal(h.b.total, 2, 'an explicit `inTotal: true` is honoured');
|
||||
assert.ok(explicit.inTotal);
|
||||
assert.equal(h.b.enemies.filter((e) => e.alive).length, 3);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// Browser checks of the stage-device tip (GitHub #228, idea of PR #229 by @rickylxw; the terrain tip of #184 for devices) on
|
||||
// the in-match mock harness (public/dev/game-mock.html):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/device-tip.e2e.test.js
|
||||
//
|
||||
// A tap on a STAGE DEVICE standing on the board — 阻隔工事 here (the mock's default stage act2autochess_m01 keeps its
|
||||
// blowers and its dormant turrets off the prep field, so ?stage=act1autochess_m01 boards crates within it) — opens the
|
||||
// device's own card, on both field implementations: the engine canvas (render/app.js `tileClick`, reached by clicking
|
||||
// the tile's own screen position) and the DOM fallback (ui/fallbackField.js, the tile div). Asserted: the card names
|
||||
// the device and its mechanism, an ordinary tile closes it again, and no scenario logs a console error. The words and
|
||||
// the numbers are unit-tested in test/ui/device-tip.test.js (deviceInfo / deviceTipAt, against every device of the real
|
||||
// stages and a real battle) — this file is about the tap reaching them.
|
||||
|
||||
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));
|
||||
|
||||
/** Tiles of the boarded stage (act1autochess_m01) the tip must answer for, and one ordinary tile it must not. */
|
||||
const CRATE_STAGE = 'act1autochess_m01';
|
||||
const CRATE = { row: 10, col: 5, name: '阻隔工事' };
|
||||
const FLOOR = { row: 12, col: 9, name: null };
|
||||
|
||||
describe('stage device tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
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 });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
|
||||
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** The open detail card's text (null while none is open). */
|
||||
const card = (page) => page.evaluate(() => {
|
||||
const el = document.querySelector('.dpanel');
|
||||
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||
});
|
||||
|
||||
test('the fallback (DOM) board: a tap on the crate opens its card, an ordinary tile opens nothing', async () => {
|
||||
const { page, problems } = await open(`/dev/game-mock.html?phase=PREP&render=fallback&stage=${CRATE_STAGE}`);
|
||||
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||
await sleep(400);
|
||||
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||
if (!el) return false;
|
||||
const b = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||
return true;
|
||||
}, [row, col]);
|
||||
|
||||
assert.ok(await tap(CRATE.row, CRATE.col), 'the mock board draws the crate tile');
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
const text = await card(page);
|
||||
assert.match(text, new RegExp(CRATE.name), text);
|
||||
assert.match(text, /场地装置/, text);
|
||||
assert.match(text, /绕开它走/, text);
|
||||
|
||||
// an ordinary tile: nothing to explain, and the card that was open closes with the press
|
||||
assert.ok(await tap(FLOOR.row, FLOOR.col));
|
||||
await sleep(200);
|
||||
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
|
||||
const { page, problems } = await open(`/dev/game-mock.html?phase=PREP&stage=${CRATE_STAGE}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await sleep(1200); // models / camera
|
||||
const at = (row, col) => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [row, col]);
|
||||
const pt = await at(CRATE.row, CRATE.col);
|
||||
assert.ok(pt, 'the crate tile has a screen position');
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
const text = await card(page);
|
||||
assert.match(text, new RegExp(CRATE.name), text);
|
||||
assert.match(text, /绕开它走/, text);
|
||||
|
||||
const floor = await at(FLOOR.row, FLOOR.col);
|
||||
await page.mouse.click(floor.x, floor.y);
|
||||
await sleep(250);
|
||||
assert.equal(await card(page), null, 'the floor tile closes it again');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
// A battle: the crates are device UNITS that come in with 'spawn' events after the field meta (the mock's ?variant=devices
|
||||
// streams them like the sim). The tap finds the unit, the card draws its live HP, and it closes when the crate is destroyed.
|
||||
const hpText = (page) => page.evaluate(() => document.querySelector('.dpanel .dhp')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
|
||||
|
||||
test('a battle on the fallback board: the crate unit\'s card shows its live HP and closes when it is destroyed', async () => {
|
||||
const { page, problems } = await open(`/dev/game-mock.html?phase=COMBAT&variant=devices&render=fallback&stage=${CRATE_STAGE}`);
|
||||
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||
await page.waitForFunction(() => globalThis.__MOCK__?.S()?.battle?.devices?.length > 0, { timeout: 10000 });
|
||||
await sleep(600); // the spawn events and the first snapshots
|
||||
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||
if (!el) return false;
|
||||
const b = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||
return true;
|
||||
}, [row, col]);
|
||||
const unit = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[0]; return { row: d.y, col: d.x }; });
|
||||
assert.ok(await tap(unit.row, unit.col), 'the board draws the crate unit\'s tile');
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
const text = await card(page);
|
||||
assert.match(text, new RegExp(CRATE.name), text);
|
||||
assert.match(text, /绕开它走/, text);
|
||||
assert.equal(await hpText(page), '100 / 100');
|
||||
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 40)));
|
||||
await page.waitForFunction(() => /60 \/ 100/.test(document.querySelector('.dpanel .dhp')?.textContent || ''), { timeout: 5000 });
|
||||
// destroyed: its HP bar runs out, then its card closes with it (the tuple leaves the snapshots)
|
||||
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 100)));
|
||||
await page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 5000 });
|
||||
// a destroyed crate explains nothing any more: the tap falls through to the (ordinary) tile under it
|
||||
await sleep(1500);
|
||||
assert.ok(await tap(unit.row, unit.col));
|
||||
await sleep(250);
|
||||
assert.equal(await card(page), null, 'the wreck opens nothing');
|
||||
// another crate still answers
|
||||
const other = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[1]; return { row: d.y, col: d.x }; });
|
||||
assert.ok(await tap(other.row, other.col));
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('a battle on the engine canvas: a tap on the crate unit\'s tile opens its card with the HP bar', async () => {
|
||||
const { page, problems } = await open(`/dev/game-mock.html?phase=COMBAT&variant=devices&render=engine&stage=${CRATE_STAGE}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await page.waitForFunction(() => globalThis.__MOCK__?.S()?.battle?.devices?.length > 0, { timeout: 10000 });
|
||||
await sleep(2500); // models / camera / the spawn events
|
||||
const unit = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[0]; return { row: d.y, col: d.x }; });
|
||||
const at = () => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [unit.row, unit.col]);
|
||||
let text = null;
|
||||
for (let i = 0; i < 3 && text === null; i++) {
|
||||
const p = await at();
|
||||
assert.ok(p, 'the crate tile has a screen position');
|
||||
await page.mouse.click(p.x, p.y);
|
||||
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; }
|
||||
text = await card(page);
|
||||
}
|
||||
assert.ok(text !== null, 'the tap opened a card');
|
||||
assert.match(text, new RegExp(CRATE.name), text);
|
||||
assert.equal(await hpText(page), '100 / 100');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
// GitHub #228 (idea of PR #229 by @rickylxw — the terrain tip of #184, for the stage DEVICES): a tap on a stage device
|
||||
// (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置) explains it. `screens/game.js` tileClick hands over `{ kind: 'device',
|
||||
// device }`, resolved from the stage the board on screen is built from — `gameLogic.deviceInfo` (the prep) or
|
||||
// `deviceTipAt` (a battle: the crate / turret is a device unit there) — and the panel opens its device card.
|
||||
// Covered: what the card says for the devices of the real stages (the sim's own numbers — sim/content/devices.js) in every
|
||||
// shipped language, what the screen's override pipeline does to it (a 机变 card removes the crate → no tip), the battle
|
||||
// path against a REAL battle (the turret outside the rect stands on another tile than its stage pos; the stage crates and
|
||||
// turrets are not in the field meta of a battle shown from its start; a destroyed crate is gone), the panel link
|
||||
// (resolveDetail) and that the card closes on the next field press. The tap reaching the screen is test/ui/device-tip.e2e.test.js.
|
||||
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setLang, setMessages, registerLangs } from '../../shared/i18n.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { deviceInfo, deviceTipAt, noteDeviceUnits, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
|
||||
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
|
||||
const pack = (code) => { const p = JSON.parse(readFileSync(path.join(ROOT, 'public', 'i18n', `${code}.json`), 'utf8')); delete p._meta; return p; };
|
||||
|
||||
afterEach(() => { setLang('zh'); for (const l of ['en', 'ja', 'ko', 'zh-TW']) setMessages(l, {}); });
|
||||
|
||||
/** The first device of `role` in the stage. */
|
||||
function deviceOf(stage, role) {
|
||||
const d = (stage.devices || []).find((x) => x.role === role);
|
||||
if (!d) throw new Error(`no ${role} device in ${stage.id}`);
|
||||
return d;
|
||||
}
|
||||
const TURRET_ON = { 'trap_1104_aclasert#1': true };
|
||||
|
||||
test('阻隔工事: the crate says its own HP and the enemy behaviour the sim runs', () => {
|
||||
const st = stages['act1autochess_m01'];
|
||||
const d = deviceOf(st, 'crate');
|
||||
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||
assert.equal(info.key, 'crate');
|
||||
assert.equal(info.name, '阻隔工事', 'the device\'s own data name (the data overlay localizes it)');
|
||||
assert.equal(info.tag, '场地装置');
|
||||
assert.deepEqual([info.row, info.col], d.pos);
|
||||
assert.match(info.lines[0], /绕开它走/);
|
||||
assert.match(info.lines[0], /无路可走/);
|
||||
assert.equal(info.lines[1], '生命 100 点,被摧毁后从场上消失');
|
||||
assert.deepEqual(info.facts, ['不可部署']);
|
||||
assert.equal(info.stats, undefined, 'the crate needs no stat grid beyond its HP line');
|
||||
});
|
||||
|
||||
test('“双眼皮”: the turret reads its stats and the bond-layer scaling from the device entry', () => {
|
||||
const st = stages['act1autochess_m01'];
|
||||
// every stage's turrets stand dormant (active: false) until 机械援助 or a 机变 card turns the alias on — the screen
|
||||
// passes the player's own effectiveStage, so the tip lights up exactly when the client draws the turret
|
||||
const d = deviceOf(st, 'turret');
|
||||
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]), null, 'dormant: no tip');
|
||||
const info = deviceInfo(effectiveStage(st, { deviceOverrides: TURRET_ON }), d.pos[0], d.pos[1]);
|
||||
assert.equal(info.name, '“双眼皮”');
|
||||
assert.match(info.lines[0], /法术伤害/);
|
||||
assert.match(info.lines[0], /脆弱/);
|
||||
assert.equal(info.lines[1], '按当前层数最高的盟约计算:每 1 层,攻击速度 +1(至多 +300),附加的脆弱 +0.1%(至多 +30%)');
|
||||
assert.equal(info.lines[2], '脆弱持续 2 秒');
|
||||
// the same numbers the sim runs (devices.js spawnTurret reads these blackboard keys)
|
||||
const bb = d.skill.bb;
|
||||
assert.deepEqual([bb.attack_speed_per_stack, bb.max_attack_speed, bb.damage_scale_per_stack, bb.max_damage_scale], [1, 300, 0.001, 1.3]);
|
||||
assert.deepEqual(info.stats, [
|
||||
{ k: '生命上限', v: 3000 }, { k: '攻击', v: 900 }, { k: '防御', v: 200 }, { k: '攻击间隔', v: '4s' },
|
||||
]);
|
||||
assert.deepEqual(info.facts, ['不可部署']);
|
||||
});
|
||||
|
||||
test('射击台: the platform tells the deploy class it feeds the deploy map', () => {
|
||||
const st = stages['act1autochess_m03'];
|
||||
const d = deviceOf(st, 'platform');
|
||||
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||
assert.equal(info.name, '射击台');
|
||||
assert.match(info.lines[0], /高台位/);
|
||||
assert.match(info.lines[1], /远程位干员可以部署/);
|
||||
assert.match(info.lines[1], /不阻挡敌人/);
|
||||
assert.deepEqual(info.facts, ['仅远程位可部署']);
|
||||
});
|
||||
|
||||
test('源石流发生装置: the blower names its direction, its reach and the airflow numbers the sim applies', () => {
|
||||
const st = stages['act2autochess_m01'];
|
||||
const d = deviceOf(st, 'blower');
|
||||
assert.equal(d.dir, 'DOWN');
|
||||
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||
assert.equal(info.name, '源石流发生装置');
|
||||
assert.equal(info.lines[0], '向前方 3 格吹出气流(这一台朝下)');
|
||||
assert.equal(info.lines[1], '部署在气流里的干员:朝向与风向相同的攻击力 +30%、相反的 −30%');
|
||||
assert.equal(info.lines[2], '在气流里移动的敌人:顺风移动速度 ×1.5、逆风 ×0.5');
|
||||
// …which are the blackboard values devices.js buildTerrain / tickAirflowAlly / tickAirflowEnemy read
|
||||
const bb = d.skill.bb;
|
||||
assert.deepEqual([bb['blower_s_character[equal].atk'], bb['blower_s_character[opposite].atk'], bb['blower_s_enemy[equal].move_speed'], bb['blower_s_enemy[opposite].move_speed']], [0.3, -0.3, 0.5, -0.5]);
|
||||
// a blower without its own blackboard falls back to the stage's special.blower.bb (the sim's order)
|
||||
const bare = { ...st, devices: [{ ...d, skill: null }], special: { blower: { bb: { 'blower_s_character[equal].atk': 0.1, 'blower_s_enemy[opposite].move_speed': -0.25 } } } };
|
||||
const fb = deviceInfo(bare, d.pos[0], d.pos[1]);
|
||||
assert.equal(fb.lines[1], '部署在气流里的干员:朝向与风向相同的攻击力 +10%');
|
||||
assert.equal(fb.lines[2], '在气流里移动的敌人:逆风 ×0.75');
|
||||
});
|
||||
|
||||
test('a device the client does not draw says nothing: inactive, card-removed, tip-less role, ordinary tile', () => {
|
||||
// act1 m02's platforms stand dormant (weight-0 cards add them) — not drawn, so no tip
|
||||
const m02 = stages['act1autochess_m02'];
|
||||
const p = deviceOf(m02, 'platform');
|
||||
assert.equal(deviceInfo(m02, p.pos[0], p.pos[1]), null);
|
||||
// the screen passes the player's own effectiveStage: a 机变 card's deviceOverride flips the crate's active off
|
||||
const fake = { rows: [], tiles: {}, devices: [{ key: 'trap_1105_accrate', alias: 'trap_1105_accrate#001', role: 'crate', pos: [3, 3], active: true, stats: { maxHp: 100 } }] };
|
||||
assert.ok(deviceInfo(fake, 3, 3), 'the crate stands');
|
||||
const removed = effectiveStage(fake, { deviceOverrides: { 'trap_1105_accrate#001': false } });
|
||||
assert.equal(deviceInfo(removed, 3, 3), null);
|
||||
// a tip-less role (盟约寒风 is invisible; the controllers have no tile) falls through like an ordinary tile
|
||||
const m01 = stages['act1autochess_m01'];
|
||||
const cw = deviceOf(m01, 'coldWind');
|
||||
assert.equal(deviceInfo(m01, cw.pos[0], cw.pos[1]), null);
|
||||
assert.equal(deviceInfo(m01, 5, 5), null);
|
||||
assert.equal(deviceInfo(null, 0, 0), null);
|
||||
assert.equal(deviceInfo({ devices: 'x' }, 0, 0), null);
|
||||
assert.equal(deviceInfo(m01, 1.5, 2), null);
|
||||
assert.equal(deviceInfo({ devices: [{ role: '__proto__', pos: [1, 1], active: true }, { role: 'constructor', pos: [1, 1], active: true }] }, 1, 1), null);
|
||||
});
|
||||
|
||||
test('every drawn tip-able device of every active stage carries a well-formed card, in every language', () => {
|
||||
for (const code of ['en', 'ja', 'ko', 'zh-TW']) setMessages(code, pack(code));
|
||||
registerLangs([{ code: 'en' }, { code: 'ja', fallback: ['en'] }, { code: 'ko', fallback: ['en'] }, { code: 'zh-TW' }]);
|
||||
let n = 0;
|
||||
for (const lang of ['zh', 'en', 'ja', 'ko', 'zh-TW']) {
|
||||
setLang(lang);
|
||||
for (const st of Object.values(stages)) {
|
||||
if (!st.active) continue;
|
||||
for (const d of st.devices || []) {
|
||||
if (!['crate', 'turret', 'platform', 'blower'].includes(d.role)) continue;
|
||||
if (!(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
|
||||
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||
assert.ok(info, `${st.id} ${d.role} @${d.pos} has a tip`);
|
||||
assert.ok(info.name && info.tag, `${st.id} ${d.role} has a name and a tag`);
|
||||
assert.ok(info.lines.length && info.facts.length, `${st.id} ${d.role} has mechanism lines and a tile fact`);
|
||||
for (const line of [...info.lines, ...info.facts, info.tag]) {
|
||||
assert.ok(typeof line === 'string' && line.length && !/[{}]/.test(line), `${lang} ${st.id} ${d.role}: "${line}"`);
|
||||
// nothing is left in the source language where the pack has the words (the Chinese sources are Han text)
|
||||
if (lang === 'en' || lang === 'ko') assert.ok(!/[一-鿿]/.test(line), `${lang}: "${line}" is untranslated`);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(n >= 5 * 4, `checked ${n} cards`);
|
||||
});
|
||||
|
||||
test('the words of a card follow the language: English, 日本語, 한국어, 繁體中文', () => {
|
||||
for (const code of ['en', 'ja', 'ko', 'zh-TW']) setMessages(code, pack(code));
|
||||
registerLangs([{ code: 'en' }, { code: 'ja', fallback: ['en'] }, { code: 'ko', fallback: ['en'] }, { code: 'zh-TW' }]);
|
||||
const st = stages['act2autochess_m01'];
|
||||
const d = deviceOf(st, 'blower');
|
||||
setLang('en');
|
||||
const en = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||
assert.equal(en.tag, 'Stage Device');
|
||||
assert.equal(en.lines[0], 'Blows an airflow 3 tiles ahead (this one faces Down)');
|
||||
assert.equal(en.lines[1], 'Operators deployed in the airflow: ATK +30% when facing the same way as the airflow, −30% when facing the opposite way');
|
||||
assert.equal(en.facts[0], 'Not deployable');
|
||||
setLang('ja');
|
||||
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[1], '気流内に配置されたオペレーター:気流と同じ向きの攻撃力+30%、逆向きは−30%');
|
||||
setLang('ko');
|
||||
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[0], '전방 3칸으로 기류를 내뿜음(이 장치는 아래 방향)');
|
||||
setLang('zh-TW');
|
||||
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[2], '在氣流裡移動的敵人:順風移動速度 ×1.5、逆風 ×0.5');
|
||||
const tur = effectiveStage(stages['act1autochess_m01'], { deviceOverrides: TURRET_ON });
|
||||
const t0 = deviceOf(tur, 'turret');
|
||||
setLang('en');
|
||||
const eTur = deviceInfo(tur, t0.pos[0], t0.pos[1]);
|
||||
assert.deepEqual(eTur.stats.map((s) => s.k), ['Max HP', 'ATK', 'DEF', 'Attack Interval']);
|
||||
assert.equal(eTur.lines[2], 'The Fragile lasts 2 seconds');
|
||||
});
|
||||
|
||||
// ---- a battle on screen ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** What the game screen holds in a battle: the device UnitInfos (meta + 'spawn' events) and the latest snapshot by unit id. */
|
||||
function screenView(h) {
|
||||
const units = noteDeviceUnits(new Map(), { units: h.battle.fieldMeta().units, events: h.eventsOf('spawn') });
|
||||
const snap = new Map(h.snapshot().units.map((t) => [t[0], t]));
|
||||
return { units: [...units.values()], snap };
|
||||
}
|
||||
const turretPlayers = () => [{
|
||||
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 10 } },
|
||||
playerEffects: [{ id: 'aceffect_band_43', key: 'auto_chess_change_map', params: TURRET_ON }],
|
||||
}];
|
||||
|
||||
test('a battle: the crates and the turret are found by their UNIT — the field meta of a battle shown from its start holds none of them', () => {
|
||||
const stageId = 'act1autochess_m01';
|
||||
const h = makeBattle({ stageId, players: turretPlayers(), autoFinish: false, timeLimit: 30 });
|
||||
assert.deepEqual(noteDeviceUnits(new Map(), { units: h.battle.fieldMeta().units }).size, 0, 'nothing stands before the first step');
|
||||
h.step(1);
|
||||
const v = screenView(h);
|
||||
assert.equal(v.units.length, 4, '3 crates + the turret');
|
||||
const stage = stages[stageId];
|
||||
const crate = v.units.find((u) => u.defId === 'trap_1105_accrate');
|
||||
const tip = deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), v);
|
||||
assert.equal(tip.key, 'crate');
|
||||
assert.equal(tip.unitId, crate.id, 'the unit rides along: the screen reads its live HP through it');
|
||||
assert.equal(tip.lines[1], '生命 100 点,被摧毁后从场上消失');
|
||||
// the turret stands on its stage pos here (inside the rect); the stage entry is dormant in the plain stage a watcher holds
|
||||
// (a teammate's 机变 / band overrides are not known) — the unit proves it stands, so it is explained
|
||||
const turret = v.units.find((u) => u.defId === 'trap_1104_aclasert');
|
||||
const tt = deviceTipAt(stage, Math.round(turret.y), Math.round(turret.x), v);
|
||||
assert.equal(tt.key, 'turret');
|
||||
assert.equal(tt.name, '“双眼皮”');
|
||||
assert.equal(tt.stats[0].v, 3000);
|
||||
// an ordinary tile, and a tile outside every unit: nothing
|
||||
assert.equal(deviceTipAt(stage, 12, 8, v), null);
|
||||
// before the first snapshot of the battle has arrived, a unit counts as standing
|
||||
assert.ok(deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), { units: v.units, snap: new Map() }));
|
||||
assert.ok(deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), { units: v.units }));
|
||||
});
|
||||
|
||||
test('a battle: a turret outside the rect answers on the tile its UNIT stands on, not on its stage pos', () => {
|
||||
const stageId = 'act2autochess_m01';
|
||||
const h = makeBattle({ stageId, players: turretPlayers(), autoFinish: false, timeLimit: 30 });
|
||||
h.step(1);
|
||||
const v = screenView(h);
|
||||
const stage = stages[stageId];
|
||||
const turret = v.units.find((u) => u.defId === 'trap_1104_aclasert');
|
||||
assert.ok(turret, 'the turret is online');
|
||||
const [r, c] = [Math.round(turret.y), Math.round(turret.x)];
|
||||
const pos = deviceOf(effectiveStage(stage, { deviceOverrides: TURRET_ON }), 'turret').pos;
|
||||
assert.notDeepEqual([r, c], pos, 'act2 m01\'s turret stands on row 8, outside the normal field: the sim moves it');
|
||||
assert.equal(deviceTipAt(stage, r, c, v).key, 'turret');
|
||||
assert.equal(deviceTipAt(stage, r, c, v).unitId, turret.id);
|
||||
// its stage pos holds nothing any more (the static device is not drawn inside the rect): no phantom card there
|
||||
assert.equal(deviceTipAt(effectiveStage(stage, { deviceOverrides: TURRET_ON }), pos[0], pos[1], v), null);
|
||||
// the blowers are no units: they answer by their stage pos, as in the prep
|
||||
const bl = deviceOf(stage, 'blower');
|
||||
assert.equal(deviceTipAt(stage, bl.pos[0], bl.pos[1], v).key, 'blower');
|
||||
});
|
||||
|
||||
test('a battle: a destroyed crate is gone — HP gone, then its tuple leaves the snapshots — and the tap falls through to the tile', () => {
|
||||
const h = makeBattle({ stageId: 'act1autochess_m01', autoFinish: false, timeLimit: 30 });
|
||||
h.step(1);
|
||||
const stage = stages['act1autochess_m01'];
|
||||
const crate = h.battle.allyUnits.find((u) => u.defId === 'trap_1105_accrate');
|
||||
const [r, c] = [crate.tileR, crate.tileC];
|
||||
assert.ok(deviceTipAt(stage, r, c, screenView(h)), 'standing');
|
||||
h.battle.kill(crate, null);
|
||||
h.step(1);
|
||||
assert.equal(crate.alive, false);
|
||||
const dying = screenView(h);
|
||||
assert.equal(dying.snap.get(crate.id)[3], 0, 'still in the snapshots for the die animation, with no HP');
|
||||
assert.equal(deviceTipAt(stage, r, c, dying), null);
|
||||
h.run(3);
|
||||
const gone = screenView(h);
|
||||
assert.equal(gone.snap.has(crate.id), false, 'its tuple left the snapshots');
|
||||
assert.ok(gone.snap.size > 0);
|
||||
assert.equal(deviceTipAt(stage, r, c, gone), null, 'long destroyed: the unit is still in the meta, the card is not');
|
||||
// the other crates stand
|
||||
const other = h.battle.allyUnits.find((u) => u.defId === 'trap_1105_accrate' && u.alive);
|
||||
assert.ok(deviceTipAt(stage, other.tileR, other.tileC, gone));
|
||||
});
|
||||
|
||||
test('the prep and a scouted prep board: the stage\'s own device entries answer by pos (deviceInfo)', () => {
|
||||
const st = stages['act1autochess_m01'];
|
||||
const d = deviceOf(st, 'crate');
|
||||
assert.ok(deviceInfo(st, d.pos[0], d.pos[1]));
|
||||
// noteDeviceUnits keeps only device units, from the meta and from 'spawn' events
|
||||
const m = noteDeviceUnits(new Map(), {
|
||||
units: [{ id: 1, kind: 'op' }, { id: 2, kind: 'device', defId: 'trap_1105_accrate' }, null],
|
||||
events: [['spawn', { id: 3, kind: 'device', defId: 'trap_1104_aclasert' }], ['spawn', { id: 4, kind: 'enemy' }], ['die', 2], null, 'x'],
|
||||
});
|
||||
assert.deepEqual([...m.keys()], [2, 3]);
|
||||
assert.equal(noteDeviceUnits(new Map(), {}).size, 0);
|
||||
assert.equal(noteDeviceUnits(new Map(), { units: 'x', events: 5 }).size, 0);
|
||||
});
|
||||
|
||||
test('a device target resolves into the panel card; a malformed one resolves to nothing; the card closes on a field press', () => {
|
||||
const st = stages['act1autochess_m01'];
|
||||
const d = deviceOf(st, 'crate');
|
||||
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||
assert.deepEqual(resolveDetail({ kind: 'device', device: info }, new Map()), { type: 'device', device: info });
|
||||
const unit = { ...info, unitId: 17 };
|
||||
assert.deepEqual(resolveDetail({ kind: 'device', device: unit }, new Map()), { type: 'device', device: unit, unitId: 17 },
|
||||
'a battle device carries its unit id: the screen reads the live HP through it');
|
||||
assert.equal(resolveDetail({ kind: 'device' }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'device', device: '阻隔工事' }, new Map()), null);
|
||||
assert.equal(resolveDetail(null, new Map()), null);
|
||||
assert.equal(closesOnFieldPress({ kind: 'device', device: info }), true, 'the next field press closes the device card');
|
||||
assert.equal(closesOnFieldPress({ kind: 'terrain' }), true);
|
||||
assert.equal(closesOnFieldPress({ kind: 'enemy' }), false);
|
||||
});
|
||||
@@ -635,7 +635,10 @@ describe('enemies, HUD, stats', () => {
|
||||
assert.deepEqual(factionTypes(['FLY', { type: 'TIMES' }, { id: 'SPECIAL' }, 'FLY', 3, null]), ['FLY', 'TIMES', 'SPECIAL']);
|
||||
});
|
||||
test('snapHud / bossFrac', () => {
|
||||
assert.deepEqual(snapHud({ killed: 3, total: 9, dp: 20 }), { killed: 3, total: 9, dp: 20, boss: null });
|
||||
assert.deepEqual(snapHud({ killed: 3, total: 9, dp: 20 }), { killed: 3, resolved: 3, total: 9, dp: 20, boss: null });
|
||||
// the capsule's numerator: the field's own enemies knocked out or leaked (a runtime split / summon knocked down
|
||||
// counts in `killed` only, so the two may differ — 5/9 down but 3/9 of the round's own list resolved)
|
||||
assert.deepEqual(snapHud({ killed: 5, resolved: 3, total: 9, dp: 20 }), { killed: 5, resolved: 3, total: 9, dp: 20, boss: null });
|
||||
assert.deepEqual(snapHud({ boss: { hp: 50, max: 200 } }).boss, { hp: 50, max: 200 });
|
||||
assert.equal(snapHud(null), null);
|
||||
assert.equal(bossFrac({ hp: 50, max: 200 }), 0.25);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Browser check of the shop bar's fold on 准备就绪 (GitHub #138; opt-in, ~10 s) on the in-match mock harness:
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/ready-fold.e2e.test.js
|
||||
//
|
||||
// In prep, 准备就绪 folds the shop bar (the 展开商店 tab, the screen's `is-collapsed`), 取消准备 unfolds it again; a fold made
|
||||
// by hand before the press is not undone by it going the other way round (the player folds, readies, cancels: unfolded
|
||||
// — the owner's wording is 「取消准备时再展开」); the bar can still be unfolded by hand while ready. The decision itself
|
||||
// (only a change inside one prep round counts) is unit-tested in test/ui/ready-fold.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('the shop bar folds on 准备就绪', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
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 });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
const state = (page) => page.evaluate(() => ({
|
||||
folded: !!document.querySelector('.shopbar-tab'),
|
||||
cls: document.querySelector('.gm')?.classList.contains('is-collapsed') ?? null,
|
||||
ready: document.querySelector('.readybtn')?.getAttribute('aria-pressed') === 'true',
|
||||
label: document.querySelector('.readybtn__label')?.textContent ?? null,
|
||||
}));
|
||||
const press = (page, sel) => page.evaluate((s) => document.querySelector(s)?.click(), sel);
|
||||
/** 准备就绪 with funds left asks first (gameLogic readyFundsPrompt): answer it; 取消准备 asks nothing. */
|
||||
const ready = async (page) => {
|
||||
await press(page, '.readybtn');
|
||||
await sleep(250);
|
||||
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /准备就绪/.test(b.textContent))?.click());
|
||||
};
|
||||
|
||||
test('ready folds the shop bar, cancelling unfolds it; the tab still unfolds it by hand', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.waitForSelector('.readybtn', { timeout: 15000 });
|
||||
await sleep(500);
|
||||
|
||||
let s = await state(page);
|
||||
assert.deepEqual([s.folded, s.cls, s.ready], [false, false, false], 'the shop starts open');
|
||||
await ready(page);
|
||||
await page.waitForSelector('.shopbar-tab', { timeout: 5000 });
|
||||
s = await state(page);
|
||||
assert.deepEqual([s.folded, s.cls, s.ready, s.label], [true, true, true, '取消准备'], '准备就绪 folded the shop bar');
|
||||
|
||||
// the player unfolds by hand while ready: it stays as they left it
|
||||
await press(page, '.shopbar-tab__btn');
|
||||
await sleep(300);
|
||||
s = await state(page);
|
||||
assert.deepEqual([s.folded, s.ready], [false, true]);
|
||||
|
||||
await ready(page); // 取消准备 with the bar already open: it stays open
|
||||
await sleep(300);
|
||||
s = await state(page);
|
||||
assert.deepEqual([s.folded, s.ready], [false, false]);
|
||||
|
||||
// fold by hand, ready, cancel: unfolded again (「取消准备时再展开」)
|
||||
await press(page, '.funds__collapse');
|
||||
await page.waitForSelector('.shopbar-tab', { timeout: 5000 });
|
||||
await ready(page);
|
||||
await sleep(400);
|
||||
s = await state(page);
|
||||
assert.deepEqual([s.folded, s.ready], [true, true], 'still folded after the press');
|
||||
await ready(page);
|
||||
await sleep(400);
|
||||
s = await state(page);
|
||||
assert.deepEqual([s.folded, s.ready], [false, false], 'cancelling unfolds it');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// GitHub #138 (the owner's decision of 2026-10-07: 「点准备后商店自动收起,取消准备时再展开」): pressing 准备就绪 folds the shop
|
||||
// bar in prep, cancelling the ready unfolds it. The decision is gameLogic readyShopFold(prev, cur) over the own CONFIRMED
|
||||
// ready state (m.private.ready): only a change inside one prep round counts. The screen's effect applies it
|
||||
// (screens/game.js; the browser check is test/ui/ready-fold.e2e.test.js).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { readyShopFold } = await import('../../public/js/ui/gameLogic.js');
|
||||
|
||||
test('readyShopFold: ready folds, cancelling unfolds — inside one prep round', () => {
|
||||
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 6, ready: true }), 'fold');
|
||||
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 6, ready: false }), 'unfold');
|
||||
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 6, ready: false }), null, 'nothing changed');
|
||||
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 6, ready: true }), null, 'nothing changed');
|
||||
// a truthy / falsy flag counts like a boolean (m.private carries true / false, a missing one is not ready)
|
||||
assert.equal(readyShopFold({ round: 6, ready: undefined }, { round: 6, ready: 1 }), 'fold');
|
||||
assert.equal(readyShopFold({ round: 6, ready: 1 }, { round: 6, ready: 0 }), 'unfold');
|
||||
});
|
||||
|
||||
test('readyShopFold: the first state seen, a new round\'s reset and anything outside a prep are no press', () => {
|
||||
// a reconnect in the middle of a prep (or the prep's start): no state before it
|
||||
assert.equal(readyShopFold(null, { round: 6, ready: true }), null);
|
||||
assert.equal(readyShopFold(undefined, { round: 6, ready: false }), null);
|
||||
// the next round starts not ready (the server resets it): not a 取消准备
|
||||
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 7, ready: false }), null);
|
||||
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 7, ready: true }), null, 'a new round never reads as a press either');
|
||||
// leaving the prep (combat, the draft, the observers): the screen passes null
|
||||
assert.equal(readyShopFold({ round: 6, ready: true }, null), null);
|
||||
assert.equal(readyShopFold(null, null), null);
|
||||
assert.equal(readyShopFold('x', { round: 6, ready: true }), null);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Browser check of the settings entry of the lobby and the room (GitHub #238 — 设置 was reachable only from the title
|
||||
// screen and a running match; opt-in, ~15 s):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/settings-entry.e2e.test.js
|
||||
//
|
||||
// After 开始 the lobby's top bar carries a 设置 button left of 玩法说明 (ui/settings.js SettingsButton), and so does the
|
||||
// room's after 创建同盟; each opens the very settings modal of the title screen (language, sound, 快捷键 …) and closes
|
||||
// with Esc or 完成, leaving the screen as it was. No console / page errors. The modal itself and its contents are covered
|
||||
// by the title screen's tests; this file is about the entry. Not in the modal, deliberately: background music in a
|
||||
// hidden tab and the board style (the owner's decision of 2026-10-07 on #238).
|
||||
|
||||
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('the settings entry of the lobby and the room', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
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 });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
/** Is the 设置 button the sibling right before the 玩法说明 button of `screen`'s top bar? */
|
||||
const settingsBeforeGuide = (page, screen, guideClass) => page.evaluate((s, g) => {
|
||||
const btn = document.querySelector(`${s} [data-testid="settings-btn"]`);
|
||||
const guide = document.querySelector(`${s} .${g}`);
|
||||
return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent) && btn.querySelector('svg.btn__icon') != null;
|
||||
}, screen, guideClass);
|
||||
const modalText = (page) => page.evaluate(() => document.querySelector('.modal')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
|
||||
|
||||
test('the lobby: 设置 left of 玩法说明 opens the settings modal; Esc closes it; the room has the same entry', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.goto(`${base}/`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.waitForSelector('input', { timeout: 15000 });
|
||||
await page.type('input', '测试博士');
|
||||
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
|
||||
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
|
||||
await sleep(500);
|
||||
|
||||
assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__right', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar');
|
||||
assert.equal(await modalText(page), null, 'closed until asked');
|
||||
await page.click('.lobby-screen [data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal', { timeout: 5000 });
|
||||
const text = await modalText(page);
|
||||
assert.match(text, /设置/);
|
||||
assert.match(text, /背景音乐/);
|
||||
assert.match(text, /快捷键/);
|
||||
assert.doesNotMatch(text, /棋盘/, 'no board-style option (declined)');
|
||||
assert.doesNotMatch(text, /后台/, 'no background-music option (declined)');
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
assert.equal(await modalText(page), null, 'Esc closes it');
|
||||
assert.ok(await page.$('.lobby-screen'), 'the lobby is still there');
|
||||
|
||||
// the room: 创建同盟 → the same button, closed with 完成
|
||||
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /创建同盟/.test(b.textContent))?.click());
|
||||
await page.waitForSelector('.room-screen', { timeout: 15000 });
|
||||
await sleep(500);
|
||||
assert.ok(await settingsBeforeGuide(page, '.room-screen .topbar__left', 'room-guide'), '设置 sits right before 玩法说明 in the room\'s top bar');
|
||||
await page.click('.room-screen [data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal', { timeout: 5000 });
|
||||
assert.match(await modalText(page), /背景音乐/);
|
||||
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /完成/.test(b.textContent))?.click());
|
||||
await sleep(300);
|
||||
assert.equal(await modalText(page), null, '完成 closes it');
|
||||
assert.ok(await page.$('.room-screen'), 'the room is still there');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Browser check of the shop bar's empty slot (GitHub #332 / PR #333; opt-in, ~8 s) on the in-match mock harness:
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/shop-empty-slot.e2e.test.js
|
||||
//
|
||||
// A 调度中心 upgrade opens the new level's extra slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot
|
||||
// in m.private shop.slots) and the next refresh or round start fills it. The bar used to draw every null slot like a
|
||||
// bought one — the SOLD OUT frame with 「已招募」 — which on this slot says that somebody was recruited where nothing was.
|
||||
// Asserted: the null slot is a bare frame (no text, a tooltip saying it fills at the next refresh), a bought slot still
|
||||
// says 已招募, a real card is still a card, the empty frame is not a buyable card (`.scard:not(.scard--sold)` skips it)
|
||||
// and a tap on it opens nothing; the refresh the mock answers fills it.
|
||||
|
||||
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('the empty slot of the shop bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
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 });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
const cards = (page) => page.evaluate(() => [...document.querySelectorAll('.shopbar__cards .scard')].map((el) => ({
|
||||
empty: el.classList.contains('scard--empty'), sold: el.classList.contains('scard--sold'), text: el.textContent.replace(/\s+/g, ' ').trim(),
|
||||
title: el.getAttribute('title') || '',
|
||||
})));
|
||||
|
||||
test('a null slot is a bare frame, a bought one still says 已招募, and a refresh fills the empty one', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.waitForSelector('.shopbar__cards .scard', { timeout: 15000 });
|
||||
await sleep(400);
|
||||
const before = await cards(page);
|
||||
const n = before.length;
|
||||
assert.ok(n >= 4 && !before[0].sold, `the mock's shop has ${n} operator cards, the first one for sale`);
|
||||
|
||||
// the upgrade's new slot: a null at the end of the operator slots; one more slot bought
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
|
||||
const slots = S.priv.shop.slots;
|
||||
const chess = slots.filter((s) => !s || s.kind !== 'item');
|
||||
const item = slots.filter((s) => s && s.kind === 'item');
|
||||
chess[0] = { ...chess[0], sold: true };
|
||||
S.priv.shop.slots = [...chess, null, ...item];
|
||||
}));
|
||||
await sleep(300);
|
||||
const after = await cards(page);
|
||||
assert.equal(after.length, n + 1);
|
||||
const empty = after.filter((c) => c.empty);
|
||||
assert.equal(empty.length, 1, 'one empty frame');
|
||||
assert.equal(empty[0].text, '', 'no SOLD OUT, no 已招募 on it');
|
||||
assert.match(empty[0].title, /空栏位/);
|
||||
assert.ok(empty[0].sold, 'it carries the inert frame class every `:not(.scard--sold)` rule skips (no hover lift, not a buyable card)');
|
||||
assert.equal(after[0].empty, false);
|
||||
assert.match(after[0].text, /已招募/, 'a bought slot still says so');
|
||||
assert.equal(after.filter((c) => !c.sold).length, before.filter((c) => !c.sold).length - 1, 'the rest are real cards');
|
||||
assert.equal(after[after.length - 1].empty, true, 'the new slot is the last operator slot');
|
||||
|
||||
// a tap on the empty frame opens nothing
|
||||
await page.evaluate(() => document.querySelector('.shopbar__cards .scard--empty').click());
|
||||
await sleep(250);
|
||||
assert.equal(await page.$('.dpanel'), null);
|
||||
|
||||
// a refresh fills it: the mock rolls every slot
|
||||
await page.evaluate(() => [...document.querySelectorAll('.toolbtn')].find((b) => /刷新/.test(b.textContent))?.click());
|
||||
await page.waitForFunction(() => document.querySelectorAll('.shopbar__cards .scard--empty').length === 0, { timeout: 5000 });
|
||||
assert.equal((await cards(page)).length, n + 1);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the upgrade itself: tapping the level card twice opens the fifth slot empty, the cards shown stay', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.waitForSelector('.shopbar__cards .scard', { timeout: 15000 });
|
||||
// level 3 with its four operator slots, funds for the upgrade (the mock answers like the server: level 4 has five)
|
||||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
|
||||
const shop = S.priv.shop;
|
||||
const chess = shop.slots.filter((s) => s && s.kind === 'chess' && !s.sold).slice(0, 4);
|
||||
shop.level = 3; shop.upgradePrice = 11; S.priv.funds = 50;
|
||||
shop.slots = [...chess, shop.slots.find((s) => s && s.kind === 'item')];
|
||||
}));
|
||||
await sleep(300);
|
||||
const before = await cards(page);
|
||||
assert.equal(before.length, 4);
|
||||
assert.ok(before.every((c) => !c.sold), 'four real cards');
|
||||
await page.click('.lvcard'); // two taps: select, confirm
|
||||
await sleep(200);
|
||||
await page.click('.lvcard');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.shopbar__cards .scard--empty').length === 1, { timeout: 5000 });
|
||||
const after = await cards(page);
|
||||
assert.equal(after.length, 5);
|
||||
assert.deepEqual(after.map((c) => c.empty), [false, false, false, false, true]);
|
||||
assert.deepEqual(after.slice(0, 4).map((c) => c.text), before.map((c) => c.text), 'the cards shown stay as they were');
|
||||
assert.ok(after.slice(0, 4).every((c) => !/已招募/.test(c.text)));
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Browser check of the in-match 观战席 capsule (GitHub #120, PR #120 by @salt-fishes; opt-in, ~10 s) on the in-match mock
|
||||
// harness (public/dev/game-mock.html):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/spectator-roster.e2e.test.js
|
||||
//
|
||||
// The room screen lists the spectator seats and lets the host remove one, but the route after 开始 is the game screen: a
|
||||
// running match showed nothing of them. Now the top bar carries a capsule beside the latency — an eye and how many are
|
||||
// watching, nothing at all while the room has no spectator; a tap opens the roster (names, an offline mark, the host's ✕ on
|
||||
// every row: room.removeSpectator). Asserted: no capsule without spectators (and none in the default mock), the count and
|
||||
// the rows, the ✕ only for the host, the request the ✕ sends, the roster and the count following the room.state that
|
||||
// comes back, the capsule gone with the last seat, and the roster closing with Esc / 关闭. No console error. The server side
|
||||
// (the seat freed at any time, room.closed {kicked}, no frame after it) is test/lobby.test.js and test/match/lobby-integration.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('the 观战席 capsule of the match top bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
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 });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(query) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.waitForSelector('.gtop', { timeout: 15000 });
|
||||
await sleep(400);
|
||||
return { page, problems };
|
||||
}
|
||||
const pill = (page) => page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="spectators"]');
|
||||
return el ? { text: el.textContent.trim(), me: el.classList.contains('is-me') } : null;
|
||||
});
|
||||
const roster = (page) => page.evaluate(() => {
|
||||
const ul = document.querySelector('[data-testid="spectator-roster"]');
|
||||
if (!ul) return null;
|
||||
return [...ul.querySelectorAll('li')].map((li) => ({
|
||||
name: li.querySelector('.spec__name')?.textContent.trim(), offline: li.classList.contains('is-offline'), remove: !!li.querySelector('[data-testid="spectator-remove"]'),
|
||||
}));
|
||||
});
|
||||
const requests = (page) => page.evaluate(() => globalThis.__MOCK__.S().requests.filter((r) => r[0] === 'room.removeSpectator'));
|
||||
|
||||
test('no spectator, no capsule: the default match top bar is as it was', async () => {
|
||||
const { page, problems } = await open('phase=COMBAT');
|
||||
assert.equal(await pill(page), null);
|
||||
assert.ok(await page.$('.gtop__net .ping'), 'the latency pill is still there');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the host: the capsule counts the seats, the roster lists them with a ✕ each, removing one updates both, the last one removes the capsule', async () => {
|
||||
const { page, problems } = await open('phase=COMBAT&variant=spectators');
|
||||
assert.deepEqual(await pill(page), { text: '2', me: false }, 'an eye and the count');
|
||||
assert.ok(await page.$('.gtop__net [data-testid="spectators"]'), 'beside the latency pill');
|
||||
assert.equal(await roster(page), null, 'closed until asked');
|
||||
await page.click('[data-testid="spectators"]');
|
||||
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||
assert.deepEqual(await roster(page), [
|
||||
{ name: '观战者甲', offline: false, remove: true }, { name: '观战者乙', offline: true, remove: true },
|
||||
]);
|
||||
// ✕ on the first row: room.removeSpectator { playerId } goes out, the room.state that follows takes the row away
|
||||
await page.click('[data-testid="spectator-roster"] li:first-child [data-testid="spectator-remove"]');
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-testid="spectator-roster"] li').length === 1, { timeout: 5000 });
|
||||
assert.deepEqual(await requests(page), [['room.removeSpectator', { playerId: 'sp_1' }]]);
|
||||
assert.deepEqual((await roster(page)).map((r) => r.name), ['观战者乙']);
|
||||
assert.equal((await pill(page)).text, '1');
|
||||
// Esc closes the roster, the capsule stays
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(250);
|
||||
assert.equal(await roster(page), null);
|
||||
assert.equal((await pill(page)).text, '1');
|
||||
// the last seat: the open roster and the capsule go with it
|
||||
await page.click('[data-testid="spectators"]');
|
||||
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||
await page.click('[data-testid="spectator-remove"]');
|
||||
await page.waitForFunction(() => !document.querySelector('[data-testid="spectators"]') && !document.querySelector('.modal'), { timeout: 5000 });
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('a player who is not the host sees the roster but no ✕; 关闭 closes it', async () => {
|
||||
const { page, problems } = await open('phase=COMBAT&variant=spectators,notHost');
|
||||
assert.equal((await pill(page)).text, '2');
|
||||
await page.click('[data-testid="spectators"]');
|
||||
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||
assert.deepEqual((await roster(page)).map((r) => r.remove), [false, false], 'only the host removes');
|
||||
assert.match(await page.evaluate(() => document.querySelector('.modal')?.textContent ?? ''), /创建者可以把观战者移出/);
|
||||
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /关闭/.test(b.textContent))?.click());
|
||||
await sleep(250);
|
||||
assert.equal(await roster(page), null);
|
||||
assert.deepEqual(await requests(page), []);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user