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
This commit is contained in:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
@@ -4,7 +4,7 @@ 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 } from '../../server/match/choices.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';
|
||||
@@ -49,7 +49,8 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
|
||||
const pending = p0.pendingFunds;
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, true);
|
||||
assert.equal(p0.bounties[0].roundsLeft, card.rounds - 1, 'the Final Assault used one of the bounty\'s battles');
|
||||
// (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();
|
||||
|
||||
@@ -269,15 +269,17 @@ test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines'
|
||||
|
||||
// ---- review fixes -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('a skill summon is never a hand card (user playtest #4): 赫默 on any skill gets no 医疗无人机 token in the prep phase', async () => {
|
||||
test('a skill summon is a hand card only with that skill (user playtest #6): 赫默 on S2 gets her 医疗探机, on S1 none', async () => {
|
||||
const { give } = await import('./harness.js');
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const DRONE = 'token_10000_silent_healrb';
|
||||
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
|
||||
// "获得一个医疗无人机": the drone appears in battle when S2 fires (test/content/tokens_devices.test.js), never before
|
||||
// the drone is placed by hand (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"); in battle it takes its tile when S2
|
||||
// fires (test/content/playtest6_summons.test.js) — only S2 (index 1, the default) makes it
|
||||
const tokensIn = (ps, id = DRONE) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
|
||||
assert.equal(DATA.tokens[DRONE].placeable, false, 'tokens.json: a skill summon is not placeable');
|
||||
assert.equal(DATA.tokens[DRONE].placeable, true, 'tokens.json: a manually deployable summon');
|
||||
for (const skill of [null, 0, 1]) {
|
||||
const want = skill === 0 ? 0 : 1;
|
||||
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
|
||||
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
|
||||
h.toPrep(1);
|
||||
@@ -286,11 +288,11 @@ test('a skill summon is never a hand card (user playtest #4): 赫默 on any skil
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, SILENCE, 'board', [9, 3]);
|
||||
assert.equal(tokensIn(ps).length, 0, `normal 赫默, skill ${skill ?? 'default'}`);
|
||||
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
|
||||
assert.equal(tokensIn(ps).length, 0, `elite 赫默, skill ${skill ?? 'default'}`);
|
||||
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`);
|
||||
// a talent summon still is a hand card: 伺夜 → 狼群
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// User playtest #6 item 5 — "图中的boss没有血量了但是不死,还在战斗,直到超时我们的生命被扣完才结束,但是最后的判定仍是胜利".
|
||||
// The user's match: 2 humans (the AI teammates eliminated), 绝境, act2autochess_m01, client-side combat, one pair
|
||||
// field; the HUD read 0.0 %, the leader kept fighting, the overtime drain took the team LP and the result said 胜利.
|
||||
//
|
||||
// Reproduced with real matches (real sim, SimClients as the browsers): on v2.4.1 about one boss fight in three stalled.
|
||||
// The server pool kept float dust from crediting a report per player (40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12);
|
||||
// the authority's LocalBossPool showed exactly that and every hit added 3.6e-12 to a cumulative counter of 40000 —
|
||||
// absorbed, so its pool never reached 0 and it never reported anything new. The fight ran on until the team LP was 0;
|
||||
// the verdict then came from the pool after the final b.result, whose per-player damage is rounded: rounding up
|
||||
// emptied the dust → 胜利 (what the user saw), rounding down → 失败.
|
||||
//
|
||||
// Now (sim/constants.js BOSS_POOL_MIN_HP, finalAssault.js SharedBossPool, sim/spec.js LocalBossPool, Match.js): a pool
|
||||
// never holds less than 1 HP (the hit that would leave less takes the rest; a browser pool reading below 1 is 0), so
|
||||
// the leader dies on every field as soon as the pool shows 0 and the fight ends; the first end condition the server
|
||||
// registers decides the verdict (pool 0 → victory, team LP 0 → defeat: PRTS 卫戍协议:盟约 下半 "…使目标生命值扣除至0,
|
||||
// 则无视倒计时直接失败"), and reports arriving after the team LP ran out credit nothing.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
/** A real client-combat match (real sim) of 2 humans to the Final Assault on the user's stage and difficulty. */
|
||||
function realFinalAssault({ bossId, seed, clientCombat = true }) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 0, seed, captureFrames: false, clientCombat, pace: 'paced' });
|
||||
h.setStage('act2autochess_m01');
|
||||
const m = h.m;
|
||||
m.bossId = bossId;
|
||||
// the autoplay lineups cannot clear a full 绝境 pool: 5 % of it (the tuning knob GameData.bossHpMul) — the pool's
|
||||
// arithmetic, not its size, stalled the fight
|
||||
m.gd.bossHpMul = () => 0.05;
|
||||
h.autoHumans();
|
||||
m.start();
|
||||
let last = '';
|
||||
h.run(() => {
|
||||
const k = `${m.phase}:${m.round}`;
|
||||
if (k !== last) { last = k; if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 300; }
|
||||
return h.ended != null || m.phase === PHASE.FINAL_ASSAULT;
|
||||
}, { maxSteps: 8e6 });
|
||||
assert.equal(m.phase, PHASE.FINAL_ASSAULT, `${bossId} seed ${seed}: reached the Final Assault`);
|
||||
return h;
|
||||
}
|
||||
|
||||
// (boss, seed) pairs whose fight stalled on v2.4.1: pool < 1 HP within 30 s, then ~11 minutes of overtime drain;
|
||||
// boss_3 / 11 and boss_10 / 12 ended as a 胜利 after the team LP ran out (the user's report), the others as a 失败
|
||||
const STALLED = [['boss_1', 12], ['boss_2', 12], ['boss_3', 11], ['boss_10', 12]];
|
||||
|
||||
for (const [bossId, seed] of STALLED) {
|
||||
test(`#5 real match (client-side combat): ${bossId} seed ${seed} — the leader dies as soon as the shared pool is empty, victory, no overtime`, () => {
|
||||
const h = realFinalAssault({ bossId, seed });
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
assert.equal(m.fields.length, 1, 'one pair field');
|
||||
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
|
||||
const t0 = h.sched.now();
|
||||
const lp0 = m.teamLp;
|
||||
const pools = [];
|
||||
let emptyAt = null, endAt = null;
|
||||
h.onBroadcast.push((msg) => {
|
||||
if (msg.t !== 'b.pool') return;
|
||||
pools.push(msg.hp);
|
||||
if (msg.hp < 1 && emptyAt == null) emptyAt = h.sched.now() - t0;
|
||||
});
|
||||
h.onSend.push((pid, msg) => { if (msg.t === 'b.end' && endAt == null) endAt = h.sched.now() - t0; });
|
||||
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 8e6 });
|
||||
assert.ok(emptyAt != null, 'the team emptied the pool');
|
||||
assert.ok(pools.every((hp) => hp === 0 || hp >= 1), `the pool never holds float dust (${pools.filter((hp) => hp > 0 && hp < 1)})`);
|
||||
assert.ok(endAt != null && endAt <= emptyAt, `b.end went out when the pool emptied (${endAt} ms vs ${emptyAt} ms)`);
|
||||
assert.equal(m._finalEnding, 'cleared');
|
||||
assert.equal(m.overtimeApplied, 0, 'no overtime drain');
|
||||
assert.ok(m.teamLp > 0 && m.teamLp >= lp0 - 30, `team LP kept (${lp0} → ${m.teamLp})`);
|
||||
const c = h.clients.get('p_0');
|
||||
const local = c.battles.get(f.battleId).battle;
|
||||
assert.equal(local.reason, 'cleared', 'the authority\'s own battle saw the pool reach 0');
|
||||
assert.deepEqual(local.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), [], 'no leader left standing');
|
||||
assert.equal(local.sharedBoss.hp, 0);
|
||||
m.flush(true);
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.deepEqual(pub.bossHp, { hp: 0, max: m.bossPool.maxHp });
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, true);
|
||||
assert.equal(m.errorCount, 0);
|
||||
assert.equal(m.verifyStats.rejected, 0);
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test('#5 real match (server-run combat, SP_COMBAT=server): the Final Assault ends when the pool empties, never on float dust', () => {
|
||||
const h = realFinalAssault({ bossId: 'boss_3', seed: 11, clientCombat: false });
|
||||
const m = h.m;
|
||||
const pool = m.bossPool;
|
||||
const seen = [];
|
||||
const orig = pool.damage.bind(pool);
|
||||
pool.damage = (pid, amount) => { const d = orig(pid, amount); seen.push(pool.hp); return d; };
|
||||
const end = h.runToEnd();
|
||||
assert.ok(seen.length > 0);
|
||||
assert.ok(seen.every((hp) => hp === 0 || hp >= 1), 'the pool is 0 or at least 1 HP after every hit');
|
||||
assert.equal(pool.hp, 0);
|
||||
assert.equal(end.victory, true);
|
||||
assert.equal(m.overtimeApplied, 0);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// ---- the shared pool through the real crediting path (FakeBattle hits a real LocalBossPool; deterministic) ---------
|
||||
|
||||
test('#5 client-side combat: a boss dps whose per-player credits used to leave 1.5e-11 in the pool ends the fight when the pool empties', () => {
|
||||
for (const dps of [3456.789, 24680.13]) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: dps } : { duration: 2 }) }).start();
|
||||
const m = h.m;
|
||||
h.autoHumans();
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
const t0 = h.sched.now();
|
||||
const lp0 = m.teamLp;
|
||||
let emptyAt = null, endAt = null;
|
||||
const pools = [];
|
||||
h.onBroadcast.push((msg) => { if (msg.t === 'b.pool') { pools.push(msg.hp); if (msg.hp < 1 && emptyAt == null) emptyAt = h.sched.now() - t0; } });
|
||||
h.onSend.push((pid, msg) => { if (msg.t === 'b.end' && endAt == null) endAt = h.sched.now() - t0; });
|
||||
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
|
||||
assert.ok(emptyAt != null, `dps ${dps}: the pool emptied`);
|
||||
assert.ok(pools.every((hp) => hp === 0 || hp >= 1), `dps ${dps}: never float dust (${pools.filter((hp) => hp > 0 && hp < 1)})`);
|
||||
assert.ok(endAt != null && endAt <= emptyAt, `dps ${dps}: b.end cleared went out with the empty pool (${emptyAt} / ${endAt} ms; v2.4.1 stalled until the drain at ~205 s)`);
|
||||
assert.equal(m._finalEnding, 'cleared');
|
||||
assert.equal(m.teamLp, lp0, 'no overtime drain');
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, true);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('#5 verdict: once the team LP runs out the run is lost — a boss field\'s final result can never credit damage or turn it into a victory', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 4, lpr: 1 }] } : { duration: 2 }),
|
||||
// the authority's b.result after the forced end reports more boss damage than was ever credited (in flight / rounded)
|
||||
perPlayer: { p_0: { tamper: (result, spec) => {
|
||||
if (spec.kind !== 'boss') return result;
|
||||
for (const pp of Object.values(result.perPlayer)) pp.bossDamage = 1e6;
|
||||
return result;
|
||||
} } } }).start();
|
||||
const m = h.m;
|
||||
h.autoHumans();
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
h.sched.advance(1000);
|
||||
m.teamLp = 1; // the last target LP point: the leak at 4 game s takes it
|
||||
m.bossPool.hp = 50; // a leader 50 HP from death
|
||||
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
|
||||
assert.equal(m._finalEnding, 'forced', 'the team LP ran out first');
|
||||
assert.ok(h.clients.get('p_0').log.some((x) => x.t === 'b.result'), 'the authority reported after b.end forced');
|
||||
assert.equal(m.bossPool.hp, 50, 'nothing reported after the forced end is credited');
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, false, 'defeat: the team LP reached 0 before the pool');
|
||||
assert.equal(end.reason, 'defeat');
|
||||
assert.equal(m.teamLp, 0);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
// User playtest #6, match flow (workstream WC): #4 悬赏决策 bounty enemies keep coming back, #19 the merge reward offers
|
||||
// the same operator twice, #7 the 联防 leak counter (server side; the HUD helpers are in test/ui/playtest6-unite.test.js).
|
||||
// Real match paths (the match harness in virtual time), real data.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { generateDraft, cardView, MULTI_ROUND_BOUNTY_BATTLES } from '../../server/match/choices.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { uniteLeft, battleProgress } from '../../server/sim/spec.js';
|
||||
import { uniteSurvivors } from '../../server/match/unite.js';
|
||||
import { attachSimClients } from './simClient.js';
|
||||
|
||||
// =====================================================================================================================
|
||||
// #19 — "拿到三个相同干员合成高级干员时赠送的3选1干员经常出现两个一样的,没记错的话官方原版不会"
|
||||
|
||||
/** A solo prep at shop level `level` with an empty board and hand (the pool untouched). */
|
||||
function soloPrep(seed, level = 1) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.shop.level = level;
|
||||
ps.recompute();
|
||||
ps.funds = 100;
|
||||
return { h, m: h.m, ps };
|
||||
}
|
||||
|
||||
test('#19 the promotion reward (3 copies → 精锐) offers three DIFFERENT operators of tier level + 1 — the real merge path', () => {
|
||||
let offers = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { m, ps } = soloPrep(600 + seed, 1 + (seed % 5));
|
||||
// merge three copies of one tier-I operator (two in the hand, the third bought from the shop)
|
||||
const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) >= 3);
|
||||
give(m, ps, id);
|
||||
give(m, ps, id);
|
||||
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
||||
assert.equal(ps.stats.merges, 1, 'merged');
|
||||
const offer = ps.privateView().shop.rewardOffer;
|
||||
assert.ok(offer, 'a reward offer');
|
||||
const tier = Math.min(ps.shop.level + 1, 6);
|
||||
assert.equal(offer.tier, tier);
|
||||
const ids = offer.slots.map((s) => s.id);
|
||||
assert.equal(ids.length, 3);
|
||||
assert.equal(new Set(ids).size, 3, `seed ${seed}: three different operators (${ids.join(', ')})`);
|
||||
for (const x of ids) assert.equal(DATA.chess[x].tier, tier, `${x} is tier ${tier}`);
|
||||
offers++;
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
}
|
||||
assert.equal(offers, 12);
|
||||
});
|
||||
|
||||
test('#19 many reward offers at every shop level: never the same operator twice; offers reserve no copies', () => {
|
||||
const { m, ps } = soloPrep(640);
|
||||
let dupBefore = 0;
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
ps.shop.level = level;
|
||||
for (let k = 0; k < 60; k++) {
|
||||
const before = m.pool.snapshot();
|
||||
const offer = ps.pushRewardOffer('merge');
|
||||
const ids = offer.slots.map((s) => s.id);
|
||||
if (new Set(ids).size !== ids.length) dupBefore++;
|
||||
assert.equal(ids.length, 3);
|
||||
for (const x of ids) assert.equal(DATA.chess[x].tier, Math.min(level + 1, 6));
|
||||
assert.deepEqual(m.pool.snapshot(), before, 'an offer takes no copies (only the pick does)');
|
||||
ps.offers.length = 0;
|
||||
}
|
||||
}
|
||||
assert.equal(dupBefore, 0, `${dupBefore} of 360 offers showed an operator twice`);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#19 a tier short of different operators: the offer tops up from the tier below, still three different ones; the pick keeps the pool exact', () => {
|
||||
const { m, ps } = soloPrep(650, 3); // offers of tier IV
|
||||
const t4 = chessOfTier(4).filter((x) => m.pool.has(x));
|
||||
const keep = t4.slice(0, 2);
|
||||
const drained = new Map();
|
||||
for (const x of t4) if (!keep.includes(x)) drained.set(x, m.pool.take(x, 100));
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const offer = ps.pushRewardOffer('merge');
|
||||
const ids = offer.slots.map((s) => s.id);
|
||||
assert.equal(new Set(ids).size, 3, ids.join(', '));
|
||||
assert.deepEqual(ids.filter((x) => DATA.chess[x].tier === 4).sort(), keep.slice().sort(), 'both tier-IV operators left are offered');
|
||||
assert.equal(ids.filter((x) => DATA.chess[x].tier === 3).length, 1, 'the third comes from tier III');
|
||||
if (k < 19) ps.offers.length = 0;
|
||||
}
|
||||
// pick the tier-III one: one copy leaves the pool, into the hand
|
||||
const offer = ps.offers[0];
|
||||
const idx = offer.slots.findIndex((s) => DATA.chess[s.id].tier === 3);
|
||||
const id = offer.slots[idx].id;
|
||||
const left = m.pool.left(id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx }), { ok: true });
|
||||
assert.equal(m.pool.left(id), left - 1);
|
||||
assert.ok(ps.hand.some((p) => p && p.id === id));
|
||||
for (const [x, n] of drained) m.pool.give(x, n);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// #4 — "官方版悬赏决策的敌人我没记错的话正常只会在选择之后出现两回合,现在是一直出"
|
||||
// The draft pool follows the PRTS 敌人轮选 table (卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段), which lists the multi-round
|
||||
// "之后 / 后续的每场作战" cards too: v2.4.1 also drafted 战术特训 (only 法术教鞭 makes those) and the hidden 鸭爵 set, put
|
||||
// one or more multi-round cards in about half of the co-op R3 drafts and showed every card as plain text, so a "每场"
|
||||
// card looked like a "下场" one. Now: no 战术特训 / 鸭爵 set (战术特训 is the 教鞭 Art's, test/content/items.test.js), the
|
||||
// multi-round cards stay in as PRTS lists them, and each card names its battles in the official colours (blue "下场作战" /
|
||||
// "两场作战", red "每场"); a 1–2-battle bounty stops after its battles.
|
||||
|
||||
const BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss'];
|
||||
/** 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏: commented out of the PRTS 敌人轮选 table */
|
||||
const HIDDEN = new Set(['enemyeffect_5', 'enemyeffect_6', 'enemyeffect_7', 'enemyeffect_8']);
|
||||
/** The official rich text of a card's battles (activity_table effectDesc): 1 / 2 battles in blue, every battle in red. */
|
||||
const DURATION_RE = { 1: /<@ba\.vup>下场(作战|战斗)<\/>/, 2: /<@ba\.vup>两场作战<\/>/, 99: /<@ba\.vdown>每场<\/>/ };
|
||||
/** The official multi-round cards ("之后 / 后续的每场作战"): two battles since the user's playtest #6 answer. */
|
||||
const MULTI = new Set(DATA.choices.cards.bounty.filter((c) => c.multiRound).map((c) => c.effectId));
|
||||
|
||||
test('#4 the 悬赏决策 draft: PRTS 敌人轮选 cards only (no 战术特训, no 鸭爵 set), each naming its battles in the official colours', () => {
|
||||
let bountyDrafts = 0;
|
||||
let cards = 0;
|
||||
let withMulti = 0;
|
||||
for (const modeId of BOUNTY_MODES) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
const sch = DATA.choices.schedule[modeId];
|
||||
for (const r of sch.spRounds) {
|
||||
for (let seed = 1; seed <= 25; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 131 + r), r, { stageId: 'act2autochess_m01' });
|
||||
if (!d || d.family !== 'bounty') continue;
|
||||
bountyDrafts++;
|
||||
let multi = 0;
|
||||
assert.equal(new Set(d.cards.map((c) => c.id)).size, d.cards.length, 'distinct cards');
|
||||
for (const c of d.cards) {
|
||||
cards++;
|
||||
assert.equal(c.payout, 'kill', `${modeId} R${r}: ${c.id} ${c.name} — 战术特训 comes from 法术教鞭 only`);
|
||||
assert.ok(!HIDDEN.has(c.id), `${c.id} ${c.name}: hidden from the PRTS table`);
|
||||
assert.ok([1, 2].includes(c.rounds), `${c.id}: ${c.rounds} battles`);
|
||||
if (MULTI.has(c.id)) {
|
||||
multi++;
|
||||
assert.equal(c.rounds, 2, `${c.id} ${c.name}: a multi-round card lasts two battles (the user's call)`);
|
||||
assert.ok(!/每场/.test(c.descRaw || ''), `${c.id}: no 每场 left in the card text`);
|
||||
}
|
||||
assert.match(c.descRaw || '', DURATION_RE[c.rounds], `${c.id} ${c.name}: the card text shows its battles (${c.descRaw})`);
|
||||
assert.equal(cardView(c).descRaw, c.descRaw, 'the public card carries the rich text (ui/choiceOverlay.js renders it first)');
|
||||
}
|
||||
if (multi) withMulti++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(bountyDrafts >= 100 && cards >= 400, `${bountyDrafts} bounty drafts, ${cards} cards`);
|
||||
assert.ok(withMulti > 0, 'the multi-round cards are still offered (PRTS lists them), as two-battle cards');
|
||||
});
|
||||
|
||||
test('#4 data: cards.bounty marks the draft pool after the PRTS table — 战术特训 (教鞭) and the 鸭爵 set are out, the multi-round cards and 源石虫·特训 in', () => {
|
||||
const list = DATA.choices.cards.bounty;
|
||||
const reasons = {};
|
||||
for (const c of list) {
|
||||
const expect = c.payout === 'kill' && !HIDDEN.has(c.effectId);
|
||||
assert.equal(c.draft, expect, `${c.effectId} ${c.name}: draft ${c.draft}`);
|
||||
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
|
||||
else assert.equal(c.draftExcluded, null);
|
||||
}
|
||||
assert.deepEqual(reasons, { perfect: 20, hidden: 4 });
|
||||
assert.equal(list.filter((c) => c.draft).length, 105);
|
||||
const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill');
|
||||
assert.equal(multi.length, 7, '多轮悬赏 · 假想敌 ×6 + 山海众头目·多轮悬赏');
|
||||
assert.ok(multi.every((c) => c.draft), 'PRTS lists them in 敌人轮选');
|
||||
assert.equal(list.find((c) => c.effectId === 'enemyeffect_5_1').draft, true, '源石虫·特训 is in the table too');
|
||||
});
|
||||
|
||||
/** Drive a co-op 绝境 match to its R3 bounty draft; the human takes the first free card `want(card, free)` accepts. */
|
||||
function pickAtR3(seed, want) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed, fake: true }).start();
|
||||
const m = h.m;
|
||||
const ok = h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true });
|
||||
assert.ok(ok && m.sp.family === 'bounty', `seed ${seed}: the R3 bounty draft`);
|
||||
let picked = null;
|
||||
for (let guard = 0; guard < 200 && m.phase === PHASE.SP_DRAFT; guard++) {
|
||||
if (m.spTurn() === 'p_0') {
|
||||
const free = m.sp.cards.filter((c) => m.sp.taken[c.idx] == null);
|
||||
picked = free.find((c) => want(c, free)) || null;
|
||||
if (!picked) break;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: picked.idx }), { ok: true });
|
||||
} else h.sched.runNext();
|
||||
}
|
||||
return { h, m, ps: h.ps('p_0'), picked };
|
||||
}
|
||||
|
||||
/** Rounds (≤ R8) whose normal battle of p_0 spawned bounty `bountyId`. */
|
||||
function bountyRounds(bountyId) {
|
||||
const out = [];
|
||||
for (const f of FakeBattle.instances) {
|
||||
if (f.kind !== 'normal' || !f.players.includes('p_0')) continue;
|
||||
if (f.spawns.some((s) => s.tag === 'bounty' && s.mods && s.mods.bountyId === bountyId)) out.push(f.round);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('#4 E2E (co-op 绝境, the real R3 draft): a 1–2-battle bounty\'s enemies come for its battles and never again', () => {
|
||||
const seen = [];
|
||||
for (let seed = 1; seed <= 6; seed++) {
|
||||
// the human takes the longest short bounty on offer (a "接下来两场作战" one when there is one)
|
||||
const { h, m, ps, picked } = pickAtR3(660 + seed, (c, free) => c.rounds <= 2 && c.rounds === Math.max(...free.filter((x) => x.rounds <= 2).map((x) => x.rounds)));
|
||||
assert.ok(picked, 'picked a card');
|
||||
const b = ps.bounties.find((x) => x.card.effectId === picked.id);
|
||||
assert.ok(b, 'the picker holds the bounty');
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 9, { ready: true });
|
||||
const expect = picked.rounds === 1 ? [3] : [3, 4];
|
||||
const got = bountyRounds(b.id);
|
||||
assert.deepEqual(got, expect, `seed ${seed}: ${picked.id} ${picked.name} (${picked.rounds} battles) spawned in R${got.join(', R')}`);
|
||||
assert.equal(ps.bounties.length, 0, 'no bounty left after its battles');
|
||||
seen.push(picked.rounds);
|
||||
m.dispose();
|
||||
}
|
||||
assert.ok(seen.includes(2), 'a two-battle card was picked at least once');
|
||||
});
|
||||
|
||||
test('#4 E2E: a multi-round card lasts two battles like the "两场作战" cards — blue text on the card and in the effects column (the user\'s call)', () => {
|
||||
// "我不记得有过多轮悬赏" (user, after playtest #6): choices.js MULTI_ROUND_BOUNTY_BATTLES = 2; null restores 每场
|
||||
assert.equal(MULTI_ROUND_BOUNTY_BATTLES, 2);
|
||||
let done = false;
|
||||
for (let seed = 1; seed <= 40 && !done; seed++) {
|
||||
const { h, m, ps, picked } = pickAtR3(700 + seed, (c) => MULTI.has(c.id));
|
||||
if (!picked) { m.dispose(); continue; }
|
||||
done = true;
|
||||
assert.equal(picked.rounds, 2);
|
||||
assert.match(picked.descRaw, /接下来<@ba\.vup>两场作战<\/>/, 'the draft card says 两场作战 in blue');
|
||||
assert.ok(!/每场/.test(picked.descRaw), 'no red 每场');
|
||||
const b = ps.bounties.find((x) => x.card.effectId === picked.id);
|
||||
assert.equal(b.roundsLeft, 2);
|
||||
const e = ps.privateView().effects.find((x) => x.id === b.id);
|
||||
assert.equal(e.counterText, '还剩 2 场作战');
|
||||
assert.match(e.desc, /<@ba\.vup>两场作战<\/>/, 'the effects tooltip says the same');
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 9, { ready: true });
|
||||
assert.deepEqual(bountyRounds(b.id), [3, 4], 'its enemy comes for two battles, then never again');
|
||||
assert.ok(!ps.bounties.some((x) => x.id === b.id), 'and the bounty is gone');
|
||||
m.dispose();
|
||||
}
|
||||
assert.ok(done, 'some R3 draft offered a multi-round card');
|
||||
});
|
||||
|
||||
test('#4 the active bounty says how many battles it has left, with its card text in the official colours (effects column)', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 670, fake: true }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.draft && c.rounds === 2 && m.gd.enemy(c.enemyKey));
|
||||
m.addBounty(ps, card);
|
||||
const e = ps.privateView().effects.find((x) => x.name === card.name);
|
||||
assert.ok(e, 'listed');
|
||||
assert.equal(e.counter, 2);
|
||||
assert.equal(e.counterText, '还剩 2 场作战');
|
||||
assert.match(e.desc, /<@ba\.vup>两场作战<\/>/);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// #7 — "联防阶段队友漏掉的怪要是被打掉了漏怪的数字实时回调(现在好像最高只显示漏10个…十个以上最多只会一次扣10滴血的规则保持不变)"
|
||||
|
||||
const LEAKS = 14; // more than the 10-LP cap
|
||||
const SLIME = 'enemy_1007_slime';
|
||||
|
||||
/** Real battles whose 联防 helpers strike down one of the leaked enemies every `every` game seconds (from 4 s on). */
|
||||
function slowHelpers(every = 6) {
|
||||
return class SlowHelpers extends Battle {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
if (this.kind !== 'unite') return;
|
||||
let next = 4;
|
||||
this.on('tick', () => {
|
||||
if (this.time < next) return;
|
||||
const e = this.enemies.find((x) => x.alive && x.counted);
|
||||
if (!e) return;
|
||||
this.kill(e, null);
|
||||
next += every;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Normal-battle results of the round: `leaker` let `n` `key` enemies (LEAKS 源石虫) through, everyone else was perfect. */
|
||||
function craftedResults(m, leaker, key = SLIME, n = LEAKS) {
|
||||
const out = new Map();
|
||||
for (const ps of m.alivePlayers()) {
|
||||
const leaked = ps.playerId === leaker
|
||||
? Array.from({ length: n }, () => ({ enemyKey: key, mods: { hpMul: 1, atkMul: 1, speedMul: 1 }, lpr: 1, sourcePlayerId: leaker, tag: null, counted: true }))
|
||||
: [];
|
||||
out.set(ps.playerId, { leaked, perfect: leaked.length === 0, coins: 0, layerGains: {}, killed: 5, total: 5 + leaked.length, damageDealt: 0, unitsEnd: [] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Enter the 联防 of round 1 from crafted COMBAT results (the real planner, field and settlement from there). */
|
||||
function enterUnite(h, leaker, key = SLIME, n = LEAKS) {
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 1, { ready: false });
|
||||
m.phase = PHASE.COMBAT;
|
||||
m.fields = [];
|
||||
m.lastResults = craftedResults(m, leaker, key, n);
|
||||
m._afterCombat();
|
||||
assert.equal(m.phase, PHASE.UNITE, '联防');
|
||||
assert.deepEqual(m.unitePlan.leakers.map((p) => p.playerId), [leaker]);
|
||||
}
|
||||
|
||||
/** m.public players[] entry of a player. */
|
||||
const pubOf = (m, pid) => m.publicView().players.find((p) => p.playerId === pid);
|
||||
|
||||
/** Follow the leaker's public count until the 联防 ends; returns the samples [uniteLeft, pendingLp]. */
|
||||
function followUnite(h, leaker, stepMs = 500) {
|
||||
const m = h.m;
|
||||
const seen = [];
|
||||
for (let i = 0; i < 4000 && m.phase === PHASE.UNITE; i++) {
|
||||
const p = pubOf(m, leaker);
|
||||
if (p && Number.isFinite(p.uniteLeft)) seen.push([p.uniteLeft, p.pendingLp ?? 0]);
|
||||
h.sched.advance(stepMs);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
function checkCounter(seen, lpBefore, lpAfter) {
|
||||
assert.ok(seen.length > 5, `${seen.length} samples`);
|
||||
assert.equal(seen[0][0], LEAKS, `the counter starts at every enemy that got through (${LEAKS}, not capped at 10)`);
|
||||
assert.equal(seen[0][1], 10, 'the LP about to be lost is capped at 10');
|
||||
// 源石虫 never split or summon: here it can only fall (a splitting enemy raises it — the 磨砻 test below)
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i][0] <= seen[i - 1][0], `no split, so it never rises (${seen[i - 1][0]} → ${seen[i][0]})`);
|
||||
for (const [left, pending] of seen) assert.equal(pending, Math.min(10, left), `pendingLp = min(10, ${left})`);
|
||||
const distinct = new Set(seen.map((s) => s[0]));
|
||||
assert.ok(distinct.size >= 4, `it moved live: ${[...distinct].join(' → ')}`);
|
||||
const last = seen[seen.length - 1][0];
|
||||
assert.ok(last < 10, `helpers brought it under the cap (${last})`);
|
||||
assert.equal(lpBefore - lpAfter, Math.min(10, last), `settled LP loss = min(10, ${last} left)`);
|
||||
}
|
||||
|
||||
/** Run on until the round's settlement has been applied. */
|
||||
const toSettled = (h) => assert.ok(h.drive(() => h.m.phase !== PHASE.UNITE && h.m.phase !== PHASE.SETTLE, { ready: false }));
|
||||
|
||||
test('#7 联防 (server-run): the leaker\'s count of its enemies still standing falls live as the helpers kill them; LP loss stays capped at 10', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 700, instant: false });
|
||||
h.m.BattleClass = slowHelpers();
|
||||
h.start();
|
||||
enterUnite(h, 'p_1');
|
||||
const leaker = h.ps('p_1');
|
||||
const lp = leaker.lp;
|
||||
assert.equal(pubOf(h.m, 'p_0').uniteLeft, undefined, 'only leakers carry the counter');
|
||||
const seen = followUnite(h, 'p_1');
|
||||
toSettled(h);
|
||||
checkCounter(seen, lp, leaker.lp);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('#7 联防 (client-side combat, a human helper runs it): b.progress carries `left`; the server republishes it to everyone', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 701, instant: false, clientCombat: true, clients: false });
|
||||
h.clients = attachSimClients(h, { BattleClass: slowHelpers(), pace: 'paced' });
|
||||
h.start();
|
||||
enterUnite(h, 'p_1');
|
||||
const leaker = h.ps('p_1');
|
||||
const lp = leaker.lp;
|
||||
const seen = followUnite(h, 'p_1');
|
||||
toSettled(h);
|
||||
checkCounter(seen, lp, leaker.lp);
|
||||
const lefts = h.clients.get('p_0').log.filter((x) => x.t === 'b.progress' && x.left);
|
||||
assert.ok(lefts.length > 3, 'the authority reported the leakers\' counts');
|
||||
assert.ok(lefts.every((x) => Object.keys(x.left).every((k) => k === 'p_1')), 'only the leaker\'s enemies');
|
||||
h.clients.closeAll();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('#7 联防 (client-side combat, a bot helper: the server runs it headlessly) — the timeline carries the counts on the field clock', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 702, instant: false, clientCombat: true });
|
||||
h.m.BattleClass = slowHelpers();
|
||||
h.start();
|
||||
enterUnite(h, 'p_0');
|
||||
assert.equal(h.m.fields[0].mode, 'server', 'the bot helper\'s field runs on the server');
|
||||
const leaker = h.ps('p_0');
|
||||
const lp = leaker.lp;
|
||||
const seen = followUnite(h, 'p_0');
|
||||
toSettled(h);
|
||||
checkCounter(seen, lp, leaker.lp);
|
||||
h.clients.closeAll();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('#7 uniteLeft (sim) = each source\'s enemies unspawned + alive + through again; at the end = unite.js uniteSurvivors', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 703, instant: false });
|
||||
h.m.BattleClass = slowHelpers(9);
|
||||
h.start();
|
||||
enterUnite(h, 'p_1');
|
||||
const b = h.m.fields[0].battle;
|
||||
const samples = [];
|
||||
for (let i = 0; i < 4000 && !b.finished; i++) {
|
||||
h.sched.advance(250);
|
||||
if (b.finished) break;
|
||||
const left = uniteLeft(b);
|
||||
const pending = b._pending.filter((p) => p.sourcePlayerId === 'p_1').length;
|
||||
const alive = b.enemies.filter((e) => e.alive && e.counted && e.sourcePlayerId === 'p_1').length;
|
||||
const through = Object.values(b._perPlayer).flatMap((pp) => pp.leaked).filter((l) => l.counted !== false && l.sourcePlayerId === 'p_1').length;
|
||||
assert.equal(left.p_1 || 0, pending + alive + through);
|
||||
assert.equal((left.p_1 || 0) + b.killed, LEAKS, 'standing + struck down = what got through');
|
||||
samples.push(left.p_1 || 0);
|
||||
}
|
||||
assert.ok(b.finished);
|
||||
const survivors = uniteSurvivors(h.m.unitePlan, b.result()).get('p_1') || 0;
|
||||
assert.equal(uniteLeft(b).p_1 || 0, survivors, 'the finished field: exactly the survivors settle() charges');
|
||||
assert.equal(battleProgress(b).left.p_1 || 0, survivors, 'battleProgress carries it (b.progress `left`)');
|
||||
assert.ok(samples[0] === LEAKS && samples[samples.length - 1] >= survivors);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter — the children are billed to the same leaker; the clamp is what settlement can bill, not what was sent in', () => {
|
||||
const MOLONG = 'enemy_1195_sfyin';
|
||||
const SENT = 4;
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 710, instant: false });
|
||||
h.m.BattleClass = slowHelpers(3);
|
||||
h.start();
|
||||
enterUnite(h, 'p_1', MOLONG, SENT);
|
||||
const m = h.m;
|
||||
const leaker = h.ps('p_1');
|
||||
const lp = leaker.lp;
|
||||
const b = m.fields[0].battle;
|
||||
const seen = [];
|
||||
for (let i = 0; i < 4000 && m.phase === PHASE.UNITE; i++) {
|
||||
const p = pubOf(m, 'p_1');
|
||||
if (!b.finished) {
|
||||
const alive = b.enemies.filter((e) => e.alive && e.counted && e.sourcePlayerId === 'p_1').length;
|
||||
const pending = b._pending.filter((x) => x.sourcePlayerId === 'p_1').length;
|
||||
const through = Object.values(b._perPlayer).flatMap((pp) => pp.leaked).filter((l) => l.counted !== false && l.sourcePlayerId === 'p_1').length;
|
||||
assert.equal(p.uniteLeft, pending + alive + through, 'm.public = the leaker\'s enemies standing on the field, children included');
|
||||
}
|
||||
seen.push(p.uniteLeft);
|
||||
h.sched.advance(500);
|
||||
}
|
||||
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
|
||||
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
|
||||
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
|
||||
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
|
||||
assert.ok(Math.max(...seen) <= SENT * 3, 'within what 4 磨砻 can leave behind (2 each)');
|
||||
const last = seen[seen.length - 1];
|
||||
toSettled(h);
|
||||
assert.equal(lp - leaker.lp, Math.min(10, last), `settled LP loss = min(10, ${last} left)`);
|
||||
h.m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// User playtest #6 items 1 and 2 (both from a friend): 凯瑟琳's device and 赫默's drone could not be placed by hand.
|
||||
// The official rule (PRTS 卫戍协议/帮助 §战斗部署): "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区 …
|
||||
// 根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区" — every manually deployable summon (shop state
|
||||
// tokenDisplayType DEFAULT; 凯瑟琳's 爬行号·防护单元 has no entry and is deployable in the base game) is a hand card as
|
||||
// soon as its owner stands on the board, a skill's summon only while that skill is equipped; "移动干员时,其所属召唤物全部
|
||||
// 退场并重置至手牌区" (also when a summon dragged onto its owner swaps the owner away); a summon stack left in the
|
||||
// 临时整备区 at the prep deadline "会于下一回合返还" (§手牌区). The battle side is test/content/playtest6_summons.test.js.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { checkLoadout } from '../../shared/protocol.js';
|
||||
import { DATA, makeMatch, give, giveItem, checkInvariants, legalTileFor } from './harness.js';
|
||||
import { botPrep } from '../../server/match/bot.js';
|
||||
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const CATHY = 'chess_char_4_11_a';
|
||||
const SHAMARE = 'chess_char_3_15_a';
|
||||
const DRONE = 'token_10000_silent_healrb';
|
||||
const DEVICE = 'token_10041_cathy_catsld';
|
||||
const DOLL = 'token_10006_vodfox_doll';
|
||||
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
|
||||
function prep({ loadout = null, stageId = 'act2autochess_m04', seed = 11 } = {}) {
|
||||
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
|
||||
h.toPrep(1);
|
||||
h.setStage(stageId);
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
return { h, m: h.m, ps };
|
||||
}
|
||||
|
||||
const tokensOf = (ps, id) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
|
||||
const stackOf = (ps, id) => ps.hand.find((p) => p && p.kind === 'token' && p.id === id) ?? null;
|
||||
const move = (m, uid, to, dir) => m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
|
||||
|
||||
test('tokens.json: the manually deployable summons are hand pieces — 医疗探机, 诅咒娃娃, 爬行号·防护单元 with the talent ones', () => {
|
||||
const placeable = Object.values(DATA.tokens).filter((t) => t.placeable).map((t) => t.tokenId).sort();
|
||||
assert.deepEqual(placeable, [DRONE, DOLL, 'token_10017_skadi2_dedant', 'token_10028_vigil_wolf', 'token_10030_mlyss_wtrman', DEVICE].sort());
|
||||
// HIDDEN shop-state tokens stay battle-only (PRTS: 新约能天使 with 使命必达! provides no 投递坐标 card)
|
||||
for (const t of Object.values(DATA.tokens)) if (t.displayType === 'HIDDEN') assert.equal(t.placeable, false, t.name);
|
||||
assert.equal(DATA.tokens.token_10056_angel2_target.placeable, false);
|
||||
// 凯瑟琳's device: no shop-state entry; the hand gets its deploy limit (2), not the 3 carried
|
||||
assert.equal(DATA.tokens[DEVICE].displayType, null);
|
||||
assert.equal(DATA.tokens[DEVICE].deployLimit, 2);
|
||||
});
|
||||
|
||||
test('#2 赫默 on the board sends a 医疗探机 card to the hand; it is placed by hand like any summon (S2 only)', () => {
|
||||
const { m, ps } = prep();
|
||||
const hm = give(m, ps, SILENCE);
|
||||
const at = legalTileFor(m, ps, SILENCE);
|
||||
assert.deepEqual(move(m, hm.uid, { area: 'board', row: at[0], col: at[1] }), { ok: true });
|
||||
const stack = stackOf(ps, DRONE);
|
||||
assert.ok(stack, 'the drone card is in the hand');
|
||||
assert.equal(stack.ownerUid, hm.uid);
|
||||
assert.equal(stack.count, 1, 'deploy limit 1');
|
||||
const deployed = ps.deployCount;
|
||||
const t = legalTileFor(m, ps, SILENCE);
|
||||
assert.deepEqual(move(m, stack.uid, { area: 'board', row: t[0], col: t[1] }, 'UP'), { ok: true });
|
||||
const piece = ps.board.get(`${t[0]},${t[1]}`);
|
||||
assert.equal(piece.kind, 'token');
|
||||
assert.equal(piece.id, DRONE);
|
||||
assert.equal(piece.dir, 'UP');
|
||||
assert.equal(ps.deployCount, deployed, 'summons use no deploy slot (PRTS 医疗探机 部署占用数 0)');
|
||||
const input = ps.battleInput({ side: 'L', colOffset: 0 });
|
||||
assert.ok(input.units.some((u) => u.kind === 'token' && u.tokenId === DRONE && u.ownerUid === hm.uid && u.row === t[0] && u.col === t[1]));
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
// S1 治疗强化·γ型 makes no drone: no card
|
||||
const lo = checkLoadout({ [SILENCE]: { skill: 0 } }, chess).loadout;
|
||||
const s1 = prep({ loadout: lo });
|
||||
const hm1 = give(s1.m, s1.ps, SILENCE, 'board', [9, 3]);
|
||||
assert.ok(hm1);
|
||||
assert.equal(tokensOf(s1.ps, DRONE).length, 0, '赫默 S1: no drone card');
|
||||
s1.m.dispose();
|
||||
});
|
||||
|
||||
test('#1 凯瑟琳 on the board sends her 支援装置 (2 = deploy limit) to the hand; both can be placed and turned', () => {
|
||||
const { m, ps } = prep();
|
||||
const cat = give(m, ps, CATHY);
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
|
||||
const stack = stackOf(ps, DEVICE);
|
||||
assert.ok(stack, 'the device card is in the hand');
|
||||
assert.equal(stack.count, 2);
|
||||
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 3 }, 'RIGHT'), { ok: true });
|
||||
assert.equal(stack.count, 1, 'one left on the card');
|
||||
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 11, col: 4 }, 'DOWN'), { ok: true });
|
||||
const devs = [...ps.board.values()].filter((p) => p.kind === 'token' && p.id === DEVICE);
|
||||
assert.deepEqual(devs.map((p) => p.dir).sort(), ['DOWN', 'RIGHT']);
|
||||
assert.equal(stackOf(ps, DEVICE), null, 'the whole stack is on the board');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('巫恋 (S2 诅咒娃娃) sends a doll card like 赫默', () => {
|
||||
const { m, ps } = prep();
|
||||
give(m, ps, SHAMARE, 'board', [9, 3]);
|
||||
assert.equal(tokensOf(ps, DOLL).length, 1);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('moving an owner on the board sends its placed summons back to its card (PRTS "移动干员时…重置至手牌区")', () => {
|
||||
const { m, ps } = prep();
|
||||
const cat = give(m, ps, CATHY);
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
|
||||
const stack = stackOf(ps, DEVICE);
|
||||
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
|
||||
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 11, col: 4 }), { ok: true });
|
||||
assert.equal(ps.board.get('10,3')?.id, DEVICE);
|
||||
assert.equal(ps.board.get('11,4')?.id, DEVICE);
|
||||
// re-orienting in place is not a move: the devices stay
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }, 'UP'), { ok: true });
|
||||
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 2);
|
||||
// a real move: both devices leave the board and go back onto one card of 2
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
|
||||
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 0, 'no device left on the board');
|
||||
const back = tokensOf(ps, DEVICE);
|
||||
assert.equal(back.length, 1);
|
||||
assert.equal(back[0].count, 2);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('bots place the summon cards: 赫默\'s drone and 凯瑟琳\'s devices end up on the board, each device facing an operator', () => {
|
||||
for (const [owner, token] of [[SILENCE, DRONE], [CATHY, DEVICE]]) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 21 }).start();
|
||||
h.toPrep(2);
|
||||
const bp = h.ps('ai_0');
|
||||
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
|
||||
bp.board.clear();
|
||||
bp.hand.fill(null);
|
||||
bp.recompute();
|
||||
give(h.m, bp, 'chess_char_1_10_a'); // 古米: a blocker for the devices to face
|
||||
give(h.m, bp, owner);
|
||||
botPrep(h.m, bp);
|
||||
const placed = [...bp.board.entries()].filter(([, p]) => p.kind === 'token' && p.id === token);
|
||||
assert.ok(placed.length >= 1, `${token}: placed by the bot`);
|
||||
if (token === DEVICE) {
|
||||
for (const [k, p] of placed) {
|
||||
const [r, c] = k.split(',').map(Number);
|
||||
const d = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] }[p.dir ?? 'RIGHT'];
|
||||
const front = bp.board.get(`${r + d[0]},${c + d[1]}`);
|
||||
assert.ok(front && front.kind === 'chess', `device at ${k} faces an operator (${p.dir})`);
|
||||
}
|
||||
}
|
||||
h.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('bots re-aim placed devices with every layout: a device left facing an operator the plan moved away is lifted and placed again (QA)', () => {
|
||||
// the bot arranges twice (before and after its items) and may switch to the rehearsed plan: operators moved without
|
||||
// their summons left 凯瑟琳's devices facing an empty tile (3.5–8.6 % of devices in QA's bot matches)
|
||||
const D = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] };
|
||||
const others = ['chess_char_1_10_a', 'chess_char_2_02_a', 'chess_char_1_01_a', 'chess_char_1_02_a', 'chess_char_1_03_a', 'chess_char_1_04_a'];
|
||||
let devices = 0;
|
||||
for (const seed of [21, 22, 23, 24, 25, 26, 27, 28]) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed }).start();
|
||||
h.toPrep(seed % 2 ? 3 : 5);
|
||||
const bp = h.ps('ai_0');
|
||||
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
|
||||
bp.board.clear();
|
||||
bp.hand.fill(null);
|
||||
bp.recompute();
|
||||
// a hand-made layout the bot will rearrange: every operator on the first legal tile, both devices facing one of them
|
||||
for (const id of [CATHY, ...others.slice(0, bp.deployCap - 1)]) {
|
||||
const p = give(h.m, bp, id);
|
||||
const t = legalTileFor(h.m, bp, id);
|
||||
if (t) bp.move(p.uid, { area: 'board', row: t[0], col: t[1] }, 'RIGHT');
|
||||
}
|
||||
const [fk] = [...bp.board.entries()].find(([, p]) => p.kind === 'chess' && p.id !== CATHY);
|
||||
const [fr, fc] = fk.split(',').map(Number);
|
||||
for (const [dr, dc, dir] of [[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]) {
|
||||
const stack = stackOf(bp, DEVICE);
|
||||
if (!stack) break;
|
||||
if (!bp.board.has(`${fr + dr},${fc + dc}`)) bp.move(stack.uid, { area: 'board', row: fr + dr, col: fc + dc }, dir);
|
||||
}
|
||||
botPrep(h.m, bp);
|
||||
for (const [k, p] of bp.board) {
|
||||
if (p.kind !== 'token' || p.id !== DEVICE) continue;
|
||||
devices++;
|
||||
const [r, c] = k.split(',').map(Number);
|
||||
const d = D[p.dir ?? 'RIGHT'];
|
||||
const front = bp.board.get(`${r + d[0]},${c + d[1]}`);
|
||||
assert.ok(front && front.kind === 'chess', `seed ${seed}: the device at ${k} (${p.dir}) faces an operator`);
|
||||
}
|
||||
checkInvariants(h.m);
|
||||
h.m.dispose();
|
||||
}
|
||||
assert.ok(devices >= 12, `devices checked: ${devices}`);
|
||||
});
|
||||
|
||||
test('a summon card needs its owner on the board; the owner withdrawn takes its summons along', () => {
|
||||
const { m, ps } = prep();
|
||||
const hm = give(m, ps, SILENCE);
|
||||
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
|
||||
const stack = stackOf(ps, DRONE);
|
||||
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 5 }), { ok: true });
|
||||
const free = ps.hand.findIndex((x) => x == null);
|
||||
assert.deepEqual(move(m, hm.uid, { area: 'hand', idx: free }), { ok: true });
|
||||
assert.equal(tokensOf(ps, DRONE).length, 0, 'withdrawn with its owner');
|
||||
// back on the board: a new card; it cannot be placed once the owner is gone again, and summons are not sold
|
||||
const at = legalTileFor(m, ps, SILENCE);
|
||||
assert.deepEqual(move(m, hm.uid, { area: 'board', row: at[0], col: at[1] }), { ok: true });
|
||||
const card = stackOf(ps, DRONE);
|
||||
assert.ok(card);
|
||||
assert.equal(m.handle('p_0', { t: 'g.sell', uid: card.uid }).error, ERR.BAD_TARGET);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a summon dragged onto an operator swaps it away: that operator\'s other placed summons go back onto their card', () => {
|
||||
const { m, ps } = prep();
|
||||
const cat = give(m, ps, CATHY);
|
||||
const hm = give(m, ps, SILENCE);
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
|
||||
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
|
||||
const devCard = stackOf(ps, DEVICE);
|
||||
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
|
||||
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
|
||||
assert.deepEqual(move(m, stackOf(ps, DRONE).uid, { area: 'board', row: 10, col: 7 }), { ok: true });
|
||||
// 凯瑟琳's device (10,3) dropped onto 赫默 (10,6): 赫默 takes 10,3, so her drone goes back to her card; 凯瑟琳 did not move
|
||||
const devA = ps.board.get('10,3');
|
||||
assert.deepEqual(move(m, devA.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
|
||||
assert.equal(ps.board.get('10,3')?.uid, hm.uid);
|
||||
assert.equal(ps.board.get('10,6')?.uid, devA.uid, 'the dragged device is where it was dropped');
|
||||
assert.equal(ps.board.get('10,7'), undefined, '赫默\'s placed drone left the board');
|
||||
assert.equal(stackOf(ps, DRONE)?.count, 1, '… back onto her card');
|
||||
assert.equal(ps.board.get('11,4')?.id, DEVICE, '凯瑟琳\'s other device stays');
|
||||
// the other device (11,4) dropped onto its own owner (10,4): 凯瑟琳 takes 11,4; the dropped device stays at 10,4, her
|
||||
// remaining placed device (10,6) goes back onto her card
|
||||
const devB = ps.board.get('11,4');
|
||||
assert.deepEqual(move(m, devB.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
|
||||
assert.equal(ps.board.get('11,4')?.uid, cat.uid);
|
||||
assert.equal(ps.board.get('10,4')?.uid, devB.uid);
|
||||
assert.equal(ps.board.get('10,6'), undefined);
|
||||
assert.equal(stackOf(ps, DEVICE)?.count, 1);
|
||||
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2, 'still 2 devices in all');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
/** Plain equipment (no equip / merge side effects) to fill the hand. */
|
||||
const PLAIN = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id).filter(Boolean);
|
||||
|
||||
test('a summon stack left in temp at the prep deadline comes back next round (PRTS "干员所属召唤物会于下一回合返还")', () => {
|
||||
// co-op of 2 humans: a timed prep whose deadline resolves temp (PlayerState.resolveTemp), FakeBattle
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 1601, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
h.setStage('act2autochess_m04');
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
const cat = give(m, ps, CATHY);
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
|
||||
const card = stackOf(ps, DEVICE);
|
||||
assert.deepEqual(move(m, card.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
|
||||
assert.deepEqual(move(m, card.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
|
||||
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, PLAIN[i]);
|
||||
// moving 凯瑟琳 with a full hand: her devices go back as one card of 2, which overflows into temp and blocks Ready
|
||||
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
|
||||
const inTemp = ps.temp.find((p) => p && p.kind === 'token' && p.id === DEVICE);
|
||||
assert.equal(inTemp?.count, 2);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
|
||||
// the player leaves it there: the prep deadline removes the temp card …
|
||||
h.drive(() => m.phase === PHASE.COMBAT && m.round === 1, { ready: false });
|
||||
assert.equal(tokensOf(ps, DEVICE).length, 0, 'removed with the temp slot at the deadline');
|
||||
// … and it comes back at the next round (still a full hand ⇒ temp again, where it can be placed)
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 2, { ready: false });
|
||||
const back = tokensOf(ps, DEVICE);
|
||||
assert.equal(back.length, 1);
|
||||
assert.equal(back[0].count, 2, 'the deploy limit again');
|
||||
assert.equal(back[0].ownerUid, cat.uid);
|
||||
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 10, col: 5 }, 'RIGHT'), { ok: true });
|
||||
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 11, col: 6 }, 'UP'), { ok: true });
|
||||
assert.ok(ps.tempEmpty, 'placing both cleared temp');
|
||||
// a later round start grants nothing more: both devices are on the board
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
|
||||
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2);
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -206,9 +206,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo
|
||||
const send = r.net.send;
|
||||
r.net.send = (t, fields) => { if (t === 'b.progress') cumAtSend.push(pool.cum); return send(t, fields); };
|
||||
r.advance(3000);
|
||||
const first = r.net.sent.filter((x) => x.t === 'b.progress');
|
||||
assert.ok(first.length >= 10 && first.length <= 14, `4 Hz (${first.length} in 3 s)`);
|
||||
// how soon the operators reach the leader depends on the board the autoplayed match built
|
||||
for (let i = 0; i < 30 && !(pool.cum > 0); i++) r.advance(1000);
|
||||
assert.ok(pool.cum > 0, 'local damage to the pool');
|
||||
const prog = r.net.sent.filter((x) => x.t === 'b.progress');
|
||||
assert.ok(prog.length >= 10 && prog.length <= 14, `4 Hz (${prog.length} in 3 s)`);
|
||||
const last = prog[prog.length - 1];
|
||||
assert.equal(last.bossDmg, cumAtSend[cumAtSend.length - 1]);
|
||||
assert.ok(last.bossDmg <= pool.cum);
|
||||
|
||||
@@ -137,6 +137,8 @@ export class SimClient {
|
||||
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
|
||||
} else {
|
||||
msg.leaks = p.leaks;
|
||||
// 联防: the leakers' enemies still standing (like public/js/battle/runner.js)
|
||||
if (p.left) msg.left = Object.fromEntries(Object.entries(p.left).slice(0, 4));
|
||||
}
|
||||
this.send(msg);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user