Files
ark-stronghold-protocol/test/match/followups.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

194 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Match follow-ups: multi-round bounties in the Final Assault / Hidden Core, 驰援 cards of fully banned bonds,
// 坚若磐石 = least LP lost, combat time limits in real seconds (× the forced 2× speed), boss-round layout model.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { GameData, COMBAT_TIME_SCALE } from '../../server/match/gamedata.js';
import { generateDraft, reinforcementBond, cardTargetBonds, bountyBattles } from '../../server/match/choices.js';
import { assignTitles } from '../../server/match/results.js';
import { routeByMotion } from '../../server/match/waves.js';
import { fieldModel } from '../../server/match/bot.js';
import { FIELD, parseKey } from '../../server/match/board.js';
import { createRng } from '../../server/sim/rng.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
/** A multi-round ("之后的每场作战") kill bounty whose enemy is active in the mode. */
function everyBattleBounty(gd) {
const c = DATA.choices.cards.bounty.find((x) => x.rounds >= 90 && x.payout === 'kill' && DATA.enemies[x.enemyKey] && !gd.inactiveEnemies.has(x.enemyKey));
assert.ok(c, 'data has a multi-round kill bounty');
return c;
}
test('multi-round bounties spawn in the Final Assault on the owner\'s half, show in the boss preview, then use up a battle', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
const card = everyBattleBounty(m.gd);
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
m.addBounty(p0, card);
m.addBounty(p1, card);
assert.ok(p0.privateView().nextEnemies.some((e) => e.enemyKey === card.enemyKey && e.tag === 'bounty'), 'boss-round preview lists the bounty');
assert.ok(p0.privateView().nextEnemies.some((e) => e.tag === 'boss'));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const f = bossFields()[0];
const bs = f.opts.spawns.filter((s) => s.tag === 'bounty');
assert.equal(bs.length, 2, 'one bounty spawn per player of the pair');
const fly = DATA.enemies[card.enemyKey].stats.motion === 'FLY';
for (const s of bs) {
const rt = f.opts.routes[s.routeIndex];
assert.ok(rt, 'a valid route');
assert.equal(rt.motion === 'FLY', fly, 'route motion matches the enemy');
assert.equal(s.count, card.count);
assert.ok(s.mods.hpMul > 0, 'bounty enemies take the round\'s multipliers');
if (s.ownerPlayerId === 'p_0') assert.ok(rt.end[1] < 10, `left player's bounty goes to the left goal (${rt.end})`);
else { assert.equal(s.ownerPlayerId, 'p_1'); assert.ok(rt.end[1] > 10, `right player's bounty goes to the right goal (${rt.end})`); }
}
const pending = p0.pendingFunds;
const end = h.runToEnd();
assert.equal(end.victory, true);
// (a multi-round card lasts two battles since the user's playtest #6 answer — choices.js MULTI_ROUND_BOUNTY_BATTLES)
assert.equal(p0.bounties[0].roundsLeft, bountyBattles(card) - 1, 'the Final Assault used one of the bounty\'s battles');
assert.equal(p0.pendingFunds, pending + 3, 'kill-bounty coins of the boss field are credited (spent in the Hidden Core prep)');
checkInvariants(m);
m.dispose();
});
test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
m.addBounty(h.ps('p_0'), everyBattleBounty(m.gd));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const f = bossFields()[0];
assert.ok(/_s$/.test(f.opts.waveId));
assert.equal(f.opts.spawns.filter((s) => s.tag === 'bounty' && s.ownerPlayerId === 'p_0').length, 1);
m.dispose();
const routes = [{ motion: 'WALK', end: [1, 17] }, { motion: 'WALK', end: [1, 3] }, { motion: 'FLY', end: [1, 17] }, { motion: 'FLY', end: [1, 3] }];
assert.equal(routeByMotion(routes, false), 0);
assert.equal(routeByMotion(routes, false, 'L'), 1);
assert.equal(routeByMotion(routes, false, 'R'), 0);
assert.equal(routeByMotion(routes, true, 'L'), 3);
assert.equal(routeByMotion(routes, true, 'R'), 2);
assert.equal(routeByMotion([{ motion: 'WALK', end: [9, 2] }], false, 'R'), 0, 'falls back to any route of the motion');
});
test('驰援 cards: a bond without chess in this match\'s pool is never offered', () => {
const gd = new GameData(DATA, 'mode_multi_funny');
assert.equal(reinforcementBond(gd, 'allybuff_select_7_1'), 'yanShip');
assert.equal(reinforcementBond(gd, 'allybuff_select_11'), null);
const offered = (bondAvailable) => {
const seen = new Set();
for (let s = 1; s <= 300; s++) {
const d = generateDraft(gd, createRng(s), 9, { bondAvailable });
if (d && d.family === 'tactic') for (const c of d.cards) seen.add(c.id);
}
return seen;
};
const all = offered(null);
assert.ok(all.has('allybuff_select_7_1'), 'sanity: 炎盟约驰援 is offered normally');
const filtered = offered((b) => b !== 'yanShip');
assert.ok(!filtered.has('allybuff_select_7_1'), '炎盟约驰援 filtered');
assert.ok(filtered.has('allybuff_select_7_3') || filtered.has('allybuff_select_7_2'), 'other 驰援 cards stay');
// Match wiring: bondInPool follows the pool (banned chess are never in it)
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, seed: 63, fake: true });
const m = h.m;
for (const id of m.gd.bondIds) {
const want = [...m.pool.entries.keys()].some((c) => (m.gd.chess(c).bonds || []).includes(id));
assert.equal(m.bondInPool(id), want, id);
}
for (const id of [...m.pool.entries.keys()]) if ((m.gd.chess(id).bonds || []).includes('yanShip')) m.pool.entries.delete(id);
assert.equal(m.bondInPool('yanShip'), false);
m.dispose();
});
test('标准 (FUNNY) 机变: tactic cards acting only on mode-inactive bonds (玛恩纳 / 莫斯提马的盟誓, 卡西米尔 / 拉特兰 / 阿戈尔驰援) are never offered', () => {
// the draft the finding saw at R3 of seed 18: 玛恩纳的盟誓 (卡西米尔 +12) and 卡西米尔驰援 in a mode without 卡西米尔
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed: 18, fake: true }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.SP_DRAFT);
const inactive = m.gd.modeInactiveBonds;
assert.ok(['kazimierzShip', 'lateranoShip', 'arcaneShip', 'egirShip'].every((b) => inactive.has(b)));
const dead = (c) => { const bonds = cardTargetBonds(m.gd, c.id); return !!bonds && bonds.every((b) => inactive.has(b) || !m.bondInPool(b)); };
assert.deepEqual(m.sp.cards.filter((c) => c.kind === 'tactic' && dead(c)).map((c) => c.name), []);
// every FUNNY tactic draft of the match's wiring (Match.bondLive)
const seen = new Set();
for (let s = 1; s <= 400; s++) {
const d = generateDraft(m.gd, createRng(s), 3, { stageId: m.stageId, bondAvailable: (b) => m.bondLive(b) });
if (d && d.family === 'tactic') for (const c of d.cards) seen.add(c.id);
}
for (const id of ['allybuff_select_2_7', 'allybuff_select_2_5', 'allybuff_select_7_5', 'allybuff_select_7_6', 'allybuff_select_7_8']) assert.ok(!seen.has(id), `${id} never offered in 标准`);
assert.ok(seen.has('allybuff_select_2_1'), '斯卡蒂的盟誓 (阿戈尔 off, but 深海猎人 / 坚守 on) stays');
assert.ok(seen.has('allybuff_select_7_1'), '炎盟约驰援 stays');
assert.deepEqual(cardTargetBonds(m.gd, 'allybuff_select_2_5'), ['lateranoShip', 'arcaneShip']);
assert.equal(cardTargetBonds(m.gd, 'allybuff_select_11'), null, 'cards that do anything else are always useful');
m.dispose();
// other modes keep them
const h2 = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 18, fake: true });
if (h2.m.bondInPool('kazimierzShip')) assert.equal(h2.m.bondLive('kazimierzShip'), true);
h2.m.dispose();
});
test('坚若磐石 (目标生命值损失最少): least LP lost among the players still alive (tuning override of the title rule)', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const t3 = gd.titles.find((t) => t.id === 'comment_3');
assert.deepEqual([t3.stat, t3.rule, t3.name], ['lpLost', 'min', '坚若磐石']);
const fake = (seat, lpLost, alive = true, s = {}) => ({ playerId: `p${seat}`, seat, alive, lp: 10, lpAtFinal: 10, stats: { bossDamage: 0, merges: 0, itemsEquipped: 0, gold: 0, lpLost, ...s }, activatedLayers: () => 0 });
const players = [fake(0, 12, true, { gold: 30 }), fake(1, 3, true, { gold: 10 }), fake(2, 0, false, { gold: 5 }), fake(3, 7, true, { merges: 2 })];
const won = assignTitles(gd, players, true);
assert.equal(won.get('p1').id, 'comment_3', 'the least LP lost among the alive');
assert.notEqual(won.get('p2')?.id, 'comment_3', 'an eliminated player never is 坚若磐石');
// a perfect player (0 LP lost) still qualifies
const perfect = assignTitles(gd, [fake(0, 0), fake(1, 5, true, { gold: 9 })], false);
assert.equal(perfect.get('p0').id, 'comment_3');
// without the tuning override the config rule applies
const { tuning, ...raw } = DATA; // eslint-disable-line no-unused-vars
const plain = new GameData(raw, 'mode_multi_hard');
assert.equal(plain.titles.find((t) => t.id === 'comment_3').stat, 'lpRemaining');
});
test('combat time limits: data maxPlayTime is real seconds, the sim gets game seconds (× the forced 2× speed)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.equal(COMBAT_TIME_SCALE, 2);
for (let r = 1; r <= 13; r++) {
assert.equal(gd.combatTimeLimitReal(r), DATA.config.modes.mode_multi_normal.rounds[r].combatTimeLimit);
assert.equal(gd.combatTimeLimit(r), gd.combatTimeLimitReal(r) * 2);
// every enemy of the round's template spawns inside the limit (the game-second reading fails R2 / R3)
const tpl = DATA.waves[gd.roundCfg(r).template];
const last = Math.max(...tpl.spawns.map((s) => (s.time || 0) + ((s.count || 1) - 1) * (s.interval || 0)));
assert.ok(last < gd.combatTimeLimit(r), `R${r}: last spawn ${last} s < limit ${gd.combatTimeLimit(r)} s`);
}
const custom = new GameData({ ...DATA, config: { ...DATA.config, combatTimeScale: 1 } }, 'mode_multi_normal');
assert.equal(custom.combatTimeLimit(3), 55);
// the combat deadline shown to players is maxPlayTime real seconds
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, seed: 64, fake: true, instant: false, script: () => ({ duration: 1e9 }) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.COMBAT);
assert.equal(m.wave.timeLimit, 90);
assert.ok(Math.abs(m.deadline - m.sched.now() - 45000) < 50, 'deadline = 45 real seconds at R1');
m.dispose();
});
test('boss-round layout model: the player\'s boss field on the own board, the leader weighted with a dwell, sides mirrored', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 65, fake: true }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
assert.equal(m.wave, null);
const left = fieldModel(m, h.ps('p_0'));
const right = fieldModel(m, h.ps('p_1'));
for (const model of [left, right]) {
assert.ok(model.routes.length > 0);
assert.ok(model.routes.some((r) => r.dwell > 0), 'the leader\'s route carries a dwell');
for (const rt of model.routes) for (const k of rt.tiles) {
const [r, c] = parseKey(k);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1, `tile ${k} on the own board`);
}
}
assert.notEqual(left.key, right.key, 'cached per side');
assert.equal(fieldModel(m, h.ps('p_1')), right);
m.dispose();
});