From 821ab827bdf6f07dfca01762a5311a1fd7d36ed2 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 10 Oct 2026 01:02:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=A4=E5=88=83=E6=98=8E?= =?UTF-8?q?=E9=9C=84=E9=99=88=E4=B8=89=E6=8A=80=E8=83=BD=E5=AF=B9=E7=A9=BA?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BC=80=E5=90=AF=E5=B9=B6=E6=A0=B8=E5=AE=9E?= =?UTF-8?q?=E7=A4=BE=E5=8C=BA=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 的扩展范围含可选中的飞行目标时可以自动开启剑气;普攻保持仅对地。 [ASSUMED] 自动触发区域沿用扩展攻击格和永久射程延长,不扩张到未来剑气的整条路线。 补测部署技能与休整属性预览、后排协防蕾缪安真实锁定耗弹、诗怀雅落地金币、 莎草对炸弹的治疗链,以及予愿三种技能的普通地面溅射和无视可选性例外。 未复现的报告保留证据边界;诗怀雅被动落地投瓶符合 PRTS。 全 285 场 golden 重建并验证,仅 diy-074 变化:新增 4 次陈 S3,伤害 142376→163563, 时序/选敌改变后击杀 21→18、漏怪 96→99,223.83 秒和结束方式不变。 其余 284 场不变。 CI:5879 tests,5876 pass,0 fail,3 skip;setup/smoke/lint/imports/typecheck 均通过。 新增 16 项回归,相关 6 文件 63/63,补充 2 文件 20/20,文档 31/31。 --- docs/SIM.md | 4 +- docs/design/engine.md | 5 + docs/history/0.2.3.md | 22 +++++ server/match/match/intents.js | 3 +- server/sim/content/kits/ops/op-chen3.js | 6 ++ test/content/community-round27.test.js | 116 ++++++++++++++++++++++++ test/content/lemuen-wanted-023.test.js | 19 ++++ test/golden/diy.json | 24 ++--- test/match/unitStats.test.js | 26 ++++++ 9 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 test/content/community-round27.test.js diff --git a/docs/SIM.md b/docs/SIM.md index c7c60e21..7417a858 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -953,7 +953,9 @@ flag alters movement or 失衡. heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形, 谬因 S2's beam, and the areas a skill acts through around its owner's standing summons — 麦哲伦 S1 (her drones' ranges), 令 S3 (each summon's x-5, ground), 电弧 S2 (赛柯's range, ground) / S3 (桑特拉's range): the owner's larger-range rule of 2026-10-06, kits/shared/summoner.js - `summonTriggerArea`). With `{ attackOnly: true }`, the extra range is checked only at the normal attack opportunity, not each tick: 蕾缪安’s own wanted targets and 死芒’s own living summons’ blocked targets (S1/S2); normal selectability and the skill’s actual effect range remain intact (§28.13). `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any + `summonTriggerArea`). 赤刃明霄陈 S3 uses an air-capable content range on its expanded grid for `ACTIVE_RANGE` too + (§28.26): the wave can hit flyers, its normal attacks remain ground-only. [ASSUMED] This activation area includes + permanent range extension but not the wave's eventual route. With `{ attackOnly: true }`, the extra range is checked only at the normal attack opportunity, not each tick: 蕾缪安’s own wanted targets and 死芒’s own living summons’ blocked targets (S1/S2); normal selectability and the skill’s actual effect range remain intact (§28.13). `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any living enemy (stealthed, untargetable, flying included) on `trigger.customRangeGrid` (= the skill's rangeGrid), every tick, no attack needed; `trigger.allies` (+ `hpAtMost`, default 1) — set by a kit, or by the data for an ally row (黍 S3's official `TRY_SEARCH_ALLY_SKILL`, "技能范围内存在可治疗的我方单位时释放技能": tools/build-data.mjs `TRIGGER_ALLY_RULES`, diff --git a/docs/design/engine.md b/docs/design/engine.md index 4cc54667..cfe9dc52 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -198,6 +198,11 @@ living summons owned by that operator. Sleeping or otherwise unselectable enemie the condition for activation: 死芒 S2 still links inside its own effect range. Other registered summon areas retain the existing per-tick behavior. The optional flag defaults to false for compatibility. +赤刃明霄陈 S3 also registers selectable air targets in its expanded attack grid for `ACTIVE_RANGE` automation +(§28.26): its sword wave can hit flyers while its normal attacks remain ground-only. The existing operation cooldown, +silence and control checks apply. [ASSUMED] The activation area is that grid plus permanent attack-range extension, +not every tile the roaming wave might eventually reach. + 0.2.3 echo/counter interaction (DESIGN §28.24): recursively earned “余音” pulses are drained FIFO by that echo in the same call, retaining the earned attack value and the lethal-hit pulse. [ASSUMED] This local order avoids exhausting diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index b63c349f..084f5785 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -395,3 +395,25 @@ Full golden regeneration covers all 285 scenarios. Four digests intentionally mo No scenario's ending reason changes. The bonds, matches and DIY families are byte-identical to the preceding golden files. The 50-second setting and fallback-message change do not alter battle rules in those digests. + +### 28.26 Further community reports: deployment, air targets and champagne + +Five reports were checked against the current candidate and PRTS on 2026-10-10. Reports without an operator, +phase or enemy identity remain bounded by the scenarios actually tested. + +| Report | Evidence and result | +|---|---| +| Deployment skill counts down before combat | Not reproduced in the battle lifecycle or real match prep-stat path. Constructing a battle does not deploy units; starting it activates deployment skills. Prep previews start their own battle for initial stats, never step it, send no SP/skill timer and cannot spend the next battle's duration. Tests cover 予愿安洁莉娜 S1 and repeated prep previews with 缄默德克萨斯. This does not identify the unspecified UI/phase in the report. | +| 赤刃明霄陈 will not open S3 against flyers | Reproduced and fixed. `ACTIVE_RANGE` checked her ground-only normal-attack profile, although the sword wave can hit air ([PRTS](https://prts.wiki/w/赤刃明霄陈)). S3 now registers air-capable targeting on its expanded attack grid. Normal swings remain ground-only; operation cooldown, stealth/sleep selectability and stun still apply. [ASSUMED] The auto-cast region follows the existing expanded-grid policy, including permanent range extension, rather than the wave's whole future route. This is an automation choice, not a claim about the official mode's automatic-cast rule. | +| Rear player's 蕾缪安 does not open S3 on front player's targets | Already handled by §28.4/§28.13. Added normal/elite united-field cases with the second player's 蕾缪安 and the first player's distant Laterano operator: the real wanted timer completes, S3 starts automatically, locks that enemy and spends ammo. The existing all-skill tests retain attack cadence and selectability. | +| 琳琅诗怀雅 S2 immediately creates a bottle on deployment | Expected. [PRTS](https://prts.wiki/w/琳琅诗怀雅) identifies S2 as passive, deployment with S1/S2 as skill activation, and her talent as granting a coin on activation. The first bomb spends that coin without waiting for a merchant payment. No new SP cooldown is added. Existing throw-animation timing remains an assumption. | +| 莎草 shields a champagne bomb | Not reproduced on the current candidate, which already gives bombs 禁疗 under the maintainer's earlier decision. Both skills actually heal/shield an adjacent operator but never chain to the bomb produced by 琳琅诗怀雅. [PRTS 莎草](https://prts.wiki/w/莎草) also excludes 禁疗 from S2's main-target selection. | +| 予愿安洁莉娜 dies to splash while airborne | All three skills avoid ordinary ground 控潮术师 splash while airborne and take it after landing. A 萨卡兹悖谬暴虐兵长 splash explicitly ignoring target selectability still damages her, as expected. 起飞's 对地规避 is a selectability effect, not immunity to every damage source ([PRTS 异常效果](https://prts.wiki/w/异常效果), [暴虐兵长](https://prts.wiki/w/萨卡兹悖谬暴虐兵长)). Without the reported enemy's identity these checks cannot settle that particular encounter. No global immunity change. | + +All 285 golden scenarios were regenerated and verified. Only `diy-074` moves: the mixed 陈 loadout against +flying enemies now starts four previously idle S3 activations and emits sword-wave effects. Damage rises +142376 → 163563; changed combat/target timing moves kills 21 → 18 and leaks 96 → 99, with the same 223.83 s +clear time and end reason. All other 284 scenarios, including the five other golden families, are unchanged. +The focused checks cover all four tier/elite forms, selectability/control/cooldown boundaries, actual casts, +healing chains and enemy splash. Full CI passes: 5879 tests, 5876 passed, 0 failed, 3 skipped; setup, smoke, lint, +imports and typecheck pass. This round changes no production UI and does not rerun the opt-in browser suites. diff --git a/server/match/match/intents.js b/server/match/match/intents.js index a2ef8652..e8131a24 100644 --- a/server/match/match/intents.js +++ b/server/match/match/intents.js @@ -87,7 +87,8 @@ export class MatchIntents { * band and 机变 effects — computed exactly by the shared sim. The player's battle input after the onBattleStart meta * handlers (`ev.preview: true`, no enemies — those handlers must not change the match for a preview) builds a Battle * of the battle's options that is started (initial deployment + battleStart hooks), read and dropped: it is never - * stepped, so skills and timed effects do not show. Pushed to the player as `m.unitStats { seq, round, units }` + * stepped: deployment skills contribute their initial stats but their timers do not advance. No SP / skill timer is + * sent, and the real battle gets fresh units. Pushed to the player as `m.unitStats { seq, round, units }` * (units: shared/protocol.js unitStatsEntry, board operators and summons by uid); cached per input (a build costs * ≈ 0.3–0.7 ms). Prep phases only (ROUND_START, 机变, PREP); a battle's live stats come from the browser's own sim. * @param {PlayerState} ps @param {number|null} seq echoed (the client keeps the newest answer) diff --git a/server/sim/content/kits/ops/op-chen3.js b/server/sim/content/kits/ops/op-chen3.js index aaa86c20..ad49e847 100644 --- a/server/sim/content/kits/ops/op-chen3.js +++ b/server/sim/content/kits/ops/op-chen3.js @@ -286,6 +286,12 @@ export default { }, { owner: unit }); // S3: the sword wave if (unit.skill?.id !== S3) return; + // PRTS: the sword wave hits air, although her S3 normal attacks remain ground-only. Include those selectable + // targets in the existing ACTIVE_RANGE automation. [ASSUMED] Use its expanded attack grid (plus permanent + // range extension), not the wave's entire eventual route; operation cooldown / control checks stay shared. + unit.skill.addTriggerRange(() => [{ + keys: absoluteRangeKeys(grid3, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0), profile: AIR, + }]); const ratio = num(b3.hp_ratio), minScale = num(b3.projectile_min_atk_scale); battle.on('tick', ({ dt }) => { const w = unit.mem.chen3Wave; diff --git a/test/content/community-round27.test.js b/test/content/community-round27.test.js new file mode 100644 index 00000000..b48bf9c4 --- /dev/null +++ b/test/content/community-round27.test.js @@ -0,0 +1,116 @@ +// Community follow-up: deployment clocks, air-only casts, champagne healing and airborne splash. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const TRAP = 'token_10031_swire2_gdtrap'; +const SW = 'chess_char_3_04_a'; +const wall = chessRec({ id: 'wall', skill: null, stats: { maxHp: 1e7, atk: 0, def: 0, blockCnt: 3 } }); +const dummy = enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }); +const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }; +const diy = (charId, skillIndex, tier = 5) => ({ slot: `chess_char_${tier}_diy1_a`, charId, skillIndex }); + +test('deployment duration starts at battle start, never advances from repeated start/read calls', () => { + const h = makeBattle({ autoFinish: false, units: [{ uid: 1, diy: diy('char_1015_aglna2', 0), row: 10, col: 4 }] }); + const u = h.unit(1); + assert.equal(h.b.started, false); assert.equal(u.deployed, false); + assert.ok(!u.skill?.active); + h.b.start(); const duration = u.skill.duration; + assert.equal(u.skill.timeLeft, duration); assert.equal(u.skill.activations, 1); + for (let i = 0; i < 10; i++) { h.b.start(); h.b.snapshot(); } + assert.equal(h.b.time, 0); assert.equal(u.skill.timeLeft, duration); + h.run(2); assert.ok(Math.abs(u.skill.timeLeft - (duration - 2)) < 1e-8); + done(h); +}); + +for (const tier of [5, 6]) for (const elite of [false, true]) { + test(`赤刃陈 S3 opens for a flyer in its expanded range and only the wave damages it (T${tier}, elite=${elite})`, () => { + const h = makeBattle({ autoFinish: false, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true, + defs: { enemies: { fly: enemyRec({ key: 'fly', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } }, + units: [{ uid: 1, diy: diy('char_1050_chen3', 2, tier), elite, row: 10, col: 4 }] }); + h.step(); const u = h.unit(1); const e = h.spawn('fly', { pos: [10, 6] }); + u.skill.gainSp(999, 'init'); h.run(2); + assert.equal(u.skill.activations, 0, 'the operation cooldown still applies'); + assert.ok(h.runUntil(() => u.skill.active, 2), 'air-only target can start the sword wave'); + h.run(2); + const hits = h.hooksOf('damaged').filter(c => c.source === u && c.target === e); + assert.ok(hits.length > 0, 'the wave hits the flyer'); + assert.ok(hits.every(c => c.dmg.tags.includes('chen3:wave')), 'normal S3 swings remain ground-only'); + done(h); + }); +} + +test('赤刃陈 S3 still rejects an out-of-range, hidden or sleeping flyer, and cannot cast while stunned', () => { + const h = makeBattle({ autoFinish: false, + defs: { enemies: { fly: enemyRec({ key: 'fly', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } }, + units: [{ uid: 1, diy: diy('char_1050_chen3', 2), row: 10, col: 4 }] }); + h.step(); const u = h.unit(1); u.skill.gainSp(999, 'init'); + const far = h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0); + const e = h.spawn('fly', { pos: [10, 6] }); + h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } }); + h.run(0.3); assert.equal(u.skill.activations, 0); + h.b.removeBuff(e, 'test:stealth'); h.b.applyStatus(e, 'sleep', { duration: 0.4 }); + h.run(0.2); assert.equal(u.skill.activations, 0); + h.b.applyStatus(u, 'stun', { duration: 0.7 }); h.run(0.5); assert.equal(u.skill.activations, 0); + h.run(0.4); assert.equal(u.skill.activations, 1); assert.ok(far.alive); done(h); +}); + +test('琳琅诗怀雅 S2 receives the deployment coin and throws without waiting for the first merchant payment', () => { + const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: SW, row: 10, col: 4, skillIndex: 1, potential: 1 }] }); + h.b.start(); const u = h.unit(1); + assert.equal(u.skill.kind, 'passive'); assert.equal(u.mem.coins, 1); + assert.equal(h.b.allyUnits.filter(a => a.defId === TRAP).length, 0); + h.step(); assert.equal(h.b.allyUnits.filter(a => a.defId === TRAP).length, 1); assert.equal(u.mem.coins, 0); + h.run(1); assert.equal(h.b.allyUnits.filter(a => a.defId === TRAP).length, 1, 'no free extra bombs'); + done(h); +}); + +for (const skillIndex of [0, 1]) { + test(`莎草 S${skillIndex + 1} heals/shields an adjacent operator but never chains to 琳琅诗怀雅's actual bomb`, () => { + const h = makeBattle({ autoFinish: false, hooks: ['heal'], captureNoisy: true, + flat: { rows: { 9: '##ERRrrRrrSrrrrrrrS##', 10: '##hrrRrrrrfrrrrrrrf##', 11: '##hrrRrrrrfrrrrrrrf##' } }, + defs: { chess: { wall } }, + units: [{ uid: 1, chessId: SW, row: 9, col: 5 }, + { uid: 2, chessId: 'chess_char_2_06_a', row: 10, col: 6, skillIndex }, { uid: 3, chessId: 'wall', row: 10, col: 5 }] }); + h.step(); const bomb = h.b.allyUnits.find(a => a.defId === TRAP); assert.ok(bomb); + assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]); + const p = h.unit(2), a = h.unit(3); a.hp = 100; + p.skill.gainSp(999, 'init'); + assert.ok(h.runUntil(() => !!a.findBuff('papyrs:shield'), 5), 'a real heal gave the operator a shield'); + h.run(3); + assert.equal(bomb.findBuff('papyrs:shield'), null); + assert.ok(!h.hooksOf('heal').some(c => c.source === p && c.target === bomb)); + if (skillIndex === 1) assert.notEqual(p.mem.papyrsLock, bomb); + done(h); + }); +} + +for (const skillIndex of [0, 1, 2]) { + test(`予愿 S${skillIndex + 1}: ground caster splash skips her while airborne and hits after landing`, () => { + const h = makeBattle({ autoFinish: false, timeLimit: 120, hooks: ['damaged'], captureNoisy: true, + defs: { chess: { wall }, enemies: { dummy } }, + units: [{ uid: 1, diy: diy('char_1015_aglna2', skillIndex), row: 9, col: 5 }, + { uid: 2, chessId: 'wall', row: 10, col: 5 }] }); + h.step(); const u = h.unit(1), bait = h.unit(2); + if (skillIndex > 0) assert.ok(u.skill.activate('test', { free: true })); + h.run(3); assert.ok(u.s.flags.liftoff, 'past S2 invulnerable chant, still airborne'); + const e = h.spawn('enemy_1161_tidmag', { pos: [10, 7], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0.1 } }); + h.run(7); + assert.ok(h.hooksOf('damaged').some(c => c.source === e && c.target === bait), 'caster attacked the adjacent bait'); + assert.ok(!h.hooksOf('damaged').some(c => c.source === e && c.target === u), 'ordinary ground splash cannot hit her'); + u.skill.end('test'); u.skill.spCostMul = 1000; h.run(7); + assert.ok(h.hooksOf('damaged').some(c => c.source === e && c.target === u), 'same splash reaches her after landing'); + done(h); + }); +} + +test('予愿起飞 still takes the brute leader splash explicitly marked 无视无法选择', () => { + const h = makeBattle({ autoFinish: false, hooks: ['damaged'], captureNoisy: true, + defs: { chess: { wall } }, + units: [{ uid: 1, diy: diy('char_1015_aglna2', 0), row: 9, col: 5 }, { uid: 2, chessId: 'wall', row: 9, col: 4 }], + enemies: [{ key: 'enemy_1320_wdrrl_2', route: { motion: 'WALK', start: [9, 8], end: [9, 1], checkpoints: [] }, mods: { hpMul: 1e3, atkMul: 0.1 } }] }); + h.run(14); const u = h.unit(1); + assert.ok(u.s.flags.liftoff); + assert.ok(h.hooksOf('damaged').some(c => c.target === u && c.dmg.ignoreSelect)); + done(h); +}); diff --git a/test/content/lemuen-wanted-023.test.js b/test/content/lemuen-wanted-023.test.js index 79700f22..6fd14453 100644 --- a/test/content/lemuen-wanted-023.test.js +++ b/test/content/lemuen-wanted-023.test.js @@ -17,6 +17,25 @@ function field({ skillIndex = 2, second = false, unite = false } = {}) { const h = makeBattle(config);h.step();return { h, u: h.unit(1), e: h.enemies()[0] }; } function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); } +for (const elite of [false, true]) test(`rear player's 蕾缪安 S3 locks and spends ammo on the front player's wanted target (elite=${elite})`, () => { + const h = makeBattle({ kind: 'unite', autoFinish: false, flags: { startOpCooldown: 3 }, + hooks: ['ammoUsed'], captureNoisy: true, + defs: { enemies: { target: enemyRec({ key: 'target', rank: 'ELITE', hp: 1e9, atk: 0, speed: 0 }) } }, + players: [ + { playerId: 'front', seat: 0, side: 'L', colOffset: 0, bonds: {}, + units: [{ kind: 'chess', uid: 1, chessId: 'chess_char_2_01_a', row: 9, col: 7 }] }, + { playerId: 'rear', seat: 1, side: 'L', colOffset: 0, bonds: {}, + units: [{ kind: 'chess', uid: 2, chessId: elite ? 'chess_char_6_01_b' : LEM, row: 12, col: 2, + skillIndex: 2, carryState: { sp: 999 } }] }, + ], enemies: [{ key: 'target', pos: [9, 8] }], + }); + h.step(); const u = h.unit(2), e = h.enemies()[0]; + assert.equal(u.ownerId, 'rear'); assert.equal(h.b.enemiesInKeys(u.baseRangeKeys, u, u.profile).length, 0); + assert.ok(h.runUntil(() => h.hooksOf('ammoUsed').some(c => c.unit === u), 25)); + assert.ok(e.findBuff('lemuen:wanted')); assert.ok(u.mem.lemLocks.some(l => l.e === e)); + assert.ok(u.skill.activations > 0); done(h); +}); + for (const skillIndex of [0, 1, 2]) for (const unite of [false, true]) { test(`通缉 opens S${skillIndex + 1} outside original range (unite=${unite})`, () => { const { h, u, e } = field({ skillIndex, unite }); diff --git a/test/golden/diy.json b/test/golden/diy.json index 32a8ed50..ca725fcc 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -3131,32 +3131,32 @@ "time": 223.83, "ticks": 6715, "reason": "cleared", - "killed": 21, + "killed": 18, "total": 117, "unspawned": 0, "bossHpLeft": null, "rngDraws": 69, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":142376,"bossDmg":0,"heal":1036,"deaths":22,"layerGains":{}}}, + "players": {"p1":{"killed":18,"total":117,"perfect":false,"leaks":99,"leaksUncounted":0,"coins":0,"dmg":163563,"bossDmg":0,"heal":1036,"deaths":22,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,112,0,0,4864,0,0,0], ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,142,0,0,7296,0,0,0], - ["p1",3,"op","chess_char_5_diy1_a",2,null,2432.1,1,143,0,0,4864,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2432.1,1,143,0,3055,4864,0,0,1], ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,132,0,0,5883,0,0,0], ["p1",5,"op","chess_char_5_diy1_b",1,"none",0,0,97,7,60432,5883,0,0,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,139,0,0,5883,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,139,0,7594,5883,0,0,1], ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_chen3",0,0,88,0,0,6953,542,0,0], - ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_chen3",0,0,52,8,46092,3205,0,0,1], - ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_chen3",0,0,2,0,0,3205,0,0,0], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_chen3",0,0,51,3,36751,3205,0,0,1], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_chen3",0,0,2,1,5271,3205,0,0,1], ["p1",10,"op","chess_char_6_diy1_a",0,null,0,0,6,0,0,2432,0,0,0], ["p1",11,"op","chess_char_6_diy2_a",1,null,0,0,89,6,35852,4864,0,0,1], - ["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,90,0,0,5359,495,0,0] + ["p1",12,"op","chess_char_6_diy1_a",2,null,0,0,90,1,14608,5359,495,0,1] ], - "enemies": {"enemy_1005_yokai":[51,13,38,0,31054],"enemy_1005_yokai_2":[54,6,48,3242,27770],"enemy_1112_emppnt":[12,2,10,0,83553]}, - "hooks": [1,143,6715,260,260,297,278,54,43,139,3,3,0,8200,0,0,117,96,1,43,0,0,19], - "events": {"atk":260,"deploy":26,"die":43,"dmg":278,"fx":210,"heal":2,"leak":96,"skill":6,"spawn":129}, - "fx": {"blink":3,"bombard":36,"bombardShell":36,"dash":3,"dodge":19,"shield":54,"slash":59}, - "snaps": "0ab278ec e14c9c28 e3dbdeec 64907efb 3a831eb1 1a9c57a1 09f92fde 247d01ad 6846cc08 b6fc7137 20c12774 630ab4f0 01a4bbd9 29d8c702 9370a558 d3751005 05b6064d 165e2edc 6a570fdc 746b0dc3 7ee6853f a5969f03 b0c33e95" + "enemies": {"enemy_1005_yokai":[51,12,39,0,28665],"enemy_1005_yokai_2":[54,4,50,3242,18513],"enemy_1112_emppnt":[12,2,10,0,116385]}, + "hooks": [1,143,6715,255,255,303,283,54,40,139,7,7,0,8148,0,0,117,99,1,40,0,0,20], + "events": {"atk":255,"deploy":26,"die":40,"dmg":283,"fx":590,"heal":2,"leak":99,"skill":14,"spawn":129}, + "fx": {"blink":3,"bombard":34,"bombardShell":34,"chen3Wave":374,"dash":7,"dodge":20,"shield":54,"slash":64}, + "snaps": "0ab278ec f44030d3 46e9154f 51baa875 6092a9f9 ca78b52c 84f1d8ef 976fde48 f4eac9ab 59813547 25cbd407 84abcaed 17862595 6335f961 f69a635f 3c5d9184 78ecdc54 8d1f5a6e 52029bff 7352b2fd ed9a864d c37a98ce 1b9d99b7" }, "diy-075": { "about": "5_diy1_a=leizi2/S1 5_diy2_a=leizi2/S2 5_diy1_a=leizi2/S3 5_diy2_b=leizi2/S1 5_diy1_b=leizi2/S2 5_diy2_b=leizi2/S3 6_diy1_b=chen3/S1 6_diy2_b=chen3/S2 6_diy1_b=chen3/S3 6_diy2_b=chen3/S1/002_chen3 6_diy1_b=chen3/S2/002_chen3 6_diy2_b=chen3/S3/002_chen3", diff --git a/test/match/unitStats.test.js b/test/match/unitStats.test.js index 1ad115e6..ab09c1ad 100644 --- a/test/match/unitStats.test.js +++ b/test/match/unitStats.test.js @@ -113,6 +113,32 @@ test('g.unitStats follows the prep state: another item changes the numbers; prep m.dispose(); }); +test('prep stat previews do not spend a deployment skill duration in the next real battle', () => { + const { h, m, ps } = setup(27); + const id = 'chess_char_4_16_a'; // 缄默德克萨斯: a real timed deployment skill + const piece = give(m, ps, id, 'board', legalTileFor(m, ps, id)); + const previews = []; + const create = m.newBattle.bind(m); + m.newBattle = (opts) => { const b = create(opts); previews.push(b); return b; }; + for (let i = 0; i < 3; i++) { + m._unitStatsCache = null; // test fresh previews as well as the cached request + assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: i }), { ok: true }); + const entry = h.lastTo('p_0', 'm.unitStats').units.find(u => u.uid === piece.uid); + assert.ok(entry); assert.equal('sp' in entry, false); assert.equal('timeLeft' in entry, false); + } + assert.equal(m.phase, PHASE.PREP); + for (const b of previews) { + const u = b.allyUnits.find(u => u.uid === piece.uid); + assert.equal(b.time, 0); assert.equal(b.tickCount, 0); + assert.ok(u.skill.active); assert.equal(u.skill.timeLeft, u.skill.duration); + } + const b = create(m._normalOpts(ps)); const u = b.allyUnits.find(u => u.uid === piece.uid); + assert.equal(b.started, false); assert.equal(u.deployed, false); + b.start(); assert.equal(u.skill.timeLeft, u.skill.duration); + b.step(); assert.ok(Math.abs(u.skill.timeLeft - (u.skill.duration - b.dt)) < 1e-8); + assert.deepEqual(b.errors, []); m.dispose(); +}); + test('the preview changes nothing of the match: two identical matches, one asking for the stats all along, stay identical', () => { const run = (ask) => { const { h, m, ps } = setup(23);