- #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
194 lines
12 KiB
JavaScript
194 lines
12 KiB
JavaScript
// 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();
|
||
});
|