灵巧's knocked-out aura counts only knocked-out operators; hidden-layer bonds hide their layers in the UI only (#66)
A knocked-out 灵巧 operator gives its neighbours the aura once more; a player retreat, a merchant exit or a 联防 forced exit no longer counts, and a redeploy restores it. 调和, 协防干员, 独行 and 绝技 hide their layer counts in the UI while the real layers stay for saving, settlement and support ranking. Contributed by @sunstricken.
This commit is contained in:
1 parent
8747f45bba
commit
85b91b9013
7 files changed
+305
-10
No files matched your search
@@ -58,6 +58,7 @@ Core (isPower, bondType SEASON): 炎 萨尔贡 维多利亚 谢拉格 拉特兰
|
|||||||
### 2.2 Layers (层数)
|
### 2.2 Layers (层数)
|
||||||
|
|
||||||
- Each player has one integer layer counter L per bond (23 counters). All start at 0 when the simulation starts, are never reset between rounds and are never reduced (no layer-loss effect exists in the data) [ASSUMED: never reduced].
|
- Each player has one integer layer counter L per bond (23 counters). All start at 0 when the simulation starts, are never reset between rounds and are never reduced (no layer-loss effect exists in the data) [ASSUMED: never reduced].
|
||||||
|
- `noStack: true` hides layer counts for 调和 / 协防干员 / 独行 / 绝技; it does not prohibit gains or clear stored layers. Their effects depend on member thresholds, but internal layers still participate in active-layer totals and unite ordering. [PRTS 下半盟约记录](https://prts.wiki/w/卫戍协议:盟约_下半/PRTS盟约记录): hidden-layer bonds still accept trait / strategy / equipment layer gains (review correction, 2026-10-04).
|
||||||
- L is kept while the bond is inactive, but bond effects only apply while the bond is active (PRTS 盟约记录).
|
- L is kept while the bond is inactive, but bond effects only apply while the bond is active (PRTS 盟约记录).
|
||||||
- Wording contract used by every source: "使已激活的【X】层数+N" -> add only if bond X is active at the moment of the trigger; "(无需激活盟约)" -> add even if X is inactive. "自身所属盟约" = every bond of that operator; "自身已激活的盟约" = only its bonds that are active.
|
- Wording contract used by every source: "使已激活的【X】层数+N" -> add only if bond X is active at the moment of the trigger; "(无需激活盟约)" -> add even if X is inactive. "自身所属盟约" = every bond of that operator; "自身已激活的盟约" = only its bonds that are active.
|
||||||
- No layer cap in the data. Caps are per source: max_add_count_per_battle (layers a single garrison instance may add in one battle), max_layer (per round, e.g. 安洁莉娜 12/round). The official cap is in the client, not the data: `AutoChessBattleConst.MAX_GARRISON_STACK = 999`, `AddBondCount` stores `min(L + n, 999)` per bond (research 11 §1). Since 2026-10-01 (DESIGN §20.12) the remake stops each bond at 999 (`shared/constants.js BOND_LAYER_CAP`, `layerGainRoom`); the community agrees (巴哈姆特 12534 "每把都能999层", "沒999層的盟約情況下"; 12316 "999謝").
|
- No layer cap in the data. Caps are per source: max_add_count_per_battle (layers a single garrison instance may add in one battle), max_layer (per round, e.g. 安洁莉娜 12/round). The official cap is in the client, not the data: `AutoChessBattleConst.MAX_GARRISON_STACK = 999`, `AddBondCount` stores `min(L + n, 999)` per bond (research 11 §1). Since 2026-10-01 (DESIGN §20.12) the remake stops each bond at 999 (`shared/constants.js BOND_LAYER_CAP`, `layerGainRoom`); the community agrees (巴哈姆特 12534 "每把都能999层", "沒999層的盟約情況下"; 12316 "999謝").
|
||||||
@@ -614,6 +615,7 @@ Members (14 chess, 12 in current shop pool; by tier in shop: {'1': 2, '2': 2, '3
|
|||||||
|
|
||||||
- **[2 distinct]** 灵巧 members and operators on their 4 orthogonally adjacent tiles: ASPD +(10 + 1*L).
|
- **[2 distinct]** 灵巧 members and operators on their 4 orthogonally adjacent tiles: ASPD +(10 + 1*L).
|
||||||
- **[L >= 40]** Area becomes the 8 surrounding tiles.
|
- **[L >= 40]** Area becomes the 8 surrounding tiles.
|
||||||
|
- 灵巧干员被击倒(`removeReason === 'killed'`)、等待再部署时,仍以倒地位置为中心为周围队友提供攻速加成。主动撤退及联防 `forcedExit` 虽然也留下倒地模型,但不继续提供光环;联防强制离场不算本阶段击倒,重新部署后恢复在场光环(评审修正,2026-10-04)。
|
||||||
- Formulas: `aspd = 10 + L`
|
- Formulas: `aspd = 10 + L`
|
||||||
- How layers are gained: 溯光星源 休整期结束 +2 灵巧/奥术 per 3 gold spent this round ; 断崖 +3 self & behind; 空弦 +3 self & front; 蒂比 +2; 获得时 灵知 +5. (act1 灵巧 also had a 20-layer shop reward; removed in act2.)
|
- How layers are gained: 溯光星源 休整期结束 +2 灵巧/奥术 per 3 gold spent this round ; 断崖 +3 self & behind; 空弦 +3 self & front; 蒂比 +2; 获得时 灵知 +5. (act1 灵巧 also had a 20-layer shop reward; removed in act2.)
|
||||||
- [ASSUMED] a unit covered by several 灵巧 auras gets the bonus once
|
- [ASSUMED] a unit covered by several 灵巧 auras gets the bonus once
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
|
|||||||
const next = nextThreshold(b.count ?? 0, th);
|
const next = nextThreshold(b.count ?? 0, th);
|
||||||
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-harmony=${b.harmony > 0 ? b.harmony : null}
|
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-harmony=${b.harmony > 0 ? b.harmony : null}
|
||||||
class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
|
class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
|
||||||
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
|
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${rec?.noStack ? undefined : b.layers ?? 0}
|
||||||
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
|
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
|
||||||
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
|
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
|
||||||
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} />
|
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} />
|
||||||
@@ -121,7 +121,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
|||||||
<h3 class="bpop__name">${b.name}</h3>
|
<h3 class="bpop__name">${b.name}</h3>
|
||||||
<div class="bpop__facts">
|
<div class="bpop__facts">
|
||||||
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>(含${harmonyName} +${harmony})</small>` : null}</span>`}
|
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>(含${harmonyName} +${harmony})</small>` : null}</span>`}
|
||||||
<span>层数 <b class="num t-mint">${layers}</b></span>
|
${b.noStack ? html`<span>层数不显示</span>` : html`<span>层数 <b class="num t-mint">${layers}</b></span>`}
|
||||||
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
|
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
|||||||
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
|
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
|
||||||
</div>
|
</div>
|
||||||
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
|
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
|
||||||
<h4>当前效果 <small class="num">(${layers} 层)</small></h4>
|
<h4>当前效果 ${b.noStack ? null : html`<small class="num">(${layers} 层)</small>`}</h4>
|
||||||
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
|
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
|
||||||
</section>` : null}
|
</section>` : null}
|
||||||
<section class="bpop__sec">
|
<section class="bpop__sec">
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
|
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
|
||||||
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
|
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
|
||||||
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
|
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
|
||||||
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit
|
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit;
|
||||||
|
// killed members keep providing it from their body tile until redeploy; retreat / forcedExit do not
|
||||||
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s; tier 2: ×power_weak_scale
|
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s; tier 2: ×power_weak_scale
|
||||||
// when the target is below hp_ratio at application. ONE instance per target whatever applies it — the
|
// when the target is below hp_ratio at application. ONE instance per target whatever applies it — the
|
||||||
// two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高:
|
// two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高:
|
||||||
@@ -219,10 +220,14 @@ function updateAura(battle, st) {
|
|||||||
next.clear();
|
next.clear();
|
||||||
const offs = st.auraWide ? N8 : N4;
|
const offs = st.auraWide ? N8 : N4;
|
||||||
for (const m of st.members[ID.skillful]) {
|
for (const m of st.members[ID.skillful]) {
|
||||||
if (!onField(m)) continue;
|
const active = onField(m);
|
||||||
next.add(m);
|
// isDown also covers voluntary / forced exits since v0.1.2. Only a kill keeps the aura.
|
||||||
|
if (!active && (m.removeReason !== 'killed' || !battle.isDown(m))) continue;
|
||||||
|
if (active) next.add(m);
|
||||||
|
// A knocked-out member still covers neighbours around the tile where it waits to redeploy.
|
||||||
|
const [r, c] = active ? [m.tileR, m.tileC] : battle.restTile(m);
|
||||||
for (const [dr, dc] of offs) {
|
for (const [dr, dc] of offs) {
|
||||||
const a = battle.unitAt(m.tileR + dr, m.tileC + dc);
|
const a = battle.unitAt(r + dr, c + dc);
|
||||||
if (a && a.kind === 'op' && a.ownerId === st.pid && onField(a)) next.add(a);
|
if (a && a.kind === 'op' && a.ownerId === st.pid && onField(a)) next.add(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ test('迅捷 / 不屈 proc chances: p = min(1, base + per·L) at each layer coun
|
|||||||
close(procChance(ind, 1000), 1, 'capped');
|
close(procChance(ind, 1000), 1, 'capped');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, follows death and relocation', () => {
|
test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, survives death and follows relocation', () => {
|
||||||
const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), k_f: op('k_f', []) } };
|
const defs = { chess: { k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']), k_n: op('k_n', []), k_d: op('k_d', []), k_f: op('k_f', []) } };
|
||||||
const h = makeBattle({
|
const h = makeBattle({
|
||||||
defs, bonds: { skillfulShip: bond(1, 5) },
|
defs, bonds: { skillfulShip: bond(1, 5) },
|
||||||
@@ -107,7 +107,117 @@ test('灵巧: aura on members + 4 neighbours (once), 8 tiles at ≥40 layers, fo
|
|||||||
assert.equal(a('k_n'), 100, 'relocated out of the aura');
|
assert.equal(a('k_n'), 100, 'relocated out of the aura');
|
||||||
h.b.dealDamage(null, h.unit('k_1'), { amount: 1e9, type: 'true' });
|
h.b.dealDamage(null, h.unit('k_1'), { amount: 1e9, type: 'true' });
|
||||||
h.step(2);
|
h.step(2);
|
||||||
assert.equal(a('k_d'), 100, 'aura source died');
|
assert.equal(a('k_d'), 150, 'downed aura source still buffs neighbours');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const layers of [5, 40]) {
|
||||||
|
test(`灵巧: downed sources keep one aura at ${layers} layers, update live layers and restore it on redeploy`, () => {
|
||||||
|
const defs = { chess: {
|
||||||
|
k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']),
|
||||||
|
k_n: op('k_n', []), k_d: op('k_d', []),
|
||||||
|
} };
|
||||||
|
const h = makeBattle({
|
||||||
|
defs, bonds: { skillfulShip: bond(1, layers) },
|
||||||
|
units: [
|
||||||
|
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 10, col: 6 },
|
||||||
|
{ chessId: 'k_n', row: 10, col: 5 }, { chessId: 'k_d', row: 11, col: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
const first = h.unit('k_1'), second = h.unit('k_2'), neighbour = h.unit('k_n'), diagonal = h.unit('k_d');
|
||||||
|
assert.equal(neighbour.s.aspd, 110 + layers, 'overlapping sources buff once');
|
||||||
|
h.b.kill(first);
|
||||||
|
assert.ok(h.b.isDown(first));
|
||||||
|
assert.equal(neighbour.s.aspd, 110 + layers, 'no attack speed drop on death');
|
||||||
|
h.b.kill(second);
|
||||||
|
assert.ok(h.b.isDown(second));
|
||||||
|
h.run(1);
|
||||||
|
assert.equal(neighbour.s.aspd, 110 + layers, 'all sources down: polling keeps the aura');
|
||||||
|
assert.equal(diagonal.s.aspd, layers >= 40 ? 110 + layers : 100, '4 / 8 tile range still applies');
|
||||||
|
assert.equal(neighbour.buffs.filter((b) => b.key === 'bond:skillfulShip').length, 1);
|
||||||
|
|
||||||
|
gainLayers(h.b, { playerId: 'p1', bonds: 'skillfulShip', n: 40 - layers + 2 });
|
||||||
|
h.step(2);
|
||||||
|
assert.equal(neighbour.s.aspd, 152, 'downed sources use live layer values');
|
||||||
|
assert.equal(diagonal.s.aspd, 152, 'downed sources widen their aura at 40 layers');
|
||||||
|
h.b.kill(neighbour);
|
||||||
|
assert.ok(h.b.redeploy(neighbour, { free: true }));
|
||||||
|
assert.equal(neighbour.s.aspd, 152, 'recipient redeployed next to a downed source');
|
||||||
|
assert.ok(h.b.redeploy(first, { free: true }));
|
||||||
|
assert.equal(first.s.aspd, 152, 'source regains its own bonus on redeploy');
|
||||||
|
assert.equal(neighbour.s.aspd, 152, 'redeploy does not stack the aura');
|
||||||
|
h.b.retreat(first);
|
||||||
|
assert.equal(neighbour.s.aspd, 152, 'remaining downed source still covers the recipient');
|
||||||
|
assert.ok(h.b.redeploy(second, { free: true }));
|
||||||
|
h.b.retreat(second);
|
||||||
|
assert.equal(neighbour.s.aspd, 100, 'withdrawn sources no longer provide an aura');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('灵巧: a downed source provides its aura around the body tile when it returns home after death', () => {
|
||||||
|
const defs = { chess: {
|
||||||
|
k_1: op('k_1', ['skillfulShip']), k_2: op('k_2', ['skillfulShip']),
|
||||||
|
k_home: op('k_home', []), k_away: op('k_away', []), k_tile: op('k_tile', []),
|
||||||
|
} };
|
||||||
|
const h = makeBattle({
|
||||||
|
defs, bonds: { skillfulShip: bond(1, 5) },
|
||||||
|
units: [
|
||||||
|
{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_2', row: 12, col: 3 },
|
||||||
|
{ chessId: 'k_home', row: 10, col: 5 }, { chessId: 'k_away', row: 10, col: 8 },
|
||||||
|
{ chessId: 'k_tile', row: 10, col: 7 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
h.b.retreat(h.unit('k_tile'), { permanent: true });
|
||||||
|
assert.ok(h.b.relocate(h.unit('k_1'), 10, 7));
|
||||||
|
h.run(0.4);
|
||||||
|
assert.equal(h.unit('k_home').s.aspd, 100);
|
||||||
|
assert.equal(h.unit('k_away').s.aspd, 115);
|
||||||
|
h.b.kill(h.unit('k_1'));
|
||||||
|
assert.deepEqual(h.unit('k_1').body, [10, 4]);
|
||||||
|
assert.equal(h.unit('k_home').s.aspd, 115, 'aura follows the downed body back home');
|
||||||
|
assert.equal(h.unit('k_away').s.aspd, 100, 'last living tile no longer provides an aura');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const reason of ['retreat', 'forcedExit', 'merchant']) {
|
||||||
|
test(`灵巧: ${reason} leaves a down model without preserving the killed-source aura`, () => {
|
||||||
|
const h = makeBattle({
|
||||||
|
defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } },
|
||||||
|
bonds: { skillfulShip: bond(1, 5) },
|
||||||
|
units: [{ chessId: 'k_1', row: 10, col: 4 }, { chessId: 'k_n', row: 10, col: 5 }],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
const source = h.unit('k_1'), recipient = h.unit('k_n');
|
||||||
|
assert.equal(recipient.s.aspd, 115);
|
||||||
|
h.b.retreat(source, { reason });
|
||||||
|
assert.ok(h.b.isDown(source), 'a down model is not proof of being killed');
|
||||||
|
assert.equal(source.removeReason, reason);
|
||||||
|
assert.equal(recipient.s.aspd, 100, 'aura stops immediately on retreat');
|
||||||
|
assert.ok(h.b.redeploy(source, { free: true }));
|
||||||
|
assert.equal(recipient.s.aspd, 115, 'the living source restores its aura');
|
||||||
|
h.b.kill(source);
|
||||||
|
assert.equal(recipient.s.aspd, 115, 'a subsequent real kill keeps the aura');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('灵巧: unite carry-down forcedExit starts without an aura and restores it on redeploy', () => {
|
||||||
|
const h = makeBattle({
|
||||||
|
kind: 'unite',
|
||||||
|
defs: { chess: { k_1: op('k_1', ['skillfulShip']), k_n: op('k_n', []) } },
|
||||||
|
bonds: { skillfulShip: bond(1, 40) },
|
||||||
|
units: [{ chessId: 'k_1', row: 10, col: 4, carryState: { down: true } }, { chessId: 'k_n', row: 11, col: 5 }],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
const source = h.unit('k_1'), recipient = h.unit('k_n');
|
||||||
|
assert.equal(source.removeReason, 'forcedExit');
|
||||||
|
assert.ok(h.b.isDown(source));
|
||||||
|
assert.equal(recipient.s.aspd, 100, 'forcedExit is not a kill in this battle');
|
||||||
|
assert.ok(h.b.redeploy(source, { free: true }));
|
||||||
|
assert.equal(recipient.s.aspd, 150, 'the redeployed source covers all eight neighbours');
|
||||||
checkInvariants(h.b);
|
checkInvariants(h.b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DATA, makeMatch } from './harness.js';
|
||||||
|
import { GameData } from '../../server/match/gamedata.js';
|
||||||
|
import { bondSnapshot } from '../../server/match/bondsMeta.js';
|
||||||
|
import { buildBattleSpec, createBattleFromSpec, compactResult } from '../../server/sim/spec.js';
|
||||||
|
import { validateClientResult } from '../../server/match/fields.js';
|
||||||
|
import { helperStats, helperOrder } from '../../server/match/unite.js';
|
||||||
|
|
||||||
|
const IDS = ['maniShip', 'emptyShip', 'soloShip', 'suntShip'];
|
||||||
|
const gd = new GameData(DATA, 'mode_multi_normal');
|
||||||
|
const bonds = () => Object.fromEntries(IDS.map((id) => [id, { count: 2, active: true, tier: 1, layers: 20 }]));
|
||||||
|
|
||||||
|
test('hidden-layer bonds retain saved layers, prep gains, pending gains and battle input layers', () => {
|
||||||
|
const h = makeMatch({ mode: 'solo', humans: 1, fake: true }).start();
|
||||||
|
try {
|
||||||
|
h.toPrep(1);
|
||||||
|
const ps = h.ps('p_0');
|
||||||
|
for (const id of IDS) {
|
||||||
|
assert.equal(gd.bond(id).noStack, true, id);
|
||||||
|
ps.bondCountBonus[id] = gd.bond(id).thresholds[0];
|
||||||
|
ps.layers[id] = 50;
|
||||||
|
}
|
||||||
|
ps.bondCountBonus.deputShip = 2;
|
||||||
|
ps.recompute();
|
||||||
|
for (const id of IDS) {
|
||||||
|
assert.equal(ps.bonds[id].active, true);
|
||||||
|
assert.equal(ps.layers[id], 50, 'recompute must not erase saved layers');
|
||||||
|
assert.equal(ps.addLayers(id, 8, { requireActive: true }), 8);
|
||||||
|
assert.equal(ps.addLayers(id, 8, { requireActive: false }), 8);
|
||||||
|
}
|
||||||
|
h.m.dispatch(ps, 'onPrepEnd', { round: 1 });
|
||||||
|
for (const id of IDS) assert.equal(ps.layers[id], 68, '助力 prep-end traits can add hidden layers');
|
||||||
|
ps.pendingLayerGains = Object.fromEntries(IDS.map((id) => [id, 9]));
|
||||||
|
for (const id of IDS) {
|
||||||
|
assert.equal(ps.bondsView()[id].layers, 77);
|
||||||
|
assert.equal(bondSnapshot(ps.bonds)[id].layers, 68, 'battle input keeps settled layers');
|
||||||
|
}
|
||||||
|
} finally { h.m.dispose(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden-layer gains run through the simulator hooks, event stream and result', () => {
|
||||||
|
const spec = buildBattleSpec({ stageId: 'act2autochess_m01', timeLimit: 1, content: 'none',
|
||||||
|
players: [{ playerId: 'p_0', units: [], bonds: bonds() }], flags: { layerGainsEnabled: true } });
|
||||||
|
const b = createBattleFromSpec(spec, DATA, { quiet: true });
|
||||||
|
const calls = [];
|
||||||
|
b.on('layerGain', (c) => calls.push(c.bondId));
|
||||||
|
for (const id of IDS) {
|
||||||
|
assert.equal(b.getPlayer('p_0').bonds[id].layers, 20);
|
||||||
|
assert.equal(b.addLayers('p_0', id, 4), 4);
|
||||||
|
assert.equal(b.getPlayer('p_0').bonds[id].layers, 24);
|
||||||
|
}
|
||||||
|
assert.deepEqual(calls, IDS);
|
||||||
|
assert.deepEqual(b.drainEvents().filter((e) => e[0] === 'layer').map((e) => e[2]), IDS);
|
||||||
|
b.forceEnd();
|
||||||
|
assert.deepEqual(b.result().perPlayer.p_0.layerGains, Object.fromEntries(IDS.map((id) => [id, 4])));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client validation accepts legal hidden-layer gains and still rejects malformed JSON values', () => {
|
||||||
|
const spec = buildBattleSpec({ stageId: 'act2autochess_m01', timeLimit: 1, content: 'none',
|
||||||
|
players: [{ playerId: 'p_0', units: [], bonds: bonds() }], flags: { layerGainsEnabled: true } });
|
||||||
|
const b = createBattleFromSpec(spec, DATA, { quiet: true });
|
||||||
|
for (const id of IDS) b.addLayers('p_0', id, 4);
|
||||||
|
b.forceEnd();
|
||||||
|
const raw = compactResult(b.result());
|
||||||
|
assert.equal(validateClientResult(spec, raw, { gd }).ok, true);
|
||||||
|
for (const value of [-1, '4', null, {}, 1000]) {
|
||||||
|
const forged = structuredClone(b.result());
|
||||||
|
forged.perPlayer.p_0.layerGains.soloShip = value;
|
||||||
|
assert.equal(validateClientResult(spec, forged, { gd }).ok, false, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settlement retains hidden-layer gains from the server result', () => {
|
||||||
|
const h = makeMatch({ mode: 'solo', humans: 1, fake: true,
|
||||||
|
script: () => ({ layerGains: { p_0: { soloShip: 12, suntShip: 9, yanShip: 3 } } }) }).start();
|
||||||
|
try {
|
||||||
|
h.toPrep(1);
|
||||||
|
h.m.handle('p_0', { t: 'g.ready', ready: true });
|
||||||
|
h.toPrep(2);
|
||||||
|
assert.equal(h.ps('p_0').layers.soloShip, 12);
|
||||||
|
assert.equal(h.ps('p_0').layers.suntShip, 9);
|
||||||
|
assert.equal(h.ps('p_0').layers.yanShip, 3);
|
||||||
|
} finally { h.m.dispose(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden layers contribute to unite pair ordering while inactive layers do not', () => {
|
||||||
|
const player = (id, seat, layers) => ({ playerId: id, seat, deployCount: 1, board: new Map(),
|
||||||
|
layers: { soloShip: layers, suntShip: 999 }, bonds: {
|
||||||
|
soloShip: { active: true, layers }, suntShip: { active: false, layers: 999 },
|
||||||
|
} });
|
||||||
|
const a = player('p_0', 0, 5), b = player('p_1', 1, 8), m = { gd }, results = new Map();
|
||||||
|
assert.equal(helperStats(m, b, results).layers, 8);
|
||||||
|
assert.deepEqual(helperOrder(m, [a, b], results).map((p) => p.playerId), ['p_1', 'p_0']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Regression checks for 突袭 landing terrain, using the shared server/browser simulation.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { makeBattle, flatStage, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||||
|
import { inRange } from '../../server/sim/content/support/index.js';
|
||||||
|
|
||||||
|
const HOME = [12, 3];
|
||||||
|
const ENEMY = [10, 8];
|
||||||
|
const BONDS = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
|
||||||
|
|
||||||
|
function scenario(ready, noLanding = false) {
|
||||||
|
const stage = flatStage();
|
||||||
|
const put = (r, c, glyph) => { stage.rows[r] = stage.rows[r].slice(0, c) + glyph + stage.rows[r].slice(c + 1); };
|
||||||
|
// Every tile in the landing search area is high ground or water except the one dry shore tile.
|
||||||
|
for (let r = 9; r <= 12; r++) for (let c = 6; c <= 10; c++) put(r, c, 'h');
|
||||||
|
put(...ENEMY, 'd');
|
||||||
|
put(10, 9, 'r'); // A shooting platform: low ground in the legend, but unavailable to melee.
|
||||||
|
if (!noLanding) put(9, 8, 'r');
|
||||||
|
const h = makeBattle({
|
||||||
|
stage, bonds: BONDS,
|
||||||
|
defs: {
|
||||||
|
chess: { raider: chessRec({
|
||||||
|
id: 'raider', profession: 'WARRIOR', position: 'MELEE', bonds: ['raidShip'],
|
||||||
|
rangeGrid: [[0, 0], [0, 1], [1, 0], [0, -1], [-1, 0]],
|
||||||
|
skill: ready ? { spCost: 10, initSp: 10 } : null,
|
||||||
|
}) },
|
||||||
|
enemies: { enemy_wader: enemyRec({ key: 'enemy_wader', hp: 1e7, speed: 0 }) },
|
||||||
|
},
|
||||||
|
units: [{ chessId: 'raider', row: HOME[0], col: HOME[1] }],
|
||||||
|
enemies: [{ key: 'enemy_wader', pos: ENEMY }],
|
||||||
|
setup(b) { b.setObstacle(10, 9, true); },
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
assert.equal(h.unit('raider').alive, true);
|
||||||
|
assert.equal(h.b.grid.canStand(10, 7), false, 'high ground is not a melee landing tile');
|
||||||
|
assert.equal(h.b.grid.canStand(...ENEMY), false, 'water is not a landing tile');
|
||||||
|
assert.equal(h.b.grid.canStand(10, 9), false, 'shooting platform is not a melee landing tile');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ready of [false, true]) {
|
||||||
|
const trigger = ready ? 'skill ready' : 'idle for 10 seconds';
|
||||||
|
test(`突袭 (${trigger}): skips high ground, water and shooting platforms, landing on dry ground`, () => {
|
||||||
|
const h = scenario(ready);
|
||||||
|
const u = h.unit('raider');
|
||||||
|
h.run(ready ? 0.6 : 11);
|
||||||
|
assert.deepEqual([u.tileR, u.tileC], [9, 8], 'the only legal shore tile');
|
||||||
|
assert.ok(u.alive && u.deployed && u.ground);
|
||||||
|
assert.ok(inRange(u, h.enemy('enemy_wader')), 'the enemy is in range after the jump');
|
||||||
|
const landings = h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial);
|
||||||
|
assert.equal(landings.length, 1, 'one successful raid redeployment');
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`突袭 (${trigger}): stays deployed when only high ground, water and platforms surround the enemy`, () => {
|
||||||
|
const h = scenario(ready, true);
|
||||||
|
const u = h.unit('raider');
|
||||||
|
h.run(12);
|
||||||
|
assert.deepEqual([u.tileR, u.tileC], HOME);
|
||||||
|
assert.ok(u.alive && u.deployed, 'no withdrawal without a valid landing');
|
||||||
|
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 0);
|
||||||
|
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length, 0);
|
||||||
|
checkInvariants(h.b);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ globalThis.fetch = async (url) => {
|
|||||||
const { modeOffBonds } = await import('../../public/js/ui/gameLogic.js');
|
const { modeOffBonds } = await import('../../public/js/ui/gameLogic.js');
|
||||||
const { BondChips } = await import('../../public/js/ui/detailPanel.js');
|
const { BondChips } = await import('../../public/js/ui/detailPanel.js');
|
||||||
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
|
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
|
||||||
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
|
const { BondPopup, BondStrip } = await import('../../public/js/ui/bondStrip.js');
|
||||||
const { data, getMode } = await import('../../public/js/data.js');
|
const { data, getMode } = await import('../../public/js/data.js');
|
||||||
const { DATA, makeMatch, give, legalTileFor } = await import('../match/harness.js');
|
const { DATA, makeMatch, give, legalTileFor } = await import('../match/harness.js');
|
||||||
const { tileKey } = await import('../../server/match/board.js');
|
const { tileKey } = await import('../../server/match/board.js');
|
||||||
@@ -120,3 +120,21 @@ test('the game screen hands the mode-disabled set to the shop bar, the detail ca
|
|||||||
assert.match(src, /<\$\{DetailPanel\}[\s\S]*?offBonds=\$\{offBonds\}/);
|
assert.match(src, /<\$\{DetailPanel\}[\s\S]*?offBonds=\$\{offBonds\}/);
|
||||||
assert.match(src, /<\$\{BondPopup\}[\s\S]*?off=\$\{offBonds\.has\(bondPop\.bondId\)\}/);
|
assert.match(src, /<\$\{BondPopup\}[\s\S]*?off=\$\{offBonds\.has\(bondPop\.bondId\)\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hidden-layer bonds show activation and tiers without a stack badge or layer count', () => {
|
||||||
|
for (const bondId of ['maniShip', 'emptyShip', 'soloShip', 'suntShip']) {
|
||||||
|
const entry = { bondId, active: true, count: 2, tier: 1, layers: 99 };
|
||||||
|
const pop = BondPopup({ bondId, entry, priv: { board: [], hand: [] }, onClose() {} });
|
||||||
|
assert.match(textOf(pop), /层数不显示/);
|
||||||
|
assert.doesNotMatch(textOf(pop), /99|99 层/);
|
||||||
|
const strip = BondStrip({ bonds: [entry], onOpen() {} });
|
||||||
|
const disc = [...walk(strip)].find((v) => v.props?.name === DATA.bonds[bondId].name);
|
||||||
|
assert.ok(disc);
|
||||||
|
assert.equal(disc.props.layers, undefined);
|
||||||
|
assert.equal(disc.props.active, true);
|
||||||
|
assert.equal(disc.props.tier, 1);
|
||||||
|
}
|
||||||
|
const disc = [...walk(BondStrip({ bonds: [{ bondId: 'yanShip', active: true, layers: 12 }], onOpen() {} }))]
|
||||||
|
.find((v) => v.props?.name === DATA.bonds.yanShip.name);
|
||||||
|
assert.equal(disc.props.layers, 12, 'stacking bonds keep their badge');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user