From f0f601647992d696d0d435f11bdfc7975b1aa3ba Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 06:43:26 +0800 Subject: [PATCH] =?UTF-8?q?feedback1=20integrate:=20=E9=9A=90=E5=BE=B7?= =?UTF-8?q?=E6=9D=A5=E5=B8=8C's=20candles=20standing=20at=20the=20time=20l?= =?UTF-8?q?imit=20are=20no=20leaks=20(an=20honest=20client=20result=20was?= =?UTF-8?q?=20rejected)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A co-op 联防 battle that timed out during 隐德来希 S3 listed the 心烛 (in no spawn schedule) as a leak, so the server rejected the client's result ('leak key'). Pre-existing on master; the merged bots reached it on fullmatch seed 116. --- server/sim/Battle.js | 6 ++++-- server/sim/content/kits/tier5.js | 1 + test/sim/feedback1-integrate.test.js | 31 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 test/sim/feedback1-integrate.test.js diff --git a/server/sim/Battle.js b/server/sim/Battle.js index cd46e1c1..e690bd30 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -484,9 +484,11 @@ export class Battle { _timeout() { // Remaining non-boss enemies on the field count as leaked (DESIGN §5.5). Spawns that never happened before the - // limit are dropped: they are removed from `total` and reported in `result().unspawned` (not leaks). + // limit are dropped: they are removed from `total` and reported in `result().unspawned` (not leaks). A content-made + // neutral (`mem.noLeak`: 隐德来希's 心烛, which follows its original) is never a leak — it is in no spawn schedule, so + // a client result listing it would be rejected (fields.js validateClientResult 'leak key'). for (const e of this.enemies) { - if (!e.alive || e.isBoss) continue; + if (!e.alive || e.isBoss || e.mem.noLeak) continue; this._recordLeak(e, true); } this.unspawned = []; diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index d766208e..9e1080df 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -933,6 +933,7 @@ const KITS = { cd.hp = cd.s.maxHp; cd.mem.candleOwner = unit; cd.mem.candleOf = e; + cd.mem.noLeak = true; // never a leak at the time limit (Battle._timeout) battle.addBuff(cd, { key: 'etlchi:candle', flags: { unblockable: true, noMove: true } }); unit.mem.candles.push(cd); battle.fx('candle', { x: cd.x, y: cd.y, id: cd.id, of: e.id }); diff --git a/test/sim/feedback1-integrate.test.js b/test/sim/feedback1-integrate.test.js new file mode 100644 index 00000000..24911f3f --- /dev/null +++ b/test/sim/feedback1-integrate.test.js @@ -0,0 +1,31 @@ +// Integration regressions found while merging the feedback1 workstreams (player feedback after 0.1.0). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o }); + +// A full co-op match (fullmatch-coop3, seed 116) had a 联防 battle end at its time limit while 隐德来希's S3 candles were +// standing: the result listed the 心烛 (enemy_5601_entlec, in no spawn schedule) as a leak and the server rejected the +// honest client's result ('leak key'). A candle follows its original; only the originals count at the limit. +test('隐德来希 S3: candles still standing at the time limit are no leaks (the originals are)', () => { + const h = makeBattle({ + defs: { enemies: { e_a: dummy('e_a', { hp: 1e6 }), e_b: dummy('e_b', { hp: 2e6 }) } }, + units: [{ chessId: 'chess_char_5_06_a', row: 9, col: 4 }], + enemies: [{ key: 'e_a', pos: [10, 5] }, { key: 'e_b', pos: [9, 5] }], + timeLimit: 6, + }); + const u = h.unit('chess_char_5_06_a'); + h.step(); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5), 'S3 starts'); + assert.ok(h.b.enemies.some((e) => e.alive && e.mem.candleOwner === u), 'candles stand'); + assert.ok(h.runUntil(() => h.b.finished, 10), 'the battle reaches its time limit'); + const r = h.result(); + assert.equal(r.reason, 'timeout'); + const leaked = Object.values(r.perPlayer).flatMap((p) => p.leaked).map((l) => l.enemyKey); + assert.ok(!leaked.includes('enemy_5601_entlec'), `no candle leak: ${leaked.join(',')}`); + assert.equal(leaked.filter((k) => /e_a|e_b/.test(k)).length, 2, 'both originals count'); + assert.deepEqual(h.b.errors.map((e) => e.message), []); + checkInvariants(h.b); +});