fix: 外勤医疗预置干员保留初始索敌顺序 (#315)

This commit is contained in:
YinqiMargatroid committed 2026-10-09 22:04:28 +08:00
1 parent 62eb113419
commit 3b57f72704
9 files changed
+96 -7

No files matched your search

+3 -1
View File
@@ -78,7 +78,9 @@ summon piece content flags `deferDeploy` (one its owner's loadout does not make,
one) — firing `deploy {initial:true}` for
each, forces out the operators that enter knocked out (`carryState.down`, 联防 — §1.1), then fires `battleStart`. Every
summon that came in during the initial deployment (also one an operator's deploy brought along) then ranks after all
the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field)
the operators in the aggro order (`unit.aggroSeq`, §1.2). The 外勤医疗 stage map character (预备干员-医疗 or its Touch
replacement) is the exception: its slot exists before this deployment, so its aggro order precedes the operators
although `battleStart` creates its sim unit after counting the deployed elites [ASSUMED for the exact order]. On a shared field (联防, the boss field)
the players' fields deploy side by side: the i-th operators of all players come in together (in `players` order), then
the summons the same way, and the summons rank after the operators of all players **[ASSUMED]** (PRTS describes one
field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
+1 -1
View File
@@ -117,7 +117,7 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators; the 外勤医疗 stage map character and its Touch replacement retain pre-deployment target priority [ASSUMED for the exact order]) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free, and 乌尔比安 knocked out while his S3 has moved him always on his deployment tile when it is free (the owner's deviation of 2026-10-07, `Unit.downAtHome`, §25.17.3) — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A stunned or frozen enemy is checked too — 晕眩 / 冻结 hold no 不可阻挡 (PRTS 异常效果: the STUN / FROZEN state machines stop its moves and attacks) —, so an operator redeployed beside a held enemy blocks it where it stands and lifts its 隐匿; one held short of contact is blocked once it walks in after the status (GitHub #232; until 0.2.0 the check waited for the status to end). A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first — a 速射手's air priority (优先攻击空中单位) excepted, which comes before the enemy it blocks (the owner's decision of 2026-10-08, GitHub #220) — ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7).
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18; 「同时攻击阻挡的所有敌人」 — 强攻手 / 重剑手 / 推击手 and the skills worded so, `hitAllBlocked` — up to the block count, never fewer than 1, since 0.2.0: PRTS 分支特性信息 "普通攻击最大目标数等于阻挡数(不会低于1)"), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18).
+19
View File
@@ -1575,3 +1575,22 @@ Notes for the integrator
none left) or 「{who}:没有可获得的同盟约干员」; the item is still destroyed. en / ja / ko / zh-TW.
- **Golden**: unchanged. **Tests**: test/content/items.test.js (the #207 cases assert the toast; a new case: one 缪尔赛思,
her pool taken → the bond toast), test/content/feedback5-cb1-pins.test.js 45 (the toast below level 6, none at 6).
### 27.64 外勤医疗's pre-battle map character retains its earlier aggro when Touch replaces it — `content/bands/battle.js installMapChars`
- **Seen**: the user observes 预备干员-医疗 already visible on the field during preparation. With two elite operators,
`battleStart` creates Touch only after all board units have deployed. This gives the sim unit the latest `aggroSeq`;
a local boss test makes “萨米的意志” select Touch's column first. In the
[official recording](https://b23.tv/JZLqdSO) at about 4:57, Touch stands at the right edge while 冰凌 falls on a
different column. The recording supports a mismatch, but does not by itself isolate every other target's taunt.
- **Official**: [PRTS 卫戍协议:盟约 下半](https://prts.wiki/w/%E5%8D%AB%E6%88%8D%E5%8D%8F%E8%AE%AE%EF%BC%9A%E7%9B%9F%E7%BA%A6_%E4%B8%8B%E5%8D%8A)
describes a 预备干员-医疗 appearing on every player's field and being replaced by Touch at combat start if at least two
elite operators stand there. PRTS 索敌 places the last deployed target ahead at equal taunt. [ASSUMED]: the
replacement retains the map character's pre-deployment aggro position; the sources do not fix its exact numeric rank.
- **Now**: only the 外勤医疗 map character (预备干员-医疗 or Touch) gets an early `aggroSeq` after its `battleStart`
spawn. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and
mid-battle summons retain their existing priority.
- **Golden**: one value moves in `test/golden/matches.json`: `coop2-NORMAL-14-standins`, player `h_0`'s
`dmgDealt` 3,000,543 → 2,999,081. That match fields 外勤医疗, so its changed initial target order can alter damage;
every other recorded result is unchanged. **Tests**: `test/content/bands.test.js` checks both variants' priority and
“萨米的意志” 冰凌 selection on solo and two-player boss fields.
+5
View File
@@ -41,6 +41,8 @@ export class BattleSummons {
* skill, a summon that skill does not produce (producesToken) is refused unless `opts.anySource`: hand-authored kit
* install hooks are written for the default skill and run under every skill (琳琅诗怀雅 S1/S3 would drop 香槟炸弹);
* under the default skill the kit stays the authority.
* `opts.preBattleAggro` is only for a prepared stage map character instantiated at battleStart after its variant
* is chosen; its aggro precedes initial operators despite the later deploy event.
*/
spawnToken(owner, tokenId, row, col, opts = {}) {
const ownerUnit = owner && typeof owner === 'object' ? owner : null;
@@ -66,6 +68,9 @@ export class BattleSummons {
this.offOwner(u);
return null;
}
// A map character already visible in preparation may be instantiated at battleStart after the elite count is
// known. [ASSUMED] Its replacement keeps the slot's pre-deployment aggro order; ordinary summons stay latest.
if (opts.preBattleAggro) u.aggroSeq = -1; // aggroCmp falls back to deploySeq when aggroSeq is zero
if (opts.hp != null && Number.isFinite(Number(opts.hp))) u.hp = Math.max(1, Math.min(u.s.maxHp, Number(opts.hp)));
if (opts.duration > 0 && Number.isFinite(Number(opts.duration))) this.after(Number(opts.duration), () => { if (u.alive) this.retreat(u, { reason: 'expired', permanent: true }); }, { owner: u });
return u;
+3 -1
View File
@@ -241,7 +241,9 @@ function installMapChars(battle) {
for (const ps of holders) {
const elites = deployedOps(battle, ps.playerId).filter(isElite).length;
for (const id of amedicCharsFor(elites, variants)) {
const u = spawnMapChar(battle, ps.playerId, id);
// The map character is visible before combat; Touch replaces it here once the elite count is known.
// [ASSUMED] The replacement retains the stage slot's earlier aggro order.
const u = spawnMapChar(battle, ps.playerId, id, { preBattle: true });
if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND);
}
}
+5 -3
View File
@@ -1564,9 +1564,11 @@ function touchKit(bb, raw, def) {
/**
* Spawn a band map character (char_605_cmedic 预备干员-医疗 / char_613_acmedc Touch) at its stage position on the
* player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). Returns the unit or null.
* player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). `preBattle` preserves the
* aggro order of a character already visible in preparation; ad-hoc mid-battle spawns keep ordinary summon order.
* Returns the unit or null.
*/
export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) {
export function spawnMapChar(battle, playerId, tokenId, { alias = null, preBattle = false } = {}) {
const list = battle.stage?.raw?.mapChars ?? battle.stage?.mapChars ?? [];
const ps = battle.getPlayer(playerId);
const multi = battle.players.length > 1;
@@ -1583,7 +1585,7 @@ export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) {
for (const m of cands) {
if (!tileFree(battle, m.pos[0], m.pos[1])) continue;
// stage positions are field tiles: their direction is taken as it is (default RIGHT)
const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir) });
const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir), preBattleAggro: preBattle });
if (u) return u;
}
return null;
+57
View File
@@ -707,6 +707,63 @@ test('Touch 外勤医疗: every player of a match with the band gets 预备干
cover('band_amedic');
});
test('外勤医疗 map character already on its slot keeps earlier aggro than the initially deployed operators', () => {
for (const [golden, mapCharId] of [[false, 'char_605_cmedic'], [true, 'char_613_acmedc']]) {
const defs = {
e1: op('e1', { golden, stats: { maxHp: 1e7, atk: 0 } }),
e2: op('e2', { golden, stats: { maxHp: 1e7, atk: 0 } }),
last: op('last', { stats: { maxHp: 1e7, atk: 0 } }),
};
const sharedBoss = { hp: 1e6, maxHp: 1e6, damage(pid, n) { this.hp -= n; } };
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', bandId: 'band_amedic', sharedBoss,
defs: { chess: defs }, units: [
{ chessId: 'e1', row: 10, col: 5 }, { chessId: 'e2', row: 11, col: 6 }, { chessId: 'last', row: 12, col: 7 },
],
timeLimit: 100, autoFinish: false,
});
h.step();
const mapChar = h.unit(mapCharId);
assert.ok(mapChar?.alive, mapCharId);
assert.ok(h.unit('e1').aggroSeq < h.unit('e2').aggroSeq);
assert.ok(h.unit('e2').aggroSeq < h.unit('last').aggroSeq);
assert.ok(mapChar.aggroSeq < h.unit('e1').aggroSeq, `${mapCharId} keeps its pre-battle slot priority`);
const boss = h.spawn('enemy_9033_acdeer', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
assert.ok(boss);
h.run(boss.s.interval + 0.1);
const columns = h.eventsOf('fx').filter((f) => f[1] === 'column').map((f) => f[4].c);
assert.equal(columns[0], 7, `${mapCharId}: 萨米的意志 targets the last deployed operator's column`);
checkInvariants(h.b);
}
});
test('外勤医疗 Touch does not take either player’s first 冰凌 on a shared boss field', () => {
const defs = {
e1: op('e1', { golden: true, stats: { maxHp: 1e7, atk: 0 } }),
e2: op('e2', { golden: true, stats: { maxHp: 1e7, atk: 0 } }),
last: op('last', { stats: { maxHp: 1e7, atk: 0 } }),
};
const players = ['p1', 'p2'].map((playerId, seat) => ({
playerId, seat, side: seat ? 'R' : 'L', colOffset: 0, bandId: 'band_amedic', bonds: {}, playerEffects: [],
units: ['e1', 'e2', 'last'].map((chessId, i) => ({ uid: seat * 10 + i + 1, kind: 'chess', chessId, row: 10 + i, col: 5 + i })),
}));
const sharedBoss = { hp: 1e6, maxHp: 1e6, damage(pid, n) { this.hp -= n; } };
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m01', players, sharedBoss,
defs: { chess: defs }, timeLimit: 100, autoFinish: false });
h.step();
const touches = h.allies().filter((u) => u.defId === 'char_613_acmedc');
assert.equal(touches.length, 2);
assert.ok(touches.every((u) => u.aggroSeq < h.allies().filter((a) => a.kind === 'op' && a.ownerId === u.ownerId)[0].aggroSeq));
const boss = h.spawn('enemy_9033_acdeer', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
assert.ok(boss);
h.run(boss.s.interval * 2 + 0.1);
const columns = h.eventsOf('fx').filter((f) => f[1] === 'column').map((f) => f[4].c);
assert.deepEqual(columns.slice(0, 2), [7, 13]);
checkInvariants(h.b);
});
test('coverage: every band is exercised by a test above', () => {
cover('band_dusk'); // 夕: 画卷 grant (fixed-round test) + 墨色真颜 (battle test)
const missing = Object.keys(DATA.bands).filter((id) => !COVER.has(id)).sort();
+2
View File
@@ -511,6 +511,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
assert.ok(med);
assert.deepEqual([med.tileR, med.tileC], [10, 2]);
const gu = h.unit('test_guard');
assert.ok(med.aggroSeq > gu.aggroSeq, 'an ad-hoc spawn after battle start keeps normal summon priority');
gu.hp = 5000;
h.run(4);
assert.ok(gu.hp > 5000, 'healed');
@@ -518,6 +519,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
h.step();
const touch = spawnMapChar(h.b, 'p1', TOKEN_IDS.touch);
assert.ok(touch);
assert.ok(touch.aggroSeq > gu.aggroSeq, 'an ad-hoc Touch also keeps normal summon priority');
touch.skill.activate('test', { free: true });
gu.hp = 3000;
h.run(4);
+1 -1
View File
@@ -1598,7 +1598,7 @@
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":3000543,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21},
"stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":2999081,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21},
"lineup": ["6_18_a@12,3[4_11_e_a]","4_10_a@12,4","1_17_b@11,3[4_03_e_a+1_01_e_a]","5_20_a@11,8[6_05_e_a+3_06_e_a]","3_17_b@10,3","3_12_a@10,4[1_02_e_a+3_11_e_a]","5_07_a@10,7[4_02_e_b+2_01_e_b]","1_19_b@10,8[2_01_e_b+4_04_e_b]"],
"bonds": ["arcaneShip 2/24","kazimierzShip 1/64","miraShip 1/12","preciShip 0/8","raidShip 1/108","siracusaShip 1/12","skillfulShip 0/3","soloShip 1/0","suntShip 1/0","swiftShip 0/23","yanShip 0/1"]
},