From c623550f29c7bee30efc9066ce10b316752dc622 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 17:53:41 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feedback6=20R21E:=20the=20=E5=A4=96?= =?UTF-8?q?=E5=8B=A4=E5=8C=BB=E7=96=97=20Touch=20casts=20=E6=81=B3?= =?UTF-8?q?=E5=88=87=E7=A6=8F=E9=9F=B3=20on=20an=20injured=20ally=20inside?= =?UTF-8?q?=20its=205-2=20(ACTIVE=5FRANGE=20in=20touchKit=20=E2=80=94=20th?= =?UTF-8?q?e=20larger-range=20rule=20the=20Touch=20=E8=A1=A5=E4=BD=8D=20al?= =?UTF-8?q?ready=20runs=20by=20its=20data;=20the=20map=20character's=20rec?= =?UTF-8?q?ord=20keeps=20DEFAULT),=20and=20the=20Touch=20/=20=E9=A2=84?= =?UTF-8?q?=E5=A4=87=E5=B9=B2=E5=91=98-=E5=8C=BB=E7=96=97=20card=20is=20an?= =?UTF-8?q?=20operator=20card=20(=E5=B9=B2=E5=91=98=20=C2=B7=20=E5=8C=BB?= =?UTF-8?q?=E7=96=97=20=C2=B7=20=E8=BF=9C=E7=A8=8B=E4=BD=8D,=20=E7=89=B9?= =?UTF-8?q?=E6=80=A7,=20skill=20row=20with=20SP=20/=20duration,=20talents?= =?UTF-8?q?=20from=20the=20tokens.json=20record=20=E2=80=94=20it=20took=20?= =?UTF-8?q?the=20summon=20path:=20=E5=8F=AC=E5=94=A4=E7=89=A9,=20base=20st?= =?UTF-8?q?ats=20only);=20=E6=81=B3=E5=88=87=E7=A6=8F=E9=9F=B3=20gets=20a?= =?UTF-8?q?=20line=20under=20its=20text=20that=20the=20boost=20is=20strict?= =?UTF-8?q?ly=20below=20half=20(PRTS=20=E4=BF=AE=E6=AD=A3=20of=20=E4=B8=8D?= =?UTF-8?q?=E9=AB=98=E4=BA=8E,=20what=20touchGospel=20does;=20the=20operat?= =?UTF-8?q?or=20card=20of=20the=20=E8=A1=A5=E4=BD=8D=20Touch=20too:=20SKIL?= =?UTF-8?q?L=5FTEXT=5FNOTES);=20GitHub=20#260,=20PR=20#278;=20golden:=20ro?= =?UTF-8?q?ster-041=20(its=20strategy=20Touch=20heals=208438=20=E2=86=92?= =?UTF-8?q?=2017072,=2019=20=E2=86=92=2027=20heal=20actions,=20casts=20unc?= =?UTF-8?q?hanged=20at=202,=20the=20same=20numbers=20as=20PR=20#278;=20p1?= =?UTF-8?q?=20deaths=207=20=E2=86=92=204)=20and=20matches=20coop2-NORMAL-1?= =?UTF-8?q?4-standins=20(h=5F0=20plays=20=E5=A4=96=E5=8B=A4=E5=8C=BB?= =?UTF-8?q?=E7=96=97:=20defeat=20in=20round=2013=20=E2=86=92=20victory,=20?= =?UTF-8?q?teamLp=2016)=20=E2=80=94=20both=20battles=20with=20that=20strat?= =?UTF-8?q?egy's=20Touch,=20no=20other=20scenario=20moved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 4 +- public/i18n/en.json | 1 + public/i18n/ja.json | 1 + public/i18n/ko.json | 1 + public/i18n/zh-TW.json | 1 + public/js/ui/detailPanel.js | 91 ++++++++++++++++++++++++++--- server/sim/content/tokens.js | 9 ++- test/content/tokens_devices.test.js | 22 +++++++ test/golden/matches.json | 54 ++++++++--------- test/golden/roster.json | 46 +++++++-------- test/ui/playtest6_summons.test.js | 34 +++++++++++ 11 files changed, 203 insertions(+), 61 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 0d0d6f59..16a03c93 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -895,7 +895,9 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l row (薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3) whose attack range while it runs strictly contains the unit's own range checks the DEFAULT condition — a targetable enemy (or one it blocks), a heal skill an injured ally — on `trigger.customRangeGrid` (= that running range, grown by the unit's permanent rangeExtend unless the skill's - `targeting.noRangeExtend`), every tick, no attack needed; `DEFAULT` with `trigger.allies` (+ `hpAtMost`, + `targeting.noRangeExtend`), every tick, no attack needed (the 外勤医疗 map character Touch's 恳切福音 too, on its 5-2: + set by its kit, content/tokens.js `touchKit` — the map character's record keeps DEFAULT, build-data widens operators' + skills only; GitHub #260); `DEFAULT` with `trigger.allies` (+ `hpAtMost`, `grid`) = the basic rule **and** such an ally on the grid: the cast replaces the attack about to be made (塞雷娅 S1 "触发 时会替换当次攻击", ≤ half HP); a cast whose ally condition fails before that attack is withdrawn, its charge returned; `TAKE_DAMAGE` — ready and just hit (重装: "不受技能范围影响,受到伤害时释放技能"; in the data every MANUAL 重装 skill but diff --git a/public/i18n/en.json b/public/i18n/en.json index 4443e173..bbf29fcf 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -438,6 +438,7 @@ "自动触发": "Auto Trigger", "精英": "Elite", "领袖": "Leader", + "实际为生命值低于一半时提高治疗量,正好一半不提高(PRTS 修正,原文为“不高于”)": "In practice the heal boost applies below half HP, not at exactly half (PRTS correction; the original text says “at or below”)", "物理": "Physical", "法术": "Arts", "治疗": "Healing", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 735a1a8e..9986bbbe 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -439,6 +439,7 @@ "自动触发": "自動発動", "精英": "エリート", "领袖": "ボス", + "实际为生命值低于一半时提高治疗量,正好一半不提高(PRTS 修正,原文为“不高于”)": "実際は HP が半分未満のときに治療量が上がり、ちょうど半分では上がらない(PRTS の修正。原文は「以下」)", "物理": "物理", "法术": "術", "治疗": "治療", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 7e98deb4..daf09d7f 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -435,6 +435,7 @@ "自动触发": "자동 발동", "精英": "정예", "领袖": "리더", + "实际为生命值低于一半时提高治疗量,正好一半不提高(PRTS 修正,原文为“不高于”)": "실제로는 HP가 절반 미만일 때 치료량이 증가하며, 정확히 절반일 때는 증가하지 않습니다 (PRTS 정정, 원문은 ‘이하’)", "物理": "물리", "法术": "마법", "治疗": "치료", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 24023b66..24d9f4e9 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -437,6 +437,7 @@ "自动触发": "自動觸發", "精英": "精英", "领袖": "領袖", + "实际为生命值低于一半时提高治疗量,正好一半不提高(PRTS 修正,原文为“不高于”)": "實際為生命值低於一半時提高治療量,正好一半不提高(PRTS 修正,原文為「不高於」)", "物理": "物理", "法术": "法術", "治疗": "治療", diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 32b887b2..243a9114 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -14,7 +14,10 @@ // make the wearer its member, 本局禁用 marked; on a wearer's card the pairing it wears highlighted, 生效中 — GitHub issue // #1, DESIGN §21.26) and a bond item (`giveBondId`) the line "与变形同构体一同装备时,携带者视为【X】成员" (MorphGrantLine); // tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes -// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; enemies — stats, rank, +// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; a band map character (外勤医疗's +// Touch / 预备干员-医疗, tokens.json kind 'mapChar') — an operator of the mode: 干员, its class, 特性, skill and talents +// from its own record (MapCharDetail, GitHub #260); a skill text the game contradicts gets the line under it that says +// what the sim does (SKILL_TEXT_NOTES: Touch 恳切福音's 低于一半, PRTS 修正); enemies — stats, rank, // faction tags, abilities. Selling / destroying is the underframe's job in the // match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass // `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a @@ -51,6 +54,13 @@ const SP_TYPE = { INCREASE_WITH_TIME: N_('自动回复'), INCREASE_WHEN_ATTACK: const SKILL_TYPE = { MANUAL: N_('自动触发'), AUTO: N_('自动触发'), PASSIVE: N_('被动') }; /** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */ const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_bond', SERVER_PREP_FIN: 's_icon_bond', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' }; +/** + * A skill text the game contradicts, by skill id: the record keeps the official sentence, the card adds what the sim + * does under it (a PRTS 修正 the kit follows). Touch 恳切福音 (the 外勤医疗 map character's and the Touch 补位's): + * "对生命值不高于一半的友方单位" — PRTS corrects it to 低于 (原因 6), and content/tokens.js touchGospel boosts strictly + * below half (GitHub #260). + */ +const SKILL_TEXT_NOTES = { skchr_acmedc_3: N_('实际为生命值低于一半时提高治疗量,正好一半不提高(PRTS 修正,原文为“不高于”)') }; const RANK = { NORMAL: N_('普通'), ELITE: N_('精英'), BOSS: N_('领袖') }; const DMG = { phys: N_('物理'), arts: N_('法术'), heal: N_('治疗'), true: N_('真实'), none: N_('无') }; @@ -357,6 +367,22 @@ export function chessStatsBlock({ rec, chess, live = null }) { `; } +/** A skill's tags (SP type, trigger, initial SP · cost, duration, charges): the operator card's and a map character's. */ +function skillTags(sk) { + return html` + ${t(SP_TYPE[sk.spType]) || t('技力')} + ${t(SKILL_TYPE[sk.skillType]) || t('自动触发')} + ${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<${GIcon} name="bolt" />${t('初始')} ${sk.initSp ?? 0} ${t('· 消耗')} ${sk.spCost ?? 0}` : null} + ${sk.duration > 0 ? html`${t('持续')} ${sk.duration}s` : null} + ${sk.maxChargeTime > 1 ? html`${t('充能')} ${sk.maxChargeTime}` : null}`; +} + +/** The line under a skill text the game contradicts (SKILL_TEXT_NOTES), or null. */ +function skillTextNote(sk) { + const note = sk && SKILL_TEXT_NOTES[sk.skillId]; + return note ? html`

<${Icon} name="info" />${t(note)}

` : null; +} + export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) { const m = data.get('assets'); const hp = hpOf(live, snapHp); @@ -424,16 +450,11 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond <${Img} src=${skIcon} class="dskill__icon" fallback=${html`${skSlot ? html`${skSlot}` : null}`} />
${skSlot && (lo?.choices || 0) > 1 ? html`${skSlot}` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`${t('已调配')}` : null} -
- ${t(SP_TYPE[sk.spType]) || t('技力')} - ${t(SKILL_TYPE[sk.skillType]) || t('自动触发')} - ${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<${GIcon} name="bolt" />${t('初始')} ${sk.initSp ?? 0} ${t('· 消耗')} ${sk.spCost ?? 0}` : null} - ${sk.duration > 0 ? html`${t('持续')} ${sk.duration}s` : null} - ${sk.maxChargeTime > 1 ? html`${t('充能')} ${sk.maxChargeTime}` : null} -
+
${skillTags(sk)}
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" /> + ${skillTextNote(sk)} ` : null; blocks.module = golden && lo?.module ? html`<${Section} key="module" title=${t('模组')} micro="MODULE" class="dsec--module">
@@ -581,8 +602,62 @@ function tokenOwnerId(piece, pieces) { return owner && owner.kind === 'chess' ? owner.id : null; } +/** + * A band map character's card (TokenDetail): labelled 干员 with its class (医疗 · 远程位), its 特性, the four stats, its + * skill — the operator card's row (icon, SP, duration), its text and SKILL_TEXT_NOTES — and its talents, all from the + * tokens.json record. Until 0.2.1 it took the summon path: 召唤物, base stats only (GitHub #260). + */ +function MapCharDetail({ token, snapHp, live, m }) { + const s = token.stats || {}; + const hp = hpOf(live, snapHp); + const st = { + maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def), + blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)), + }; + const sk = token.skill && token.skill.desc ? token.skill : null; + const talents = chessTalents(token); + const trait = token.trait?.descRaw || token.trait?.desc || token.descRaw || token.desc || ''; + return html` +
+
<${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} />
+
+
${t('干员')}
+

${token.name}

+
+ <${Img} src=${profIconUrl(m, token.profession)} class="dhead__prof" /> + ${t(PROF_NAME[token.profession]) || token.profession || ''} + ${token.position === 'MELEE' ? t('近战位') : t('远程位')} +
+ ${hp ? html`
${fmtNum(hp.hp)} / ${fmtNum(hp.max)}
` : null} +
+
+ ${trait ? html`

<${Icon} name="info" /><${RichText} text=${trait} />

` : null} +
+ <${LiveTag} live=${live} /> + <${Stat} k=${t('生命上限')} ...${st.maxHp} /><${Stat} k=${t('攻击')} ...${st.atk} /> + <${Stat} k=${t('防御')} ...${st.def} /><${Stat} k=${t('阻挡数')} ...${st.blockCnt} /> +
+ ${sk ? html`<${Section} title=${t('技能')} micro="SKILL" class="dsec--skill"> +
+ <${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="dskill__icon" fallback=${html``} /> +
+ ${sk.name} +
${skillTags(sk)}
+
+
+ <${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" /> + ${skillTextNote(sk)} + ` : null} + ${talents.length ? html`<${Section} title=${t('天赋')} micro="TALENT" class="dsec--talent"> + ${talents.map((x, i) => html`
${x.name}<${RichText} text=${x.descRaw || x.desc} class="dtext" />
`)} + ` : null}`; +} + export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live = null }) { const m = data.get('assets'); + // a band map character (外勤医疗's Touch / 预备干员-医疗, tokens.json kind 'mapChar') is an operator of the mode, not a + // summon: no owner variants — its stats, skill, talents and 特性 are on the record (GitHub #260, PR #278) + if (token?.kind === 'mapChar') return MapCharDetail({ token, snapHp, live, m }); // the owner's variant: its stats, talents and token skill (a golden owner's summon is stronger) const v0 = tokenVariantFor(token, ownerId); const s = v0?.stats || token.stats || {}; diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index c2df0169..beff08f4 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -1515,11 +1515,18 @@ export function touchGospel(bb, skill) { }; } -/** Touch (外勤医疗 map character): 恳切福音 (touchGospel) + 攫升 / 超脱 (mapCharTalents). */ +/** + * Touch (外勤医疗 map character): 恳切福音 (touchGospel) + 攫升 / 超脱 (mapCharTalents). The trigger is ACTIVE_RANGE on + * the skill's 5-2 range (her running range, which strictly contains her own 3-3): an injured ally inside it casts — the + * owner's larger-range rule of 2026-10-05, read for a heal skill as for the Touch 补位 stand-in, whose data rule it is + * (data/backups.json). The map character's record keeps DEFAULT (tools/build-data.mjs resolveTrigger widens operators' + * skills only), so she waited for an injured ally in her 3-3 (GitHub #260, PR #278). + */ function touchKit(bb, raw, def) { const sk = def?.skill; if (!sk) return { skill: null, talents: mapCharTalents(def) }; const g = touchGospel(bb, sk); + if (sk.rangeGrid && sk.rangeGrid.length) g.skill.trigger = { rule: 'ACTIVE_RANGE', grid: sk.rangeGrid }; return { talents: mapCharTalents(def), skill: g.skill, install: g.install }; } diff --git a/test/content/tokens_devices.test.js b/test/content/tokens_devices.test.js index 1ce6f16c..9e3b7d51 100644 --- a/test/content/tokens_devices.test.js +++ b/test/content/tokens_devices.test.js @@ -1124,6 +1124,28 @@ test('Touch talents: 攫升 +3 SP to the healed unit, 超脱 +5 SP when an opera checkInvariants(h.b); }); +test('GitHub #260: the 外勤医疗 Touch casts 恳切福音 on an injured ally only inside the skill\'s 5-2 (ACTIVE_RANGE, as the 补位 Touch); a full-HP or unhealable one does not', REAL, () => { + // Touch stands on (10,2) facing right: her 3-3 reaches column 5, the skill's 5-2 column 7 + const h = makeBattle({ stageId: 'act2autochess_m01', defs: { chess: { test_guard: guard({ stats: { maxHp: 10000, atk: 0 } }) } }, + units: [{ chessId: 'test_guard', row: 10, col: 7 }], autoFinish: false, timeLimit: 60 }); + h.step(); + const touch = spawnMapChar(h.b, 'p1', TOKEN_IDS.touch); + assert.deepEqual([touch.tileR, touch.tileC], [10, 2]); + assert.equal(touch.skill.rule, 'ACTIVE_RANGE'); + const ally = h.unit('test_guard'); + touch.skill.gainSp(1000); + h.run(3); + assert.equal(touch.skill.activations, 0, 'every ally at full HP: no cast'); + ally.hp = 4000; + const buff = h.b.addBuff(ally, { key: 'test:noHeal', flags: { noHeal: true } }); + h.run(2); + assert.equal(touch.skill.activations, 0, 'an ally no heal can pick: no cast'); + h.b.removeBuff(ally, buff); + assert.ok(h.runUntil(() => touch.skill.activations === 1, 2), 'the injured ally five tiles ahead (outside her 3-3) casts the skill'); + assert.ok(h.runUntil(() => ally.hp > 4000, 5), 'and she heals it from the larger range'); + checkInvariants(h.b); +}); + test('spawnMapChar: per-player slots — unite: p1 #1 (10,2), p2 its multi-only slot (10,10); boss: L (3,2), R (3,18); solo never multi-only', REAL, () => { const players = [ { playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} }, diff --git a/test/golden/matches.json b/test/golden/matches.json index c31bb6bf..9e2e1065 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -1492,8 +1492,8 @@ "combat": "client", "boost": null, "setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]}, - "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":59326,"virtualMs":119550}, - "rng": {"setup":25,"shop":484,"waves":0,"draft":34,"bots":17816,"meta":19}, + "end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":16,"bossPoolLeft":0,"virtualMs":119900}, + "rng": {"setup":25,"shop":525,"waves":0,"draft":34,"bots":18080,"meta":23}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ { @@ -1553,43 +1553,43 @@ }, { "round": 12, - "h_0": [1,10,14,0,0,6,8,"efcfaab5",123,"arcaneShip 0/7, kazimierzShip 0/8, miraShip 1/18, preciShip 1/24, siracusaShip 1/34, soloShip 1/0, suntShip 1/0, swiftShip 1/47, yanShip 0/13",15,31,16,116], - "ai_0": [1,23,12,0,1,6,8,"97827397",254,"arcaneShip 0/2, deputShip 1/32, kazimierzShip 0/8, lateranoShip 1/49, miraShip 1/6, preciShip 0/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/42, skillfulShip 1/62, suntShip 1/14, swiftShip 1/49",31,31,0,112] + "h_0": [1,15,14,0,0,6,8,"2d442d6d",115,"arcaneShip 0/7, kazimierzShip 0/8, miraShip 1/14, preciShip 1/24, siracusaShip 1/30, soloShip 1/0, suntShip 1/0, swiftShip 1/47, yanShip 0/13",21,31,10,116], + "ai_0": [1,23,12,0,1,6,8,"b3d60fb0",254,"arcaneShip 0/2, deputShip 1/32, kazimierzShip 0/8, lateranoShip 1/49, miraShip 1/6, preciShip 0/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/42, skillfulShip 1/62, suntShip 1/14, swiftShip 1/49",31,31,0,112] }, { "round": 13, - "h_0": [0,0,12,0,0,6,0,"811c9dc5",0,"arcaneShip 0/7, kazimierzShip 0/8, miraShip 0/30, preciShip 0/33, sargonShip 0/9, siracusaShip 0/46, swiftShip 0/51, yanShip 0/13",8,57,49,130], - "ai_0": [1,13,13,0,2,6,8,"c4308d5d",194,"arcaneShip 0/2, deputShip 0/32, kazimierzShip 0/8, lateranoShip 1/63, miraShip 1/6, preciShip 1/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/54, skillfulShip 0/68, suntShip 1/14, swiftShip 1/55",10,57,47,127] + "h_0": [1,5,12,0,2,6,8,"bd084a10",173,"arcaneShip 0/7, kazimierzShip 0/8, miraShip 1/20, preciShip 1/46, raidShip 1/0, sargonShip 1/22, siracusaShip 1/34, suntShip 1/0, swiftShip 1/51, yanShip 0/13",10,57,47,130], + "ai_0": [1,13,13,0,2,6,8,"5a6df95e",182,"arcaneShip 0/2, deputShip 0/38, kazimierzShip 0/8, lateranoShip 1/57, miraShip 1/6, preciShip 1/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/54, skillfulShip 0/68, suntShip 1/14, swiftShip 1/49",15,57,42,127] } ], "players": { "h_0": { - "alive": false, - "victory": false, - "roundsPassed": 12, - "eliminatedRound": 13, - "lp": 0, + "alive": true, + "victory": true, + "roundsPassed": 14, + "eliminatedRound": null, + "lp": 4, "bandId": "band_amedic", - "title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"}, - "trophies": 3, - "reward": 280, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1950591,"gold":130,"itemsEquipped":14,"kills":218,"leaks":76,"lpLost":28,"merges":4,"perfectRounds":9,"refreshes":20}, - "lineup": [], - "bonds": ["arcaneShip 0/7","kazimierzShip 0/8","miraShip 0/30","preciShip 0/33","sargonShip 0/9","siracusaShip 0/46","swiftShip 0/51","yanShip 0/13"] + "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"}, + "trophies": 4, + "reward": 400, + "stats": {"activatedLayers":193,"bossDamage":275036,"dmgDealt":2465720,"gold":144,"itemsEquipped":20,"kills":244,"leaks":68,"lpLost":23,"merges":6,"perfectRounds":9,"refreshes":24}, + "lineup": ["5_10_a@12,3[1_02_e_b]","5_15_a@11,3[3_11_e_a]","3_13_b@11,8[1_01_e_a]","3_19_b@10,3[4_07_e_a+3_01_e_a]","4_16_a@10,7[4_03_e_b+3_07_e_a]","1_08_b@10,8[4_11_e_b+4_02_e_a]","6_05_a@9,8[6_09_e_a+6_01_e_b]","5_02_a@9,9"], + "bonds": ["arcaneShip 0/11","kazimierzShip 0/16","kjeragShip 0/8","miraShip 1/24","preciShip 1/50","sargonShip 1/22","siracusaShip 1/34","soloShip 1/0","suntShip 1/0","swiftShip 1/63","yanShip 0/13"] }, "ai_0": { "alive": true, - "victory": false, - "roundsPassed": 13, + "victory": true, + "roundsPassed": 14, "eliminatedRound": null, - "lp": 0, + "lp": 12, "bandId": "band_chiave", - "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, - "trophies": 3, - "reward": 320, - "stats": {"activatedLayers":280,"bossDamage":330674,"dmgDealt":3048187,"gold":142,"itemsEquipped":22,"kills":259,"leaks":47,"lpLost":10,"merges":6,"perfectRounds":12,"refreshes":21}, - "lineup": ["1_01_b@12,3[2_01_e_a+1_02_e_a]","4_21_a@12,7","3_21_b@11,3[3_01_e_a+1_05_e_b]","5_20_a@11,8[3_07_e_b]","3_01_b@10,3[4_08_e_b+6_05_e_a]","3_18_a@10,7[4_11_e_b+4_02_e_b]","4_16_a@10,8","6_01_a@9,8[5_09_e_a+3_08_e_a]"], - "bonds": ["arcaneShip 0/2","deputShip 0/32","kazimierzShip 0/8","lateranoShip 1/71","miraShip 1/6","preciShip 0/2","raidShip 0/2","sargonShip 0/2","siracusaShip 1/66","skillfulShip 1/68","suntShip 1/14","swiftShip 1/55"] + "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, + "trophies": 4, + "reward": 400, + "stats": {"activatedLayers":280,"bossDamage":504964,"dmgDealt":3358197,"gold":140,"itemsEquipped":22,"kills":260,"leaks":42,"lpLost":10,"merges":6,"perfectRounds":12,"refreshes":21}, + "lineup": ["1_01_b@12,3[2_01_e_a+3_08_e_b]","3_21_b@11,3[3_01_e_a+1_05_e_b]","5_20_a@11,8[3_07_e_b]","3_01_b@10,3[4_08_e_b+6_05_e_a]","3_18_a@10,7","4_16_a@10,8[4_11_e_a+4_02_e_a]","6_01_a@9,8[5_09_e_a+3_07_e_a]","4_21_a@9,9"], + "bonds": ["arcaneShip 0/2","deputShip 0/38","kazimierzShip 0/8","lateranoShip 1/71","miraShip 1/12","preciShip 0/2","raidShip 0/2","sargonShip 0/2","siracusaShip 1/66","skillfulShip 1/68","suntShip 1/14","swiftShip 1/49"] } }, "standIns": { @@ -1607,7 +1607,7 @@ [10,"3_18_a→char_609_acguad 4_16_a→char_615_acspec 5_10_a→char_606_csuppo"], [11,"4_16_a→char_615_acspec 5_10_a→char_606_csuppo"], [12,"4_16_a→char_615_acspec 5_10_a→char_606_csuppo 6_05_a→char_611_acnipe"], - [13,""] + [13,"4_16_a→char_615_acspec 5_10_a→char_606_csuppo 6_05_a→char_611_acnipe"] ] } }, diff --git a/test/golden/roster.json b/test/golden/roster.json index baf97f35..e5eabc90 100644 --- a/test/golden/roster.json +++ b/test/golden/roster.json @@ -2512,40 +2512,38 @@ "bossHpLeft": null, "rngDraws": 43, "errors": {"count":0,"kinds":[]}, - "players": { - "p1": {"killed":46,"total":47,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":8,"dmg":564702,"bossDmg":0,"heal":8941,"deaths":7,"layerGains":{"visiShip":5}} - }, + "players": {"p1":{"killed":46,"total":47,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":8,"dmg":535533,"bossDmg":0,"heal":17173,"deaths":4,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_4_23_a",1,null,0,0,63,7,28573,4358,0,47,1], - ["p1",2,"op","chess_char_4_01_b",1,"none",2872.7,1,9,1,7066,2118,0,105,2], - ["p1",3,"op","chess_char_4_19_a",1,null,0,0,53,0,1771,3120,0,38,2], - ["p1",4,"op","chess_char_4_22_a",1,null,2022,1,7,0,1416,3450,502,43,1], - ["p1",5,"op","chess_char_4_04_b",2,"uniequip_002_ines",2149,1,66,6,63599,10027,0,61,4], - ["p1",6,"op","chess_char_4_07_b",2,"none",2233,1,8,1,2150,400,1,1,1], - ["p1",7,"op","chess_char_4_09_b",2,"uniequip_004_mizuki",1756,1,12,18,321808,1511,0,52,2], - ["p1",8,"op","chess_char_4_20_a",1,null,1217,1,2,2,22436,0,0,82,3], - ["p1",9,"op","chess_char_4_24_a",2,null,1726,1,3,10,77993,0,0,31,4], - ["p1",10,"op","chess_char_4_25_a",1,null,1221,1,4,1,13938,0,0,0,3], + ["p1",1,"op","chess_char_4_23_a",1,null,2179,1,64,2,26142,2179,0,47,2], + ["p1",2,"op","chess_char_4_01_b",1,"none",3199.72,1,9,0,7648,1791,0,82,1], + ["p1",3,"op","chess_char_4_19_a",1,null,0,0,59,0,2327,5998,0,51,3], + ["p1",4,"op","chess_char_4_22_a",1,null,2022,1,7,0,623,2216,101,18,1], + ["p1",5,"op","chess_char_4_04_b",2,"uniequip_002_ines",465,1,55,4,71128,14681,0,103,2], + ["p1",6,"op","chess_char_4_07_b",2,"none",2233,1,8,0,0,0,0,0,0], + ["p1",7,"op","chess_char_4_09_b",2,"uniequip_004_mizuki",1492.6,1,12,26,296442,1494,0,52,2], + ["p1",8,"op","chess_char_4_20_a",1,null,1217,1,2,1,20712,0,0,82,3], + ["p1",9,"op","chess_char_4_24_a",2,null,1726,1,3,12,71831,0,0,21,4], + ["p1",10,"op","chess_char_4_25_a",1,null,1221,1,4,1,11911,0,0,0,3], ["p1",11,"op","chess_char_4_02_b",1,"none",2966.25,1,1,0,0,0,0,0,0], - ["p1",12,"op","chess_char_4_12_b",1,"none",4562.3,1,5,0,23952,0,0,69,4], - ["p1","~1","token","char_613_acmedc",null,null,1314,1,13,0,0,0,8438,19,2] + ["p1",12,"op","chess_char_4_12_b",1,"none",4562.3,1,5,0,26769,0,0,70,4], + ["p1","~1","token","char_613_acmedc",null,null,1314,1,13,0,0,0,17072,27,2] ], "enemies": { - "enemy_1001_bigbo": [1,1,0,2027,17600], + "enemy_1001_bigbo": [1,1,0,2124,17600], "enemy_10097_crshd": [1,1,0,0,8000], - "enemy_1045_hammer": [1,1,0,40,8000], - "enemy_1045_hammer_2": [6,5,0,16433,92110], - "enemy_1061_zomshd": [33,33,0,4573,410182], + "enemy_1045_hammer": [1,1,0,487,8000], + "enemy_1045_hammer_2": [6,5,0,19532,89637], + "enemy_1061_zomshd": [33,33,0,3921,383485], "enemy_1166_dusbr": [1,1,0,0,2400], "enemy_1204_msfhu_2": [1,1,0,0,6], "enemy_1288_duskls_2": [1,1,0,0,4805], "enemy_1427_lrnazg": [1,1,0,688,12000], - "enemy_2010_csdcr": [1,1,0,960,9600] + "enemy_2010_csdcr": [1,1,0,1080,9600] }, - "hooks": [1,66,5700,698,698,1192,1191,22137,53,54,29,23,63,27315,96,51,47,0,1,54,1,0,10], - "events": {"atk":1071,"bounty":4,"deploy":19,"die":54,"dmg":1134,"engage":10,"fx":194,"heal":24,"layer":1,"skill":52,"spawn":60,"status":330}, - "fx": {"aoe":2,"beam":4,"buff":1,"devour":4,"displace":45,"dodge":10,"ember":1,"garrison":1,"garrisonGrant":1,"link":3,"mote":72,"overload":3,"reweave":3,"riposte":7,"ripple":2,"sentry":5,"shadowWeave":20,"shield":4,"snow":1,"summon":1,"wake":4}, - "snaps": "10b0fc57 0b25ce77 4b6dd831 dfcc878d ea02c153 8f9524b2 a967f6bd 5b979090 1c1bc0ac a3a6e47e 27b92546 3018c6fd ff67392c 97352b97 dd49f777 68cea056 3b6f2f3a c0c82ec8 f88614a7" + "hooks": [1,64,5700,662,662,1105,1100,18020,50,51,27,23,45,27645,98,54,47,0,1,51,0,0,13], + "events": {"atk":997,"bounty":4,"deploy":17,"die":51,"dmg":1060,"engage":9,"fx":193,"heal":47,"skill":50,"spawn":60,"status":351}, + "fx": {"aoe":3,"beam":2,"devour":4,"displace":51,"dodge":13,"ember":1,"garrisonGrant":1,"link":3,"mote":69,"overload":3,"reweave":3,"riposte":7,"ripple":2,"sentry":3,"shadowWeave":19,"shield":3,"snow":1,"summon":1,"wake":4}, + "snaps": "10b0fc57 0b25ce77 2eb004e3 34bc73ed 2ae3d4fc 952fb4c4 fbed5c98 4dacadad a902ca6b d97ae173 1b59ad14 ba4ffa6d 9b7e70f2 318c9aa8 c804aa7e 79f72845 7a1a0e9d 96548306 9230b759" }, "roster-042": { "about": "pass 2 band 5: 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_5_01_a chess_char_4_16_b chess_char_4_13_b chess_char_4_20_b chess_char_4_24_b chess_char_4_25_b chess_char_5_02_a", diff --git a/test/ui/playtest6_summons.test.js b/test/ui/playtest6_summons.test.js index 954a7ae7..54cfa639 100644 --- a/test/ui/playtest6_summons.test.js +++ b/test/ui/playtest6_summons.test.js @@ -115,3 +115,37 @@ test('a golden owner\'s summon card shows the golden variant: 精锐 巫恋\'s d assert.equal(unit.type, 'token'); assert.equal(unit.ownerId, 'chess_char_3_15_b'); }); + +// GitHub #260 (PR #278): the 外勤医疗 strategy's map characters are operators of the mode — the card read them as summons +// (召唤物, an owner variant they do not have: base stats only) +const allText = (v) => { + if (v == null || typeof v === 'boolean') return ''; + if (typeof v === 'string' || typeof v === 'number') return String(v); + if (Array.isArray(v)) return v.map(allText).join(''); + if (typeof v === 'object' && typeof v.type === 'function' && ['Section', 'Stat', 'LiveTag'].includes(v.type.name)) return allText(v.type(v.props)); + if (typeof v === 'object' && typeof v.props?.text === 'string') return v.props.text; + return typeof v === 'object' ? allText(v.props?.children) : ''; +}; + +test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and show the skill, talents and 特性 of their record; 恳切福音 says what the sim does (低于一半)', async () => { + await data.loadAll('tokens', 'assets'); + for (const [id, want] of [ + ['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']], + ['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%', '攻击提升', '攻击力+4%', '恢复友方单位生命']], + ]) { + // a battle click resolves the unit as its token record + const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map()); + assert.equal(detail.type, 'token'); + const text = allText(TokenDetail(detail)); + assert.match(text, /干员/); + assert.match(text, /医疗/); + assert.doesNotMatch(text, /召唤物/); + for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`); + assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`); + } + // the record keeps the official sentence; the line under it is what touchGospel does (strictly below hp_ratio) + assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/); + assert.match(allText(TokenDetail({ token: TOKENS.char_613_acmedc })), /实际为生命值低于一半/); + // a summon keeps its summon card + assert.match(allText(TokenDetail({ token: TOKENS.token_10006_vodfox_doll, ownerId: 'chess_char_3_15_a' })), /召唤物/); +}); From c225559d4dd8974eafefe1463435ee628ffa5602 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 17:58:21 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feedback6=20R21E:=20an=20operator's=20?= =?UTF-8?q?=E5=87=8B=E4=BA=A1=20burst=20drains=20the=20official=20?= =?UTF-8?q?=E6=8A=80=E5=8A=9B=20=E2=80=94=20stored=20charges=20and=20the?= =?UTF-8?q?=20ready=20charge=20included=20(Skill.setSpTotal(spTotal=20?= =?UTF-8?q?=E2=88=92=201)=20each=20second;=20PRTS=20=E6=8A=80=E8=83=BD=20?= =?UTF-8?q?=E5=8F=AF=E5=85=85=E8=83=BD=20=E3=80=8C=E5=BD=93=E6=8C=81?= =?UTF-8?q?=E6=9C=89=E8=80=85=E7=9A=84=E6=8A=80=E5=8A=9B=E6=B5=81=E5=A4=B1?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E5=85=85=E8=83=BD=E6=AC=A1=E6=95=B0=E4=B9=9F?= =?UTF-8?q?=E4=BC=9A=E5=AE=9E=E6=97=B6=E9=99=8D=E4=BD=8E=E3=80=8D,=20PRTS?= =?UTF-8?q?=20=E5=85=83=E7=B4=A0=20=E3=80=8C=E6=AF=8F=E7=A7=92=E6=8D=9F?= =?UTF-8?q?=E5=A4=B11=E7=82=B9=E6=8A=80=E5=8A=9B=E3=80=8D):=20the=20drain?= =?UTF-8?q?=20took=20only=20the=20SP=20towards=20the=20next=20charge,=20so?= =?UTF-8?q?=20a=20full=20skill=20kept=20every=20charge=20and=20a=20one-cha?= =?UTF-8?q?rge=20skill=20stayed=20ready=20through=20the=2015=20s;=20a=20ru?= =?UTF-8?q?nning=20timed=20skill=20is=20left=20alone=20as=20before;=20PR?= =?UTF-8?q?=20#262=20(#261=20its=20closed=20duplicate),=20re-implemented;?= =?UTF-8?q?=20golden:=205=20moved,=20each=20by=20a=20burst=20on=20an=20ope?= =?UTF-8?q?rator=20with=20SP=20stored=20(=E5=81=87=E6=83=B3=E6=95=8C?= =?UTF-8?q?=EF=BC=9A=E7=AE=A1's=20=E5=87=8B=E4=BA=A1=20strikes,=20the=20?= =?UTF-8?q?=E8=90=A8=E5=8D=A1=E5=85=B9=E7=8E=8B=E5=BA=AD=E5=86=9B's=20?= =?UTF-8?q?=E5=87=8B=E4=BA=A1=20on=20hit)=20=E2=80=94=20fields=20hidden-bo?= =?UTF-8?q?ss=5F10-solo=20(=E6=B3=A5=E5=B2=A9=20loses=20its=20stored=20cha?= =?UTF-8?q?rge:=20casts=201=20=E2=86=92=200,=20the=20move=20PR=20#262=20re?= =?UTF-8?q?ported;=20the=20team's=20heals=20and=20the=20snapshots=20after?= =?UTF-8?q?=20it),=20boss-boss=5F3-solo=20/=20hidden-boss=5F10-pair=20and?= =?UTF-8?q?=20diy-058=20(later=20snapshots=20and=20event=20counts=20only,?= =?UTF-8?q?=20the=20same=20results),=20diy-059=20(=E9=BB=8D=20forms=20agai?= =?UTF-8?q?nst=20the=20=E8=90=A8=E5=8D=A1=E5=85=B9=E7=8E=8B=E5=BA=AD?= =?UTF-8?q?=E5=86=9B=20waves:=20fewer=20casts=20=E2=80=94=2011=20=E2=86=92?= =?UTF-8?q?=205,=206=20=E2=86=92=202,=209=20=E2=86=92=205,=206=20=E2=86=92?= =?UTF-8?q?=201=20=E2=80=94=20the=20battle=20goes=20otherwise,=20kills=203?= =?UTF-8?q?9=20=E2=86=92=2047,=20leaks=2057=20=E2=86=92=2049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 2 +- server/sim/damage.js | 8 ++- test/content/feedback6-apoptosis-sp.test.js | 65 +++++++++++++++++++++ test/golden/diy.json | 40 ++++++------- test/golden/fields.json | 34 +++++------ 5 files changed, 109 insertions(+), 40 deletions(-) create mode 100644 test/content/feedback6-apoptosis-sp.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 16a03c93..45d84c7b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -663,7 +663,7 @@ the source's side): |---|---|---| | `burn` 灼燃 | 1200 arts + RES −20, 10 s lock | 7000 元素伤害 + RES −20, 10 s lock | | `neural` 神经 | stun 10 s, then 1000 true (10 s lock) | 3 `palsy` (none with 麻痹免疫), then 6000 元素伤害, 10 s lock | -| `apoptosis` 凋亡 | 15 s: 阻回 (`noSp`: no SP gain of any kind, skills.js) + 静默 (no skill activation), −1 SP/s, 100 arts/s | 15 s: 50 % weaken recovering over the burst, 800 元素伤害/s | +| `apoptosis` 凋亡 | 15 s: 阻回 (`noSp`: no SP gain of any kind, skills.js) + 静默 (no skill activation), −1 技力/s — of `spTotal`, stored charges included (PRTS 技能 可充能 "当持有者的技力流失时,充能次数也会实时降低"; `setSpTotal`, a running timed skill untouched; PR #262), 100 arts/s | 15 s: 50 % weaken recovering over the burst, 800 元素伤害/s | | `erosion` 侵蚀 | permanent DEF −100 (stacking `erosionDown`) then 800 phys, 10 s lock | permanent DEF −120 then 5000 元素伤害, 8 s lock | | `necrosis` (legacy spare gauge) | 12 s: 100 true/s, ATK −20 % | same | diff --git a/server/sim/damage.js b/server/sim/damage.js index d6f170fd..72e95d92 100644 --- a/server/sim/damage.js +++ b/server/sim/damage.js @@ -478,12 +478,16 @@ function resolveBurst(battle, source, target, el) { }, }); } else { - // 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second + // 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second. + // The loss is of the official 技力 (Skill.spTotal: the stored charges × cost + the SP towards the next) — PRTS 技能 + // 可充能 "当持有者的技力流失时,充能次数也会实时降低": taking it from the partial bar alone left a full skill every + // charge it had (a one-charge skill stayed ready through the burst; PR #262). setSpTotal rebuilds the charges + // silently (no spGain) and leaves a running timed skill alone, whose SP was spent at its cast. lock(c.duration, { flags: { silence: true, noSp: true }, interval: 1, onTick: () => { const sk = target.skill; - if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec); + if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.spTotal > 0) sk.setSpTotal(Math.max(0, sk.spTotal - c.spLossPerSec)); hit(c.dps, c.dpsType); }, }); diff --git a/test/content/feedback6-apoptosis-sp.test.js b/test/content/feedback6-apoptosis-sp.test.js new file mode 100644 index 00000000..0bb80e7b --- /dev/null +++ b/test/content/feedback6-apoptosis-sp.test.js @@ -0,0 +1,65 @@ +// PR #262 (#261 its closed duplicate): an operator's 凋亡 burst — PRTS 元素 "立即获得等时长的阻回、静默,该阻回、静默持续期间 +// 每秒损失1点技力并受到100点无来源法术持续伤害" — drains the official 技力, stored charges included: PRTS 技能 可充能 "当持有者 +// 的技力流失时,充能次数也会实时降低". The drain took only the SP towards the next charge, so a full skill (sp = cost with +// every charge stored) kept all its charges — a one-charge skill stayed ready through the whole burst. An active timed +// skill is left alone (its SP was spent at the cast; Skill.setSpTotal leaves it too). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; +const SNOW = 'chess_char_3_11_b'; // 精锐 雪猎: S2 风雪连弩, MANUAL, 可充能2次, 14 SP per charge + +function arena(units, defs = {}) { + const h = makeBattle({ defs, units, autoFinish: false, timeLimit: 120, hooks: ['spGain', 'damaged'] }); + h.step(); + return h; +} +const burst = (h, u) => { + h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: u.gaugeMax }); + assert.ok(u.findBuff('apoptosisBurst'), '凋亡 bursts'); +}; + +test('PR #262: 凋亡 drains a charge skill\'s stored charges — 精锐 雪猎 with both charges loses one at the first second and is empty after 15', REAL, () => { + const h = arena([{ chessId: SNOW, row: 10, col: 4 }]); + const u = h.unit(SNOW), sk = u.skill; + assert.equal(sk.maxCharges, 2); + const cost = sk.spCost; + sk.setSpTotal(2 * cost); + assert.deepEqual([sk.charges, sk.sp, sk.spTotal], [2, cost, 2 * cost], 'both charges stored'); + burst(h, u); + h.run(1.01); + assert.deepEqual([sk.charges, sk.sp, sk.spTotal], [1, cost - 1, 2 * cost - 1], 'one second: −1 技力, the second charge goes'); + assert.equal(sk.ready, true, 'one charge left'); + h.run(14); + // (the burst ends with its 15th second: the time SP of the ticks since then is back) + assert.equal(sk.charges, 0, '15 seconds: −15 技力 — the last charge gone too'); + assert.ok(Math.abs(sk.spTotal - (2 * cost - 15)) < 0.1, `${sk.spTotal} ≈ ${2 * cost - 15}`); + assert.equal(sk.ready, false); + assert.equal(sk.activations, 0, '静默: no cast through the burst'); + assert.ok(!u.findBuff('apoptosisBurst'), 'the burst is over'); + checkInvariants(h.b); +}); + +test('PR #262: a full one-charge skill is no longer ready after the first second of 凋亡; an active timed skill keeps its state', () => { + const op = chessRec({ id: 't_apo', stats: { maxHp: 1e5, spRecovery: 0 }, skill: { spCost: 20, initSp: 0, duration: 30, durationType: 'NONE' } }); + const h = arena([{ chessId: 't_apo', row: 10, col: 4, uid: 1 }, { chessId: 't_apo', row: 10, col: 6, uid: 2 }], { chess: { t_apo: op } }); + const a = h.unit(1), b = h.unit(2); + a.skill.setSpTotal(20); + assert.equal(a.skill.ready, true); + burst(h, a); + h.run(1.01); + assert.deepEqual([a.skill.charges, a.skill.sp, a.skill.ready], [0, 19, false], 'the ready charge is part of the 技力 it loses'); + h.run(14); + assert.equal(a.skill.spTotal, 5, '−15 技力 in all'); + assert.equal(h.hooksOf('spGain').length, 0, 'the drain is no SP gain (no spGain hook)'); + // a running timed skill: its SP was spent at the cast — nothing to drain, and it runs on (the burst silences new casts only) + b.skill.setSpTotal(20); + assert.ok(b.skill.activate('test')); + const before = [b.skill.active, b.skill.charges, b.skill.sp]; + burst(h, b); + h.run(3); + assert.deepEqual([b.skill.active, b.skill.charges, b.skill.sp], before); + checkInvariants(h.b); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index b38a0fc4..b452fd5f 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -2382,7 +2382,7 @@ "hooks": [1,129,6242,303,303,643,724,1632,21,124,15,15,0,5917,2,46,105,103,1,21,0,13,0], "events": {"atk":309,"deploy":21,"die":21,"dmg":681,"engage":10,"fx":124,"heal":41,"leak":103,"skill":30,"spawn":120,"status":46}, "fx": {"aoe":24,"beam":20,"burst":13,"crit":2,"explode":1,"healField":10,"shield":9,"thorns":45}, - "snaps": "8af17d6a 024bce8c e0452b2c 5af87abd 42a1f8a9 0b71523e 3fd3e515 fa3770c9 2ca7b522 c32eaf65 dffbf1b7 224a00a2 994934f9 5bc28beb 05894548 582c0f90 dd02f272 cbdb633c 2fb767c6 ad6a50e0 205812fb" + "snaps": "8af17d6a c4ee14aa b0c6352a 4663534b ad4f6c57 79103640 6a18ed37 6ce034df 01e7e370 1a22aca7 2ab6744d 07e795ec 55eb2147 66c813ad 16426e3e 9e7597da 454aaf0c ba882526 bb829c2c 99b4e6c6 a4e6b5a1" }, "diy-059": { "about": "5_diy1_b=shu/S1/002_shu 5_diy2_b=shu/S2/002_shu 5_diy1_b=shu/S3/002_shu 6_diy1_a=shu/S1 6_diy2_a=shu/S2 6_diy1_a=shu/S3 6_diy2_b=shu/S1 6_diy1_b=shu/S2 6_diy2_b=shu/S3 6_diy1_b=shu/S1/002_shu 6_diy2_b=shu/S2/002_shu 6_diy1_b=shu/S3/002_shu", @@ -2396,35 +2396,35 @@ "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 39, + "killed": 47, "total": 96, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":39,"total":96,"perfect":false,"leaks":57,"leaksUncounted":0,"coins":0,"dmg":234243,"bossDmg":0,"heal":203629,"deaths":18,"layerGains":{}}}, + "players": {"p1":{"killed":47,"total":96,"perfect":false,"leaks":49,"leaksUncounted":0,"coins":0,"dmg":231786,"bossDmg":0,"heal":217108,"deaths":15,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",0,0,103,2,14695,21669,15436,64,11], - ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shu",0,0,119,0,7297,21517,15906,36,1], - ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shu",3070,1,120,6,14925,16583,5503,77,1], - ["p1",4,"op","chess_char_6_diy1_a",0,null,0,0,99,1,7880,21342,8555,56,6], - ["p1",5,"op","chess_char_6_diy2_a",1,null,0,0,101,0,5224,32777,48418,50,2], - ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,77,2,11366,9710,12836,42,1], - ["p1",7,"op","chess_char_6_diy2_b",0,"none",2791.49,1,107,5,36418,40843,15790,135,9], - ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,102,2,16600,20626,10010,73,1], - ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,5,5,15654,5050,26206,69,2], - ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_shu",0,0,8,0,14640,10510,10285,43,6], - ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_shu",0,0,10,6,39992,33225,14891,106,1], - ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_shu",3180,1,116,7,49553,20305,19793,109,2], + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",0,0,106,1,16402,16000,9569,59,5], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shu",2945.74,1,119,0,5494,19342,17088,32,2], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shu",3070,1,120,5,16030,16314,5378,80,1], + ["p1",4,"op","chess_char_6_diy1_a",0,null,2313,1,100,2,12751,26007,6577,66,2], + ["p1",5,"op","chess_char_6_diy2_a",1,null,2057.85,1,103,1,13344,39122,47357,75,2], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,77,2,11366,9681,12807,42,1], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",2183.12,1,102,6,34558,41766,13074,133,5], + ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,99,2,16300,33752,26299,85,2], + ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,5,5,16102,3554,24484,71,2], + ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_shu",0,0,8,1,6712,7983,4179,20,1], + ["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_shu",0,0,10,5,35380,29094,14012,94,1], + ["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_shu",3180,1,115,9,47347,18313,36285,106,2], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1229_darmy":[27,13,14,45139,72535],"enemy_1229_darmy_2":[57,26,31,120688,198119],"enemy_9007_acelem":[12,0,11,69460,101381]}, - "hooks": [1,120,6900,1449,1449,4412,4696,22580,57,113,43,42,0,15690,0,93,96,56,1,57,0,39,0], - "events": {"atk":1500,"deploy":21,"die":57,"dmg":4696,"engage":12,"fx":181,"heal":328,"leak":56,"skill":85,"spawn":111,"status":105}, - "fx": {"beam":18,"burst":39,"healField":47,"teleport":77}, - "snaps": "8dc1b37a b5460f3d 3555fcc6 db56014a a0553a57 f4dca0ac f05a71e9 77e33494 98645a14 e2eefd2d bd5020fc d9664555 4c789e18 d251cfc2 3e54ab89 a00b133e 02012e0d 11dc3504 389257ff 0e7213e4 96b7c324 1b69e292 0f8b3ea2" + "enemies": {"enemy_1229_darmy":[27,15,12,48739,79249],"enemy_1229_darmy_2":[57,32,23,133695,210887],"enemy_9007_acelem":[12,0,10,58192,72262]}, + "hooks": [1,120,6900,1513,1513,4343,4631,22639,62,107,26,25,0,15564,0,93,96,45,1,62,0,37,0], + "events": {"atk":1572,"deploy":21,"die":62,"dmg":4631,"engage":12,"fx":189,"heal":335,"leak":45,"skill":51,"spawn":111,"status":101}, + "fx": {"beam":18,"burst":37,"healField":49,"teleport":85}, + "snaps": "8dc1b37a b5460f3d 3ef6a531 1b763d75 2ca58274 c2fef8fd afccd50e 9e6a1580 6921be5e a89eb8c3 950cf27b 18a4160c e6764ecc cf15dcc9 dbfe2e1c fd2de4b2 aaaf0692 702a1a56 289559cc 2f1168ca 69dba497 ce110259 f0b47a01" }, "diy-060": { "about": "5_diy1_a=pallas/S1 5_diy2_a=pallas/S2 5_diy1_a=pallas/S3 5_diy2_b=pallas/S1 5_diy1_b=pallas/S2 5_diy2_b=pallas/S3 5_diy1_b=pallas/S1/002_pallas 5_diy2_b=pallas/S2/002_pallas 5_diy1_b=pallas/S3/002_pallas 5_diy2_b=pallas/S1/003_pallas 5_diy1_b=pallas/S2/003_pallas 5_diy2_b=pallas/S3/003_pallas", diff --git a/test/golden/fields.json b/test/golden/fields.json index be5ee8d0..9f773474 100644 --- a/test/golden/fields.json +++ b/test/golden/fields.json @@ -375,7 +375,7 @@ "hooks": [1,42,6000,387,387,643,1580,796,20,24,80,78,20,17921,1173,2,19,0,1,139,0,1,0], "events": {"atk":405,"deploy":17,"die":24,"dmg":733,"engage":8,"fx":203,"heal":59,"skill":158,"spawn":36,"status":179}, "fx": {"anchor":1,"aoe":3,"beam":6,"burst":1,"candle":3,"ember":3,"explode":104,"healAoe":34,"overload":4,"phase":12,"revive":1,"splash":20,"substitute":1,"summon":6,"swap":1,"telegraph":1,"teleport":2}, - "snaps": "87076623 3c36dd04 03a89072 745a7c6b 24a96213 0ed332a6 26e97a3b 2b49b7d6 376df693 78dab18f 1b931756 f174873b d4541524 71b41d30 2da111bb 5c2d56c1 fa32d0c4 f6866cb6 7c0b0632 9a7cccfc" + "snaps": "87076623 3c36dd04 03a89072 745a7c6b 24a96213 0ed332a6 26e97a3b 2b49b7d6 4deb0556 087cdcb6 79fbf5f7 dfe3d106 fdaa7bd5 af470bb9 c87c0fc2 ed37f880 df193341 1e30dddf 21a08fc7 5270e9dd" }, "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", @@ -1283,8 +1283,8 @@ "enemy_9022_acdumm": [1,0,0,478,0], "enemy_9023_acdums": [12,12,0,25367,120] }, - "hooks": [1,53,6000,1112,1250,2538,2725,6368,26,28,66,63,0,29865,1406,6,18,0,1,28,0,10,0], - "events": {"atk":1217,"deploy":33,"die":28,"dmg":2714,"engage":14,"fx":1020,"heal":66,"skill":129,"spawn":42,"status":675}, + "hooks": [1,53,6000,1112,1250,2538,2725,6368,26,28,66,63,0,29862,1406,6,18,0,1,28,0,10,0], + "events": {"atk":1217,"deploy":33,"die":28,"dmg":2714,"engage":14,"fx":1020,"heal":66,"skill":129,"spawn":42,"status":683}, "fx": { "beam": 13, "blink": 2, @@ -1321,7 +1321,7 @@ "weightless": 5, "zone": 76 }, - "snaps": "a3593c93 05df9199 329883cc 83065c46 6d189e2d d27e20b7 d0119d66 53c297b8 408cec99 65434e27 1d704b57 5517a58b dc7344d6 58d4ad2b b8585956 3bb8237c 89be2434 4474d986 12d9ba25 a4ab8999" + "snaps": "a3593c93 05df9199 329883cc 83065c46 6d189e2d d27e20b7 d0119d66 53c297b8 408cec99 65434e27 1d704b57 5517a58b dc7344d6 58d4ad2b b8585956 eedd57cd 2c69cc89 3e330a00 d5d56836 2efc2732" }, "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", @@ -1339,32 +1339,32 @@ "total": 13, "unspawned": 0, "bossHpLeft": 7156236, - "rngDraws": 28, + "rngDraws": 27, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":12,"total":13,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":595215,"bossDmg":443764,"heal":73194,"deaths":0,"layerGains":{}}}, + "players": {"p1":{"killed":12,"total":13,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":595215,"bossDmg":443764,"heal":72505,"deaths":0,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3513,1,4,1,1,0,0,1,0], - ["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",7440.8,1,6,0,5,3861,4102,5,1], - ["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",4147.8,1,7,4,193323,7944,2803,230,5], + ["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",7440.8,1,6,0,5,3861,4169,5,0], + ["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",4147.8,1,7,4,193323,7934,2790,230,5], ["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3600.6,1,5,0,0,2336,0,0,0], - ["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",3905.8,1,8,7,206512,35880,6134,154,3], + ["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",3905.8,1,8,7,206512,35241,6004,154,3], ["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1419,1,1,0,170086,0,0,70,7], - ["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",2920.8,1,9,0,0,4595,39389,67,2], + ["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",2920.8,1,9,0,0,4595,39464,67,2], ["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2178,1,2,0,25288,0,0,20,1], - ["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1671,1,10,0,0,16053,3215,0,3], - ["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1522,1,3,0,0,2241,17551,29,6] + ["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1671,1,10,0,0,15953,3078,0,3], + ["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1522,1,3,0,0,2241,17000,28,6] ], "enemies": { "enemy_1005_yokai_3": [1,0,1,13187,65325], "enemy_9010_acpupp": [2,2,0,25403,86026], "enemy_9021_acduml_2": [1,0,0,0,443764], "enemy_9022_acdumm": [1,0,0,1006,0], - "enemy_9023_acdums": [10,10,0,29635,100] + "enemy_9023_acdums": [10,10,0,28891,100] }, - "hooks": [1,25,6000,614,614,1220,1745,4474,12,13,28,26,0,15177,45,8,15,1,1,18,0,7,0], - "events": {"atk":746,"deploy":10,"die":12,"dmg":1740,"engage":6,"fx":464,"heal":136,"leak":1,"skill":56,"spawn":25,"status":248}, - "fx": {"bandage":6,"beam":15,"burst":7,"ember":2,"explode":188,"featherArrow":7,"garrisonGrant":1,"healField":2,"jungleSoul":5,"mote":68,"phase":29,"redistribute":40,"reweave":3,"rockslide":1,"sandChainsCharged":1,"shieldBreak":7,"slash":60,"spGift":9,"summon":8,"telegraph":2,"truesilver":3}, - "snaps": "e9089456 d56595f6 94862b5d c7e389c2 97042251 346900c6 d3f1fdf3 ee72f858 3559a133 032bf766 7d5d0f98 ab1e397a ca88665f 19e0cc43 4f90670f aa7bdb4e f43775ca a84c46d1 705ff5fd 5b7831a7" + "hooks": [1,25,6000,613,613,1210,1727,4456,12,13,27,25,0,15178,45,7,15,1,1,18,0,7,0], + "events": {"atk":746,"deploy":10,"die":12,"dmg":1722,"engage":6,"fx":460,"heal":135,"leak":1,"skill":54,"spawn":25,"status":248}, + "fx": {"bandage":6,"beam":15,"burst":7,"ember":2,"explode":185,"featherArrow":7,"garrisonGrant":1,"healField":2,"jungleSoul":5,"mote":68,"phase":29,"redistribute":40,"reweave":3,"sandChainsCharged":1,"shieldBreak":7,"slash":60,"spGift":9,"summon":8,"telegraph":2,"truesilver":3}, + "snaps": "e9089456 d56595f6 94862b5d c7e389c2 97042251 346900c6 d3f1fdf3 ee72f858 76fb6eac 826bd52d 00497c63 de189efc 37b5cbcd ab1762e2 8644257f 4db1c850 efe502ae b7764884 f14f9be1 89f56bcc" }, "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", From ce2bd75b49d5ebf8e63db342f95b5b1e81f5ec80 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 18:02:48 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feedback6=20R21E:=20a=20refresh=20runs=20th?= =?UTF-8?q?e=20=E5=88=B7=E6=96=B0=E6=97=B6=20=E7=89=B9=E8=B4=A8=20of=20the?= =?UTF-8?q?=20chess=20that=20stood=20on=20the=20board=20/=20in=20the=20han?= =?UTF-8?q?d=20when=20it=20happened=20(EffectDispatcher.dispatch=20takes?= =?UTF-8?q?=20them=20before=20its=20first=20handler;=20each=20still=20runs?= =?UTF-8?q?=20where=20it=20stands=20now,=20one=20that=20left=20is=20skippe?= =?UTF-8?q?d):=20the=20=E6=8B=89=E6=99=AE=E5=85=B0=E5=BE=B7=20=E8=B4=BE?= =?UTF-8?q?=E7=BB=B4=20grants=20on=20every=206th=20refresh=20=E2=80=94=20o?= =?UTF-8?q?r=20the=20elite=20that=20gift=20completes=20=E2=80=94=20fired?= =?UTF-8?q?=20in=20the=20very=20refresh=20that=20granted=20her=20and=20spe?= =?UTF-8?q?nt=20her=20=E3=80=8C=E6=9C=AC=E5=9B=9E=E5=90=88=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E4=B8=BB=E5=8A=A8=E5=88=B7=E6=96=B0=E3=80=8D;=20now?= =?UTF-8?q?=20her=20first=20is=20the=20next=20manual=20refresh;=20round=20?= =?UTF-8?q?counters=20are=20still=20not=20copied=20on=20a=20merge=20(a=20m?= =?UTF-8?q?erged=20elite=20is=20a=20new=20=E6=8B=89=E6=99=AE=E5=85=B0?= =?UTF-8?q?=E5=BE=B7,=20=C2=A725.13.3);=20the=20part=20of=20PR=20#196=200.?= =?UTF-8?q?2.0=20lacked,=20re-implemented;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/META.md | 2 +- server/match/effectsMeta.js | 32 ++++++++++++++++++++++--- server/sim/content/garrisons/meta.js | 4 +++- test/match/feedback1-meta.test.js | 35 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/META.md b/docs/META.md index b5cd6288..3963ffa3 100644 --- a/docs/META.md +++ b/docs/META.md @@ -371,7 +371,7 @@ that per garrison with `garrisonHooks(garrison) → hook[]`, e.g. "<进入休整 | `SERVER_GAIN` 获得时 | `onGain` | the gained piece — run **×2 while 投资人 is active, ×3 at ≥ 100 投资人 layers** | | `SERVER_PREP_START` 进入休整期时 | `onRoundStart` | owned chess: board, and hand unless `bbStr.conditionkey === 'character_target_inboard'` | | `SERVER_PREP_FIN` 休整期结束时 | `onPrepEnd` | same | -| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same — manual refreshes only (`ev.trigger` marks a re-run); 拉普兰德's "本回合首次主动刷新" counts per copy (`incPieceCounter`) | +| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same, as they stood when the refresh happened (taken before its first handler: a chess gained during the dispatch — 贾维's gift, the elite it completes — waits for the next refresh; one that left is skipped) — manual refreshes only (`ev.trigger` marks a re-run); 拉普兰德's "本回合首次主动刷新" counts per copy (`incPieceCounter`) | | `SERVER_CHESS_SOLD` 售出时 | `onSold` | the sold piece | | `SERVER_PRICE` 购买价格 | `onPrice` (first) | the chess in the priced slot (`ctx.source.where === 'shop'`); SERVER_CHESS_PRICE `bb.price` is the discount off the tier price (至简 3 − 2 = 1, 红豆 2 − 1 = 1: both texts say 购买价格为1) | | `IN_BATTLE` | — | battle side (server/sim/content/garrisons.js `install`) | diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index df1bdc84..5ef54039 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -18,7 +18,9 @@ // onRoundStart, SERVER_PREP_FIN→onPrepEnd, SERVER_CHESS_SOLD→onSold of the sold piece, SERVER_PRICE→onPrice of the // slot's chess, SERVER_REFRESH_SHOP→onRefresh); the dispatcher calls `handler[hook] ?? handler.run`. A handler may // widen that per garrison with `garrisonHooks(garrison) → hook[]` (e.g. "<进入休整期时><休整期结束时>"). Owned-piece -// garrisons fire for board pieces, and for hand pieces unless bbStr.conditionkey is 'character_target_inboard'. +// garrisons fire for board pieces, and for hand pieces unless bbStr.conditionkey is 'character_target_inboard'. A +// refresh (onRefresh) runs those that stood on the board / in the hand when it happened (taken before its first +// handler): a chess gained during its dispatch — 贾维's gift, the elite that gift completes — waits for the next refresh. // 投资人 (investShip) active ⇒ SERVER_GAIN garrisons run ×2 (×3 at ≥ 100 layers) — owned by the dispatcher. // ctx.triggerGarrisons(uid, eventType) re-runs another piece's garrisons (铃兰, "触发…的获得时效果", 特质相同). // Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on @@ -204,6 +206,10 @@ export class EffectDispatcher { if (deferItems) ps._deferItemMerge = (ps._deferItemMerge || 0) + 1; try { const reg = this.registry; + // onRefresh: the board / hand chess as they stood when the refresh happened, taken before any handler runs — a + // chess gained during this dispatch (贾维's gift on the 6th refresh, the elite its copy completes) was not there + // when it happened: its 刷新时 特质 waits for the next manual refresh (拉普兰德's "本回合首次主动刷新", PR #196) + const refreshed = hook === 'onRefresh' ? refreshPieces(ps) : null; // 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on if (hook === 'onPrice') this._garrisons(ps, hook, ev); // 1. globals @@ -221,7 +227,7 @@ export class EffectDispatcher { if (h) this._call(ps, key, h, hook, { kind: 'bond', key, bondId, bond: ps.bonds[bondId] ?? null }, ev); } // 4. garrisons (onPrice: already run as step 0) - if (hook !== 'onPrice') this._garrisons(ps, hook, ev); + if (hook !== 'onPrice') this._garrisons(ps, hook, ev, refreshed); // 5. equipped items (not for the item-specific hooks): every [holder, item] pair of the owned chess, taken before // the first item runs; each runs only while still equipped on its still-owned holder — handlers move / destroy // pieces (header). Taking the pairs per holder as the walk reached it ran an item equipped meanwhile onto a later @@ -279,7 +285,12 @@ export class EffectDispatcher { return [GARRISON_HOOK[g.eventType]]; } - _garrisons(ps, hook, ev) { + /** + * Run the owned chess's garrisons of `hook`. `refreshed` (onRefresh): the board / hand chess taken when the dispatch + * began (refreshPieces) — each runs if it is still on the board or in the hand (where it stands now); a chess gained + * meanwhile does not run, nor one that left (sold, consumed by a merge, moved to the 临时整备区). + */ + _garrisons(ps, hook, ev, refreshed = null) { const gd = ps.gd || this.m.gd; const run = (piece, where) => { const rec = gd.chess(piece.id); @@ -304,6 +315,13 @@ export class EffectDispatcher { return; } if (hook !== 'onRoundStart' && hook !== 'onPrepEnd' && hook !== 'onRefresh') return; + if (refreshed) { + for (const piece of refreshed) { + const loc = ps.find(piece.uid); + if (loc && loc.piece === piece && (loc.area === 'board' || loc.area === 'hand')) run(piece, loc.area); + } + return; + } for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') run(piece, 'board'); for (const p of ps.hand) if (p && p.kind === 'chess') run(p, 'hand'); } @@ -385,6 +403,14 @@ function ownedChess(ps) { return out; } +/** The board chess (reading order) then the hand chess — the pieces whose 刷新时 特质 a refresh runs (onRefresh). */ +function refreshPieces(ps) { + const out = []; + for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') out.push(piece); + for (const p of ps.hand) if (p && p.kind === 'chess') out.push(p); + return out; +} + /** Dispatch step 5: is `it` still equipped on `holder`, and `holder` still an owned chess of `ps`? */ function stillEquipped(ps, holder, it) { if (!Array.isArray(holder.items) || !holder.items.includes(it)) return false; diff --git a/server/sim/content/garrisons/meta.js b/server/sim/content/garrisons/meta.js index 6931434e..90c670e4 100644 --- a/server/sim/content/garrisons/meta.js +++ b/server/sim/content/garrisons/meta.js @@ -202,7 +202,9 @@ H.SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER = { // (ev.trigger) is no manual refresh: it neither fires nor counts. A new copy (bought, granted) starts at 0, and so does // an elite merged this round: a new 拉普兰德, its own first manual refresh this round adds +8 even when its copies // already fired (GitHub #169; the owner's decision of 2026-10-06 — PlayerState._mergeChess carries no counter over). -// [ASSUMED]: a copy bought after selling one this round is a new copy — "获得该干员后" — and fires on its own first +// A copy gained by a refresh's own effects (贾维's gift on every 6th refresh, or the elite that gift completes) did not +// witness that refresh: the dispatcher runs the chess that stood there when it happened (effectsMeta.js onRefresh), so +// her first is the next manual refresh (PR #196). [ASSUMED]: a copy bought after selling one this round is a new copy — "获得该干员后" — and fires on its own first // refresh (the server cannot tell it from any other copy; each such +4 costs her price + a refresh − the 1-fund refund, // and needs her in the shop again). const REFRESH_CNT_KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT:refreshes'; // per-piece counter (module-prefixed) diff --git a/test/match/feedback1-meta.test.js b/test/match/feedback1-meta.test.js index c1bd2af2..38456a6c 100644 --- a/test/match/feedback1-meta.test.js +++ b/test/match/feedback1-meta.test.js @@ -184,6 +184,41 @@ test('#1 only manual refreshes count: a re-triggered "刷新时" trait (ctx.trig m.dispose(); }); +// PR #196 (the part 0.2.0 lacks): 贾维 【团伙行动】 grants a 叙拉古 operator on every 6th manual refresh from that refresh's +// own dispatch (band step, before the garrisons). The 拉普兰德 it grants — or the elite its copy completes — is gained +// after the refresh happened: her "本回合首次主动刷新" is the next manual one. The dispatcher walks the board and hand as +// they stood when the refresh happened (EffectDispatcher.dispatch onRefresh snapshot); it walked them live, so she fired +// (and spent her first refresh) in the refresh that granted her. Round counters are not copied on a merge (§25.13.3). +for (const variant of ['a new copy', 'a copy that completes the elite']) { + test(`#1 贾维 refresh gift (${variant}, PR #196): the 拉普兰德 waits for the next manual refresh`, () => { + const s = setup(); + const { m, ps } = s; + ps.bandId = 'band_chiave'; + ps.shop.level = 2; + // 贾维's draw (ctx.rollChess on the meta RNG) gives 拉普兰德; the shop rolls stay as they are + const roll = m.pool.roll.bind(m.pool); + m.pool.roll = (rng, opts = {}) => (rng === m.rngMeta && typeof opts.filter === 'function' && opts.filter(LAP) ? LAP : roll(rng, opts)); + s.activate(); + const elite = variant !== 'a new copy'; + if (elite) { give(m, ps, LAP, 'hand'); give(m, ps, LAP, 'hand'); } + for (let i = 0; i < 5; i++) s.refresh(); + const before = s.L(); + assert.equal(before, elite ? 8 : 0, 'the copies already owned fire on the round\'s first refresh'); + assert.ok(!ps.allChess().some((p) => p.id === LAP_B) && ps.allChess().filter((p) => p.id === LAP).length === (elite ? 2 : 0)); + s.refresh(); // the 6th: 贾维's gift + const lap = ps.allChess().find((p) => p.id === (elite ? LAP_B : LAP)); + assert.ok(lap, elite ? 'the gift completed the elite' : 'the gift is owned'); + assert.equal(s.L(), before, 'not in the refresh that granted her'); + s.refresh(); + assert.equal(s.L(), before + (elite ? 8 : 4), 'her first manual refresh is the next one'); + s.refresh(); + assert.equal(s.L(), before + (elite ? 8 : 4), 'once a round'); + assert.equal(m.dispatcher.errors, 0); + checkInvariants(m); + m.dispose(); + }); +} + // --------------------------------------------------------------------------------------------------------------------- // #4 突变细胞 From f2f4657e7534e072b369ccb19e714447f60aa3f7 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 18:03:54 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feedback6=20R21E:=20.gitignore=20lists=20.n?= =?UTF-8?q?pm-cache/=20=E2=80=94=20a=20local=20npm=20cache=20(npm=5Fconfig?= =?UTF-8?q?=5Fcache=3D./.npm-cache,=20npm=20ci=20--cache=20.npm-cache)=20i?= =?UTF-8?q?s=20never=20committed=20by=20any=20contributor=20(GitHub=20#258?= =?UTF-8?q?);=20test/gitignore.test.js=20asks=20git=20itself;=20golden:=20?= =?UTF-8?q?unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ test/gitignore.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 test/gitignore.test.js diff --git a/.gitignore b/.gitignore index 767b4166..37a6479b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # --- dependencies & generated client libs ------------------------------------------------------- node_modules/ +# a local npm cache (npm_config_cache=./.npm-cache, npm ci --cache .npm-cache — GitHub #258) +.npm-cache/ # copied from node_modules by tools/vendor.mjs (postinstall); never commit third-party builds public/vendor/ diff --git a/test/gitignore.test.js b/test/gitignore.test.js new file mode 100644 index 00000000..b5cbb29c --- /dev/null +++ b/test/gitignore.test.js @@ -0,0 +1,31 @@ +// .gitignore keeps per-machine state out of the repository (and so out of the release packages, which ship tracked +// files only: tools/package.mjs). GitHub #258: a local npm cache (`npm_config_cache=./.npm-cache`, `npm ci --cache +// .npm-cache`) must never be committed by any contributor. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const lines = readFileSync(path.join(ROOT, '.gitignore'), 'utf8').split(/\r?\n/).map((l) => l.trim()); + +/** git's own verdict on a path (null when git or the repository is not available). */ +function ignored(rel) { + try { + execFileSync('git', ['check-ignore', '-q', '--no-index', rel], { cwd: ROOT, stdio: 'ignore' }); + return true; + } catch (e) { + return e && e.status === 1 ? false : null; + } +} + +test('GitHub #258: the local npm cache (.npm-cache/) is ignored, like node_modules/ and .cache/', () => { + for (const entry of ['.npm-cache/', 'node_modules/', '.cache/']) assert.ok(lines.includes(entry), `.gitignore lists ${entry}`); + for (const rel of ['.npm-cache/_cacache/index-v5/00/aa', '.npm-cache/_logs/debug-0.log']) { + const v = ignored(rel); + if (v !== null) assert.equal(v, true, `${rel} is ignored`); + } + assert.notEqual(ignored('package.json'), true, 'a tracked file is not'); +});