From bcf0aeac91651feda614bfa1e7b238c81fab46c9 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:18:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feedback1f=20WS:=20=E5=9D=9A=E5=9B=BA?= =?UTF-8?q?=E7=BB=B4=E5=BC=8F=E9=87=8D=E9=94=A4's=20lock=20always=20comes?= =?UTF-8?q?=20before=20the=20M3=E8=8C=A7=E7=94=B2=20revive=20(PRIO=5FRESPA?= =?UTF-8?q?WN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player report F1 after 0.1.0 ("坚固锤子的锁血没生效"). The lock itself works for every kind of lethal damage (hits, element bursts, 无来源 damage, 流失, the carrier's own drain), measured on real operators, real bot matches and the browser's local battle. With M3茧甲 equipped before the hammer, the revive (same 'fatal' priority, registered first) took the first lethal hit instead. PRTS: a 复活 acts on a knock-out, which the hammer's 不死 prevents, so the revive items now run at PRIO_RESPAWN (-101), after every 不死 and before 埃芒加德 (-110). --- docs/SIM.md | 2 +- server/sim/content/bands/battle.js | 2 +- server/sim/content/items/battle.js | 17 +-- test/content/feedback1f-hammer.test.js | 138 +++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 test/content/feedback1f-hammer.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..6ac15217 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -521,7 +521,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` | | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items). Fired by every HP loss — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110) | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) | | `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart | diff --git a/server/sim/content/bands/battle.js b/server/sim/content/bands/battle.js index 01fd5e3f..67e8d30d 100644 --- a/server/sim/content/bands/battle.js +++ b/server/sim/content/bands/battle.js @@ -40,7 +40,7 @@ import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js' import { spawnMapChar } from '../tokens.js'; export const AMEDIC_BAND = 'band_amedic'; -/** 'fatal' priority of 埃芒加德: after the operators' own items (PRIO_REVIVE) and every talent / skill saver. */ +/** 'fatal' priority of 埃芒加德: after the operators' own items (PRIO_REVIVE / PRIO_RESPAWN) and every talent / skill saver. */ export const PRIO_BAND_REVIVE = PRIO_REVIVE - 10; const keyOf = (bandId, part = '') => `band:${bandId}${part ? `:${part}` : ''}`; diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index efa4b14a..5bfc4463 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -19,11 +19,14 @@ // core bond is active). Combo partners (bbStr.equip_chess_id / other_equip) are item keys without the _a/_b suffix: // either quality satisfies them; lent items count as carried. Both are evaluated live, at the moment of use. // -// Hook priorities (non-default): 'fatal' — consumable death savers (坚固维式重锤's first lethal hit, M3茧甲 revives) -// run LAST (PRIO_REVIVE −100: "被击倒时" = nothing else prevented the knock-down, so a skill's / talent's own undying -// (kits use 10 … −60) never wastes a charge); 骑士戒律's free in-skill undying runs early (20). Flat damage reduction -// 'hit' −10 (after the other damage modifiers). Proc damage dealt by items carries the tag 'item' and never -// re-triggers item procs. +// Hook priorities (non-default): 'fatal' — consumable death savers run LAST, so a skill's / talent's own undying (kits +// use 10 … −60) never wastes a charge: 坚固维式重锤's first-lethal-hit lock (异常效果 不死) at PRIO_REVIVE −100, then the +// M3茧甲 revive at PRIO_RESPAWN −101 — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "“复活”的实现方式为:受益者因移动之外的 +// 原因退场时下次部署的再部署时间和费用归零": a revive acts on a knock-out, which a 不死 prevents, so the lock always comes +// first whatever the equip order (player report F1 after 0.1.0: with the 茧甲 equipped first the revive ran first and +// the first lethal hit showed no lock); 埃芒加德's band revive follows (bands/battle.js PRIO_BAND_REVIVE −110). +// 骑士戒律's free in-skill undying runs early (20). Flat damage reduction 'hit' −10 (after the other damage modifiers). +// Proc damage dealt by items carries the tag 'item' and never re-triggers item procs. // // 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators // that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the @@ -58,6 +61,8 @@ const procTags = (k) => ['item', `item:${k}`]; const chance = (battle, p) => p > 0 && (p >= 1 || battle.rng() < p); /** 'fatal' priorities (see header). */ export const PRIO_REVIVE = -100; +/** The items' 复活 (M3茧甲): acts on a knock-out, so after every 不死 (PRIO_REVIVE) — see header. */ +export const PRIO_RESPAWN = PRIO_REVIVE - 1; const PRIO_FREE_UNDYING = 20; /** Stat keys of a stat buff → mods (直接乘算, see header). */ @@ -532,7 +537,7 @@ const BY_ITEM = { c.prevented = true; u.hp = u.s.maxHp; fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used }); - }, PRIO_REVIVE); + }, PRIO_RESPAWN); // after the hammer's 不死 lock, whatever the equip order (header) }, // 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack chess_item_5_01_e(battle, u, rec, S) { diff --git a/test/content/feedback1f-hammer.test.js b/test/content/feedback1f-hammer.test.js new file mode 100644 index 00000000..79b3454d --- /dev/null +++ b/test/content/feedback1f-hammer.test.js @@ -0,0 +1,138 @@ +// Player report F1 after 0.1.0 (2026-10-03): "维多利亚坚固锤子的锁血没生效" — 坚固维式重锤 (chess_item_3_09_e_a / _b): +// "首次受到致命伤害时生命值不低于1,持续8秒" (activity_table act2autochess eff_acarm043 / eff_acgarm043, blackboard +// undeadable_duration 8; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "持有不死" — 异常效果 不死 UNDEADABLE "重设常规生命值时不会 +// 使其低于1"). The lock itself works for every kind of lethal damage (pinned here on synthetic and real operators); what +// failed was the order against M3茧甲: with the 茧甲 equipped before the hammer, its revive ran first at the same priority +// and the first lethal hit showed no lock. PRTS (same page, M3茧甲 / 埃芒加德 / 阿戈尔 备注): "“复活”的实现方式为:受益者因移动 +// 之外的原因退场时下次部署的再部署时间和费用归零" — a revive acts on a knock-out (退场), which a 不死 prevents, so the lock always +// comes first (items/battle.js PRIO_RESPAWN). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; +import { PRIO_REVIVE, PRIO_RESPAWN } from '../../server/sim/content/items/battle.js'; +import { PRIO_BAND_REVIVE } from '../../server/sim/content/bands/battle.js'; + +const HAMMER = 'chess_item_3_09_e_a'; +const HAMMER_B = 'chess_item_3_09_e_b'; +const M3 = 'chess_item_4_12_e_a'; +const SOLVENT = 'chess_item_1_05_e_a'; + +/** Synthetic operator: 2000 HP, 500 ATK, 200 DEF, 1 s attacks, 20 s redeploy. */ +const op = (id) => chessRec({ + id, profession: 'WARRIOR', bonds: [], tier: 1, rangeGrid: [[0, 0], [0, 1]], + stats: { maxHp: 2000, atk: 500, def: 200, res: 10, aspd: 100, bat: 1, respawnTime: 20, spRecovery: 1, blockCnt: 2 }, + skill: { spCost: 60, duration: 5, initSp: 0 }, +}); + +/** One synthetic carrier at (10, 4) with `items` and a harmless static dummy at (10, 9). */ +function carrier(items) { + const h = makeBattle({ + defs: { chess: { t_op: op('t_op') }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: 't_op', row: 10, col: 4, items }], + enemies: [{ key: 'e_d', pos: [10, 9] }], timeLimit: 999, autoFinish: false, + }); + h.step(1); + return { h, u: h.unit('t_op'), e: h.b.enemies.find((x) => x.alive) }; +} + +/** fx events of `kind` on `u` (the hammer's lock: 'undying'; M3茧甲: 'revive'). */ +function fxLog(h, u) { + const log = []; + const fx0 = h.b.fx.bind(h.b); + h.b.fx = (kind, p) => { if (p && p.id === u.id) log.push([kind, Math.round(h.b.time * 100) / 100, p.src ?? null]); return fx0(kind, p); }; + return log; +} + +test('F1 坚固维式重锤: every kind of lethal damage is held at ≥ 1 HP for 8 s (hit, burst, 流失, 无来源, own drain)', () => { + const kinds = { + phys: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'phys', canDodge: false }), + arts: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'arts', canDodge: false }), + true: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false }), + sourceless: ({ h, u, e }) => h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false, sourceless: true }), + // a full 神经 gauge bursts on the carrier: stun, then 1000 无来源 true damage (sim/constants.js ELEMENT) + burst: ({ h, u, e }) => { u.hp = 5; h.b.dealDamage(e, u, { type: 'element', element: 'neural', amount: 5000 }); }, + hpLoss: ({ h, u, e }) => h.b.loseHp(u, 1e7, { source: e }), + hpLossSourceless: ({ h, u, e }) => h.b.loseHp(u, 1e7, { source: e, sourceless: true }), + }; + for (const [name, hit] of Object.entries(kinds)) { + for (const id of [HAMMER, HAMMER_B]) { + const c = carrier([id]); + const log = fxLog(c.h, c.u); + hit(c); + assert.ok(c.u.alive && c.u.hp >= 1, `${name} ${id}: the first lethal ${name} leaves ≥ 1 HP`); + assert.deepEqual(log.filter((x) => x[0] === 'undying').map((x) => x[2]), ['item:hammer'], `${name}: the hammer's lock`); + c.h.run(7.8); + for (let i = 0; i < 3; i++) c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + assert.ok(c.u.alive && c.u.hp >= 1, `${name} ${id}: still held inside the 8 s`); + c.h.run(0.4); + c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + assert.equal(c.u.alive, false, `${name} ${id}: knocked out after the 8 s`); + } + } +}); + +test('F1 坚固维式重锤: the carrier\'s own 源石溶剂 drain cannot finish it inside the lock', () => { + const c = carrier([HAMMER, SOLVENT]); + c.u.hp = 1; + c.h.run(1.05); // the drain's first tick (60 per second) is lethal at 1 HP + assert.ok(c.u.alive && c.u.hp >= 1, 'held by the lock'); + c.h.run(6.5); + assert.ok(c.u.alive, 'every drain tick inside the 8 s is held'); + c.h.run(2.5); + assert.equal(c.u.alive, false, 'the first tick after the 8 s knocks it out'); +}); + +test('F1 坚固维式重锤: one lock per battle (a redeploy does not re-arm it [ASSUMED]); a new battle re-arms it', () => { + const c = carrier([HAMMER]); + const hit = () => c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + hit(); + assert.ok(c.u.alive); + c.h.run(8.2); + hit(); + assert.equal(c.u.alive, false, 'knocked out after the window'); + c.h.runUntil(() => c.u.alive, 60); + assert.ok(c.u.alive, 'redeployed'); + hit(); + assert.equal(c.u.alive, false, 'the second deployment of the battle has no lock left'); + const next = carrier([HAMMER]); + next.h.b.dealDamage(next.e, next.u, { amount: 1e7, type: 'true', canDodge: false }); + assert.ok(next.u.alive && next.u.hp >= 1, 'the next battle locks again'); +}); + +test('F1 坚固维式重锤 + M3茧甲: the lock comes before the revive whatever the equip order (PRTS: a revive acts on 退场)', () => { + assert.ok(PRIO_RESPAWN < PRIO_REVIVE && PRIO_RESPAWN > PRIO_BAND_REVIVE, 'revive items: after every 不死, before 埃芒加德'); + for (const items of [[M3, HAMMER], [HAMMER, M3]]) { + const c = carrier(items); + const log = fxLog(c.h, c.u); + const hit = () => c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); + hit(); + assert.ok(c.u.alive && c.u.hp < 2, `${items}: the first lethal hit is held at 1 HP (lock), not revived to full`); + assert.deepEqual(log.map((x) => x[0]), ['undying'], `${items}: lock first`); + c.h.run(8.2); + hit(); + assert.ok(c.u.alive, `${items}: after the 8 s the 茧甲 revives`); + assert.equal(Math.round(c.u.hp), 2000, 'full HP'); + assert.deepEqual(log.map((x) => x[0]), ['undying', 'revive']); + hit(); + assert.equal(c.u.alive, false, `${items}: nothing left`); + } +}); + +test('F1 坚固维式重锤 on a real operator in a real fight: 角峰 blocking scaled-up real enemies stands 8 s at 1 HP', () => { + for (const id of [HAMMER, HAMMER_B]) { + const h = makeBattle({ + stageId: 'flat', seed: 7, + units: [{ chessId: 'chess_char_1_02_a', row: 9, col: 5, items: [id] }], + enemies: Array.from({ length: 6 }, (_, i) => ({ key: 'enemy_1422_lrsldr', time: i * 0.5, route: 0, mods: { atkMul: 30, hpMul: 50 } })), + timeLimit: 120, autoFinish: false, + }); + const u = h.unit('chess_char_1_02_a'); + const log = fxLog(h, u); + let out = null; + h.b.on('death', (c) => { if (c.unit === u && out == null) out = h.b.time; }); + h.runUntil(() => out != null, 60); + const lock = log.find((x) => x[0] === 'undying'); + assert.ok(lock && lock[2] === 'item:hammer', `${id}: the hammer locked (${JSON.stringify(log)})`); + assert.ok(out != null && out >= lock[1] + 8 - 1e-6, `${id}: knocked out ${out} ≥ 8 s after the lock at ${lock[1]}`); + } +}); From becb165f7a63137f87759219889da61e255f5b36 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:18:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feedback1f=20WS:=20=E4=BC=91=E9=9C=B2?= =?UTF-8?q?=E4=B8=9D=20/=20=E7=8E=9B=E6=81=A9=E7=BA=B3=20purchase=20counte?= =?UTF-8?q?rs=20pinned=20to=20shop=20purchases=20(F2=20not=20reproduced)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player report F2 after 0.1.0: a 谢拉格 taken from the promotion reward was said to use up 休露丝's first-purchase discount. Through real intents the promotion reward, 寻呼模块's pick and the 拟态物质 / 紧急调度券 / 简易通讯机 grants never touch it: only g.buy dispatches onBuy, where every purchase counter lives. Regression tests and the META onBuy row say so. --- docs/META.md | 2 +- server/sim/content/bands/meta.js | 3 +- test/match/feedback1f-sciurus.test.js | 108 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 test/match/feedback1f-sciurus.test.js diff --git a/docs/META.md b/docs/META.md index 62ae4972..7d8bdc37 100644 --- a/docs/META.md +++ b/docs/META.md @@ -242,7 +242,7 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa | `onSold` | a chess was sold (after refund of copies) | `{ piece, gain }` — **write `ev.gain`** to change the funds paid | | `onRefresh` | manual refresh | `{ slots, free, price }` (slots mutable, or use `ctx.setShopSlot`) | | `onPrice` | every price query of a shop slot (views + buy) — must be **pure** | `{ slot, kind, id, price }` — write `ev.price` / `ctx.setPrice` / `ctx.modifyPrice` | -| `onBuy` | after a purchase | `{ piece, slot, price, kind }` (`piece` = owned result, elite after a merge) | +| `onBuy` | after a shop purchase (`g.buy` only) | `{ piece, slot, price, kind }` (`piece` = owned result, elite after a merge). The "购买" counters — 休露丝 首名<谢拉格> 1 资金, 玛恩纳 每购买一名<卡西米尔>, 升华 / 整备 "接下来购买的…" — hook it, so a free pick (promotion reward, 寻呼模块 / 信标 / 凯瑟琳 offers: `g.reward`) or a grant (紧急调度券, 简易通讯机, 拟态物质, strategies) never counts as a purchase (player report F2 after 0.1.0; PRTS 帮助 describes the promotion reward as a 0-cost temporary shop — whether the official 雪域礼赠 counts that pick is unknown, [ASSUMED] no) | | `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid], area? }` — a chess merge's `area` is where its elite went: 'board' (a consumed copy's tile) \| 'hand' \| 'temp' | | `onLevelUp` | shop level up | `{ level, price }` | | `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) | diff --git a/server/sim/content/bands/meta.js b/server/sim/content/bands/meta.js index ae1371fa..ba6cd2ce 100644 --- a/server/sim/content/bands/meta.js +++ b/server/sim/content/bands/meta.js @@ -14,7 +14,8 @@ // band_coin_cost_gain_random_char_by_shop_level 绮良 every coin_cnt funds spent → count random chess ≤ shop level // up_shop_next_refresh_must_present_bond_char 佩佩 level-up to a level of lvlist → +1 free refresh (price 0) that prefers // gain_bond_char_per_round {round,preround,bond} 哈洛德 rounds round, round+preround, …: a chess (≤ shop level, else any tier) -// first_buy_in_round_char_price_change {price,bond} 休露丝 first chess of the round costs `price` +// first_buy_in_round_char_price_change {price,bond} 休露丝 first chess of the round costs `price` (bought in the +// shop: onBuy — a free pick / grant never uses it up) // band_cost_coin_reach_cnt_gain_chess_from_pool 潘格尼尼 once coin_cnt funds spent in total: 1 chess of pool (elite 拉特兰 ≥ T4) // round_start_bond_check_gain_layer 余 round start of `round`: exactly factioncount active bonds → +count1, else each active +count2 // up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free) diff --git a/test/match/feedback1f-sciurus.test.js b/test/match/feedback1f-sciurus.test.js new file mode 100644 index 00000000..3af2a0ee --- /dev/null +++ b/test/match/feedback1f-sciurus.test.js @@ -0,0 +1,108 @@ +// Player report F2 after 0.1.0 (2026-10-03): with 休露丝 【雪域礼赠】 ("每回合购买的首名<谢拉格>干员消耗资金为1", +// band_sciurus first_buy_in_round_char_price_change {price 1, bond kjeragShip}) a <谢拉格> taken for free — the promotion +// reward's 3-choose-1 after a merge, an item's pick-one offer or grant — was said to use up the round's discount. Checked +// through real intents (g.buy / g.reward / g.equip on a real Match with the real registry): only a shop purchase (g.buy → +// onBuy) counts; every free acquisition leaves the discount for the next <谢拉格> bought. Same rule for 玛恩纳 【业务指标】 +// ("每购买一名<卡西米尔>干员,下回合开始时资金+1"). Pinned here (the report did not reproduce on 0.1.0). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, give, giveItem, DATA } from './harness.js'; + +const OK = { ok: true }; +const KJ = (id) => !!(DATA.chess[id] && DATA.chess[id].bonds.includes('kjeragShip')); +const slot = (id) => ({ kind: 'chess', id, basePrice: DATA.chess[id].tier === 1 ? 2 : DATA.chess[id].tier <= 4 ? 3 : 4, frozen: false, sold: false }); +const NOT_KJ_T1 = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && !c.bonds.includes('kjeragShip') && !c.bonds.includes('kazimierzShip')).chessId; + +/** Solo match at R1 prep with an empty board, 50 funds, shop level 3 and `band`; the shop = `shop` chess ids. */ +function setup(band, shop) { + const h = makeMatch({ mode: 'solo', humans: 1, seed: 3 }).start(); + h.toPrep(1); + const m = h.m; + const ps = h.ps('p_0'); + for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); ps.hand.fill(null); ps.offers.length = 0; + ps.funds = 50; ps.counters = {}; ps.pendingFunds = 0; ps.shop.level = 3; ps.bandId = band; + ps.shop.slots = shop.map(slot); + ps.recompute(); + const viewPrice = (i) => { m.flush(true); return h.lastTo('p_0', 'm.private').shop.slots[i].price; }; + return { h, m, ps, viewPrice }; +} + +/** After a free acquisition: the shop's <谢拉格> at `i` still costs 1 (server and m.private), a purchase pays 1, the next costs full. */ +function discountIntact({ m, ps, viewPrice }, i, j, what) { + assert.equal(ps.priceOf(ps.shop.slots[i]), 1, `${what}: the shop <谢拉格> still costs 1`); + assert.equal(viewPrice(i), 1, `${what}: m.private shows 1`); + const f0 = ps.funds; + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: i }), OK); + assert.equal(f0 - ps.funds, 1, `${what}: the first <谢拉格> bought pays 1`); + assert.equal(ps.priceOf(ps.shop.slots[j]), ps.shop.slots[j].basePrice, `${what}: the second <谢拉格> of the round costs full`); +} + +test('F2 休露丝: a <谢拉格> taken from the promotion reward after a merge keeps the round\'s discount', () => { + const s = setup('band_sciurus', ['chess_char_3_11_a', 'chess_char_3_14_a', NOT_KJ_T1]); + give(s.m, s.ps, NOT_KJ_T1); give(s.m, s.ps, NOT_KJ_T1); + assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 2 }), OK, 'the third copy merges'); + const offer = s.ps.offers[0]; + assert.ok(offer && offer.source === 'merge', 'promotion reward'); + if (!offer.slots.some((x) => KJ(x.id))) offer.slots[0].id = 'chess_char_3_20_a'; + const idx = offer.slots.findIndex((x) => KJ(x.id)); + assert.deepEqual(s.m.handle('p_0', { t: 'g.reward', idx }), OK); + assert.ok(s.ps.hand.some((p) => p && p.kind === 'chess' && KJ(p.id)), 'the <谢拉格> joined the hand'); + discountIntact(s, 0, 1, 'promotion reward'); +}); + +test('F2 休露丝: 寻呼模块\'s pick-one and the free grants of items (拟态物质, 紧急调度券, 简易通讯机) keep the discount', () => { + // 寻呼模块 on 银灰 (only <谢拉格>): three <谢拉格> ≤ shop level offered, one taken for free + { + const s = setup('band_sciurus', ['chess_char_3_11_a', 'chess_char_3_14_a']); + const t = give(s.m, s.ps, 'chess_char_4_22_a'); + const it = giveItem(s.m, s.ps, 'chess_item_4_01_e_a'); + assert.deepEqual(s.m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: t.uid }), OK); + const offer = s.ps.offers[0]; + assert.ok(offer && offer.slots.length && offer.slots.every((x) => KJ(x.id)), `寻呼模块 offers <谢拉格> (${offer && offer.slots.map((x) => x.id)})`); + assert.deepEqual(s.m.handle('p_0', { t: 'g.reward', idx: 0 }), OK); + discountIntact(s, 0, 1, '寻呼模块'); + } + // 拟态物质 on one of two 角峰: the third 角峰 (a merge) and its promotion reward + { + const s = setup('band_sciurus', ['chess_char_3_11_a', 'chess_char_3_14_a']); + give(s.m, s.ps, 'chess_char_1_02_a'); + const t = give(s.m, s.ps, 'chess_char_1_02_a'); + const it = giveItem(s.m, s.ps, 'chess_item_5_05_e_a'); + assert.deepEqual(s.m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: t.uid }), OK); + assert.ok([...s.ps.hand, ...s.ps.board.values()].some((p) => p && p.id === 'chess_char_1_02_b'), 'elite 角峰'); + const offer = s.ps.offers[0]; + if (!offer.slots.some((x) => KJ(x.id))) offer.slots[0].id = 'chess_char_3_20_a'; + assert.deepEqual(s.m.handle('p_0', { t: 'g.reward', idx: offer.slots.findIndex((x) => KJ(x.id)) }), OK); + discountIntact(s, 0, 1, '拟态物质'); + } + // 紧急调度券: a shop operator taken for free (here one of three <谢拉格>) + { + const s = setup('band_sciurus', ['chess_char_3_11_a', 'chess_char_3_14_a', 'chess_char_3_20_a']); + const t = give(s.m, s.ps, NOT_KJ_T1); + const it = giveItem(s.m, s.ps, 'chess_item_2_02_e_a'); + assert.deepEqual(s.m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: t.uid }), OK); + const left = s.ps.shop.slots.map((x, i) => (x && !x.sold ? i : -1)).filter((i) => i >= 0); + assert.equal(left.length, 2, 'one shop operator taken'); + discountIntact(s, left[0], left[1], '紧急调度券'); + } + // 简易通讯机 on 银灰: a random <谢拉格> granted + { + const s = setup('band_sciurus', ['chess_char_3_11_a', 'chess_char_3_14_a']); + const t = give(s.m, s.ps, 'chess_char_4_22_a'); + const it = giveItem(s.m, s.ps, 'chess_item_2_06_e_a'); + assert.deepEqual(s.m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: t.uid }), OK); + assert.equal(s.ps.hand.filter((p) => p && p.kind === 'chess' && KJ(p.id)).length, 2, 'a <谢拉格> granted'); + discountIntact(s, 0, 1, '简易通讯机'); + } +}); + +test('F2 玛恩纳 业务指标: only a shop purchase of a <卡西米尔> pays the next round\'s +1 (a free reward pick does not)', () => { + const kz = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier <= 3 && c.bonds.includes('kazimierzShip')).map((c) => c.chessId); + const s = setup('band_mlynar', [kz[0], kz[1], NOT_KJ_T1]); + s.ps.pushRewardOffer('merge', { ids: [kz[2] ?? kz[0]] }); + assert.deepEqual(s.m.handle('p_0', { t: 'g.reward', idx: 0 }), OK); + assert.equal(s.ps.pendingFunds, 0, 'a free <卡西米尔> pays nothing'); + assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 0 }), OK); + assert.equal(s.ps.pendingFunds, 1, 'a bought <卡西米尔>: +1 next round'); +}); From 1af0600b50357f1313843abd7009411a7fe51a8f Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:58:55 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feedback1f=20WS:=20=E5=9D=9A=E5=9B=BA?= =?UTF-8?q?=E7=BB=B4=E5=BC=8F=E9=87=8D=E9=94=A4=20scope=20pinned=20and=20e?= =?UTF-8?q?xplained=20(per=20battle=20[ASSUMED],=20=E9=98=BF=E6=88=88?= =?UTF-8?q?=E5=B0=94=20devour),=20SIM=20fatal=20row=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock is spent once per battle: a carrier knocked out after its lock comes back without one (forced-hammer bot matches: 27 of 382 carrier knock-outs). The 阿戈尔 battle-start devour (5000 physical damage) also spends it on the fodder in front. Both stay as they were; the per-battle scope is marked [ASSUMED] in the code and its test, with the one-line flip named. The SIM hook table's fatal row no longer lists 不屈 (a death hook) and names the boss-pool exception. --- docs/SIM.md | 2 +- server/sim/content/items/battle.js | 6 +++- test/content/feedback1f-hammer.test.js | 38 ++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 6ac15217..24f26b45 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -521,7 +521,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` | | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items). Fired by every HP loss — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110) | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) | | `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart | diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 5bfc4463..5dc23b40 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -270,7 +270,11 @@ function hammerAcquire(battle, rt, u) { if (t && t.side === 'enemy' && t.alive && chance(battle, pr)) battle.applyStatus(t, 'tremble', { duration: num(p.disarmed_duration, 2), source: u }); } }); - // 坚固: first lethal hit per battle ⇒ HP never below 1 for undeadable_duration × m s + // 坚固: first lethal hit per battle ⇒ HP never below 1 for undeadable_duration × m s. Per battle [ASSUMED]: the text + // only says 首次 (M3茧甲 says 一场战斗, 拉特兰桥夹 每次部署); the hammer state lives on the unit, which a redeploy + // reuses, so a carrier knocked out after its lock comes back without one. Per deployment would reset + // undyingUsed / undyingUntil on the carrier's own non-initial 'deploy'. Any lethal HP loss sets it off, an ally's (the + // 阿戈尔 battle-start devour, "造成5000点物理伤害") or the carrier's own (源石溶剂) included. S.on('fatal', (c) => { if (c.unit !== u || c.prevented) return; if (battle.time < hs.undyingUntil) { c.prevented = true; return; } diff --git a/test/content/feedback1f-hammer.test.js b/test/content/feedback1f-hammer.test.js index 79b3454d..9058e778 100644 --- a/test/content/feedback1f-hammer.test.js +++ b/test/content/feedback1f-hammer.test.js @@ -1,11 +1,12 @@ // Player report F1 after 0.1.0 (2026-10-03): "维多利亚坚固锤子的锁血没生效" — 坚固维式重锤 (chess_item_3_09_e_a / _b): // "首次受到致命伤害时生命值不低于1,持续8秒" (activity_table act2autochess eff_acarm043 / eff_acgarm043, blackboard // undeadable_duration 8; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "持有不死" — 异常效果 不死 UNDEADABLE "重设常规生命值时不会 -// 使其低于1"). The lock itself works for every kind of lethal damage (pinned here on synthetic and real operators); what -// failed was the order against M3茧甲: with the 茧甲 equipped before the hammer, its revive ran first at the same priority -// and the first lethal hit showed no lock. PRTS (same page, M3茧甲 / 埃芒加德 / 阿戈尔 备注): "“复活”的实现方式为:受益者因移动 -// 之外的原因退场时下次部署的再部署时间和费用归零" — a revive acts on a knock-out (退场), which a 不死 prevents, so the lock always -// comes first (items/battle.js PRIO_RESPAWN). +// 使其低于1"). The lock itself works for every kind of lethal damage (pinned here on synthetic and real operators). One +// ordering fault is fixed: with M3茧甲 equipped before the hammer, its revive ran first at the same priority and the first +// lethal hit showed no lock. PRTS (same page, M3茧甲 / 埃芒加德 / 阿戈尔 备注): "“复活”的实现方式为:受益者因移动之外的原因退场时 +// 下次部署的再部署时间和费用归零" — a revive acts on a knock-out (退场), which a 不死 prevents, so the lock always comes first +// (items/battle.js PRIO_RESPAWN). Pinned too, as the ways a lock can look missing: one lock per battle [ASSUMED] (a +// redeployed carrier has none) and the 阿戈尔 battle-start devour spending it. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; @@ -82,6 +83,10 @@ test('F1 坚固维式重锤: the carrier\'s own 源石溶剂 drain cannot finish assert.equal(c.u.alive, false, 'the first tick after the 8 s knocks it out'); }); +// [ASSUMED] scope, an open question for the user: the official text says only 首次 (no 一场战斗 / 每次部署), and a +// carrier knocked out after its lock comes back without one — the likeliest reading of "锁血没生效" (forced-hammer bot +// matches, 3 绝境 + 4 困难: 27 of 382 carrier knock-outs came in a later deployment with the lock spent). Per deployment +// would flip the third assertion below. test('F1 坚固维式重锤: one lock per battle (a redeploy does not re-arm it [ASSUMED]); a new battle re-arms it', () => { const c = carrier([HAMMER]); const hit = () => c.h.b.dealDamage(c.e, c.u, { amount: 1e7, type: 'true', canDodge: false }); @@ -99,6 +104,29 @@ test('F1 坚固维式重锤: one lock per battle (a redeploy does not re-arm it assert.ok(next.u.alive && next.u.hp >= 1, 'the next battle locks again'); }); +// The other way a lock is gone before the enemies hit: the 阿戈尔 battle-start devour ("吞噬身前一格干员对其造成5000点物理 +// 伤害") is a lethal hit on the fodder, so a hammer carrier in front of an 阿戈尔 survives it at 1 HP and spends its one +// lock at t = 0 (in forced-hammer 绝境 bot matches 10 of 179 locks went this way). Kept: the text calls it damage and +// 异常效果 不死 holds any HP reset at 1 [ASSUMED that the official engine does the same]. +test('F1 坚固维式重锤 in front of an 阿戈尔: the battle-start devour spends the lock (fodder kept at 1 HP for 8 s)', () => { + const g = (id) => chessRec({ id, bonds: ['egirShip'], profession: 'WARRIOR', skill: null, stats: { atk: 1000, maxHp: 10000, def: 0, blockCnt: 2 } }); + const h = makeBattle({ + defs: { chess: { g1_a: g('g1_a'), g2_a: g('g2_a'), g3_a: g('g3_a'), t_op: op('t_op') }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 't_op', row: 10, col: 4, items: [HAMMER] }, { chessId: 'g2_a', row: 12, col: 3 }, { chessId: 'g3_a', row: 12, col: 5 }], + bonds: { egirShip: { count: 3, active: true, tier: 1, layers: 0 } }, + enemies: [{ key: 'e_d', pos: [9, 9] }], timeLimit: 999, autoFinish: false, + }); + const u = h.unit('t_op'); + const log = fxLog(h, u); + h.step(1); + assert.ok(u.alive && u.hp >= 1 && u.hp < 2, 'devoured (5000 > 2000 HP) but held at 1 HP'); + assert.deepEqual(log.filter((x) => x[0] === 'undying').map((x) => [x[1], x[2]]), [[0, 'item:hammer']], 'the lock fired at t = 0'); + const e = h.b.enemies.find((x) => x.alive); + h.run(8.2); + h.b.dealDamage(e, u, { amount: 1e7, type: 'true', canDodge: false }); + assert.equal(u.alive, false, 'the first enemy kill after the window finds no lock left'); +}); + test('F1 坚固维式重锤 + M3茧甲: the lock comes before the revive whatever the equip order (PRTS: a revive acts on 退场)', () => { assert.ok(PRIO_RESPAWN < PRIO_REVIVE && PRIO_RESPAWN > PRIO_BAND_REVIVE, 'revive items: after every 不死, before 埃芒加德'); for (const items of [[M3, HAMMER], [HAMMER, M3]]) {