Files
ark-stronghold-protocol/test/match/connection.test.js
T
sganggs 4e58481706 feedback5 WY: the Final Assault / Hidden Core leader pool is bloodPoint × the players alive when the fight starts (solo × 1), replacing 「保持固定血量」; 胄's drone share passes 限伤 (from PR #209 by @qingjingshenghuo; the owner's decision of 2026-10-06)
Ported by hand from PR #209 by @qingjingshenghuo (its one-line data/config.json was not taken):
- server/match/gamedata.js bossPoolShareOf (used by GameData.bossPoolShare and by finalAssault.bossPoolHp for a plain
  game-data object): solo × `solo` (1); co-op with `perPlayer` true × `coop` (1) × the players alive at the boss
  round's start (bots and AI 托管 seats count, eliminated / departed seats do not; at most aliveFull 4; a count left
  out = a full team). The fixed pool stays restorable through config: bossHpScale perPlayer false + solo 0.25 (and its
  optional aliveScaling).
- tools/build-data.mjs: the global bossHpScale carries the rule (perPlayer true, solo 1); the modes' entries keep only
  bloodPointKey / unaffectedByEnemyScale. data/config.json regenerated offline — JSON compare: only bossHpScale (global
  and the 9 mode entries) changed.
- The problem #209 documented: a 胄 drone death is 2 % of the pool and 限伤 cancels a hit of ceil ≥ 300000, so the
  hidden 胄 终极 pool at 3 / 4 players (21.6M / 28.8M) would cancel every drone (432000 / 576000). The drone link is
  a share of the pool, no hit: Battle.loseHp takes noHitLimit and the link passes the limit [ASSUMED: the official
  client checks every damage modifier and PRTS calls it 真实伤害, but which max HP it reads is undocumented]; a part's
  传递 and every hit stay under the limit.

Golden (regenerated; why: the pool size):
- fields: boss-boss_1..7-pair/-solo and hidden-boss_8..10-pair/-solo (all 20 boss / hidden scenarios) — a pair
  field's pool × 2 (two players), a solo template's × 4 (solo × 1, was × 0.25); boss-boss_1-pair, boss-boss_1-solo,
  boss-boss_3-pair, boss-boss_3-solo, boss-boss_5-solo, boss-boss_7-pair and hidden-boss_9-solo now end at the
  200 s cap instead of clearing; the two 联防 scenarios are unchanged.
- matches: solo-FUNNY-2, solo-NORMAL-1, solo-HARD-1, solo-HARD-2 now lose the Final Assault (solo leaders have 4× the
  HP: team LP 0 with 93088–176395 of the pool left); solo-FUNNY-1, solo-NORMAL-2, solo-HARD-9-boosted (its Hidden Core
  too), coop2-NORMAL-3, coop4-FUNNY-6, coop2-ABYSS-10-boosted, coop2-NORMAL-70-diy still win with more leader damage
  (pool × players alive; solo-FUNNY-1 / solo-HARD-9-boosted end with 6 / 1 team LP less). Unchanged:
  coop2-NORMAL-8-serverrun and coop2-NORMAL-14-standins (one player left at R14: × 1 as before) and the matches that
  end before R14; roster / bonds / standins / diy unchanged.

Tests: test/match/boss-hp-scaling.test.js (from PR #209, adapted — every leader and difficulty × 1–4 players and solo;
R14 / R15 leave out two departed players; AI 托管 and bots count, an eliminated player does not; config restores the
fixed pool, the opposite of the PR's test; the plain-object fallback agrees; only the leader's HP scales);
test/content/enemies_bosses.test.js (hidden 胄 终极 at 3 / 4 players: the drone share lands, a hit of the same size is
cancelled — fails without the change). Updated on purpose: finalAssault, balance, bosshp, connection, docs-consistency
(the §20.9 record points to §25.13.4; the data carries perPlayer true), sim/playtest6_limits (the drone share lands
past the line), match/playtest6-bosspool (the 5 % knob halved to keep the 2-player pool's size: that test is about the
pool's arithmetic). Docs: PLAYING §7, DATA, META, BALANCE, SIM, design engine.md / network.md, the §20.9 / §20.10
records, research 00 / 01 / 08, the code comments, DESIGN §25.13.4 and the §25.10 table.
2026-10-06 21:41:49 +08:00

307 lines
15 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.
// Platform interface: start/handle/onDisconnect/onReconnect/onLeave/dispose, autoplay, bot takeover, views.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js';
import { Match } from '../../server/match/Match.js';
import { StubMatch } from '../../server/match/StubMatch.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
import { bossPoolHp } from '../../server/match/finalAssault.js';
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
test('constructor validation mirrors the stub; start() sends the first m.public and one m.private per human', () => {
assert.throws(() => new Match({ seats: [] }), TypeError);
assert.throws(() => new Match({ seats: [{ seat: 0, playerId: 'a', name: 'a', isBot: false, connected: true }] }), TypeError);
assert.equal(typeof StubMatch, 'function', 'the platform stub is kept for platform tests');
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 60 }).start();
const pub = h.lastBc('m.public');
for (const k of ['phase', 'round', 'lastRound', 'deadline', 'serverNow', 'modeId', 'difficulty', 'stageId', 'factions', 'disabledBonds', 'bannedChess', 'bossId', 'players', 'fields']) assert.ok(k in pub, `m.public.${k}`);
assert.equal(pub.phase, PHASE.INFO_CHECK);
assert.equal(pub.lastRound, 14);
assert.ok(DATA.stages[pub.stageId]);
assert.equal(pub.factions.length, 3);
assert.ok(DATA.bosses[pub.bossId]);
for (const p of pub.players) {
for (const k of ['playerId', 'seat', 'name', 'isBot', 'connected', 'alive', 'lp', 'bandId', 'shopLevel', 'boardCount', 'ready', 'bonds', 'fieldId', 'status']) assert.ok(k in p, `players[].${k}`);
assert.ok(STATUSES.includes(p.status));
}
const privs = h.sent.filter(([, m]) => m.t === 'm.private');
assert.deepEqual(privs.map(([id]) => id).sort(), ['p_0', 'p_1'], 'one m.private per human, none for bots');
for (const [id, priv] of privs) {
assert.equal(priv.playerId, id);
for (const k of ['seat', 'alive', 'lp', 'funds', 'bandId', 'ready', 'canReady', 'shop', 'hand', 'temp', 'board', 'deployCap', 'deployCount', 'bonds', 'effects', 'nextEnemies', 'stats']) assert.ok(k in priv, `m.private.${k}`);
for (const k of ['level', 'maxLevel', 'upgradePrice', 'refreshPrice', 'freeRefreshes', 'frozen', 'slots', 'rewardOffer']) assert.ok(k in priv.shop, `shop.${k}`);
assert.equal(priv.hand.length, 10);
assert.equal(priv.temp.length, 5);
for (const k of ['dmgDealt', 'kills', 'leaks', 'gold', 'refreshes', 'merges']) assert.ok(k in priv.stats);
}
h.m.dispose();
});
test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effects, nextEnemies)', () => {
const h = makeMatch({ mode: 'solo', seed: 61 }).start();
h.toPrep(1);
h.flushAll();
const priv = h.lastTo('p_0', 'm.private');
assert.equal(priv.funds, 4);
assert.equal(priv.shop.slots.length, 4);
for (const s of priv.shop.slots) {
assert.deepEqual(Object.keys(s).sort(), ['basePrice', 'frozen', 'id', 'kind', 'price', 'sold']);
assert.equal(s.frozen, false);
assert.ok(s.kind === 'chess' || s.kind === 'item');
}
assert.equal(priv.shop.rewardOffer, null);
assert.equal(priv.deployCap, 8);
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
assert.ok(priv.nextEnemies.length > 0);
for (const e of priv.nextEnemies) { assert.ok(DATA.enemies[e.enemyKey]); assert.ok(e.count >= 1); assert.ok('tag' in e); }
const ps = h.ps('p_0');
h.m.handle('p_0', { t: 'g.buy', slot: 0 });
h.flushAll();
const p2 = h.lastTo('p_0', 'm.private');
const piece = p2.hand.find(Boolean);
assert.deepEqual(Object.keys(piece).sort(), ['count', 'golden', 'id', 'items', 'kind', 'ownerUid', 'tier', 'uid']);
assert.equal(p2.funds, ps.funds);
h.m.dispose();
});
test('m.public is throttled to ≤ 10/s; m.private is only resent when it changed', () => {
const h = makeMatch({ mode: 'solo', seed: 62 }).start();
h.toPrep(1);
h.sched.advance(500);
const n0 = h.bc.filter((m) => m.t === 'm.public').length;
const t0 = h.sched.now();
for (let i = 0; i < 50; i++) { h.m.handle('p_0', { t: 'g.freeze' }); h.sched.advance(5); }
h.sched.advance(200);
const n1 = h.bc.filter((m) => m.t === 'm.public').length;
const elapsed = (h.sched.now() - t0) / 1000;
assert.ok(n1 - n0 <= Math.ceil(elapsed * 10) + 1, `${n1 - n0} public frames in ${elapsed}s`);
const privs = h.allTo('p_0', 'm.private').length;
h.m.flush();
h.m.markPrivate(h.ps('p_0'));
h.m.flush();
assert.equal(h.allTo('p_0', 'm.private').length, privs, 'unchanged private view is not resent');
h.m.dispose();
});
test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at deadlines; reconnect resends everything', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 63, fake: true, script: () => ({ duration: 4 }) }).start();
const m = h.m;
h.m.onDisconnect('p_1');
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').connected, false);
m.handle('p_0', { t: 'g.infoReady' });
// p_1 never confirms: the 25 s deadline moves on; p_1 never picks: 12 s turn → 华法琳
h.drive(() => m.phase === PHASE.PREP && m.round === 1);
assert.equal(h.ps('p_1').bandId, 'band_bldsk');
const sentBefore = h.sent.length;
// p_0 readies; p_1 is auto-readied at the prep deadline
m.handle('p_0', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
assert.ok(h.ps('p_1').ready);
assert.ok(m.fields.some((f) => f.fieldId === 'n:p_1'), 'the disconnected seat still fights');
assert.ok(!h.sent.slice(sentBefore).some(([id]) => id === 'p_1') || true);
// reconnect mid-combat: m.public + m.private + m.field + b.snap
const mark = h.sent.length;
m.onReconnect('p_1');
const got = h.sent.slice(mark).filter(([id]) => id === 'p_1').map(([, x]) => x.t);
assert.ok(got.includes('m.public') && got.includes('m.private') && got.includes('m.field') && got.includes('b.snap'), got.join());
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').connected, true);
m.dispose();
});
test('g.autoplay lets the bot play a human seat (buys, places, readies); turning it off returns control', () => {
const h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
const m = h.m;
h.toPrep(1);
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: true }), { ok: true });
assert.equal(m.publicView().players[0].autoplay, true);
h.run(() => m.phase !== PHASE.PREP);
const ps = h.ps('p_0');
assert.ok(ps.board.size > 0, 'the bot deployed units');
assert.notEqual(m.phase, PHASE.PREP, 'the bot readied');
h.run(() => m.phase === PHASE.PREP && m.round === 2);
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: false }), { ok: true });
h.sched.advance(60000);
assert.equal(m.phase, PHASE.PREP, 'solo prep waits for the human again');
checkInvariants(m);
m.dispose();
});
test('onLeave: quitting counts as elimination (copies back to the pool at once, status left); last human leaving ends the match once', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 65, fake: true }).start();
const m = h.m;
h.toPrep(1);
const q = h.ps('p_1');
const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) >= 2);
give(m, q, id);
give(m, q, id);
const base = m.gd.baseIdOf(id);
const left0 = m.pool.left(base);
m.onLeave('p_1');
assert.equal(q.alive, false, 'the departed seat is out of the match');
assert.equal(m.pool.left(base), left0 + 2, 'its copies went back to the shared pool at once');
assert.ok(q.board.size === 0 && q.hand.every((x) => x == null), 'it holds nothing');
assert.equal(q.eliminatedRound, 1);
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').status, 'left');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { error: ERR.NOT_IN_ROOM });
checkInvariants(m);
m.handle('p_0', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
assert.deepEqual(m.fields.map((f) => f.fieldId), ['n:p_0'], 'no battle for the departed seat');
h.run(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(m.round, 2);
m.onLeave('p_0');
assert.equal(h.endedCount, 1);
assert.equal(h.ended.reason, 'abandoned');
assert.equal(h.ended.players.find((p) => p.playerId === 'p_1').roundsPassed, 0);
m.onLeave('p_0');
m.onReconnect('p_0');
m.onDisconnect('p_0');
assert.equal(h.endedCount, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
m.dispose();
m.dispose();
assert.equal(h.sched.pending(), 0, 'dispose cancels every timer');
});
test('onLeave: a quitter has no place in the Final Assault pairing or the boss pool; its draft turns pass on', () => {
// band draft: the player on turn leaves → its pick is the default band and the turn moves on
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 5, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
for (const id of ['p_0', 'p_1', 'p_2']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
const first = m.draftTurn();
m.onLeave(first);
assert.equal(m.draft.picks[first], m.gd.bandDraft.timeoutBandId);
assert.notEqual(m.draftTurn(), first);
h.autoHumans();
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
const alive = m.alivePlayers().map((p) => p.playerId);
assert.equal(alive.length, 2);
assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool of two alive players');
assert.equal(m.bossPool.maxHp, m.gd.boss(m.bossId).bloodPoint[m.gd.difficulty] * 2, 'bloodPoint × the two players left; the quitter does not count (DESIGN §25.13.4)');
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(end.players.find((p) => p.playerId === first).roundsPassed, 0);
m.dispose();
// 机变: the picker on turn leaves → the next alive player picks; the last alive player leaving ends the match
const h2 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 18, fake: true }).start();
const m2 = h2.m;
h2.autoHumans();
h2.drive(() => m2.phase === PHASE.SP_DRAFT);
const turn = m2.spTurn();
m2.onLeave(turn);
assert.equal(m2.phase, PHASE.SP_DRAFT);
assert.notEqual(m2.spTurn(), turn, 'the turn passed on');
checkInvariants(m2);
m2.dispose();
// mid-combat: the quitter's own battle is force-ended, the others play on
const h4 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 20, fake: true, instant: false, script: (b) => (b.kind === 'normal' ? { duration: 5 } : {}) }).start();
const m4 = h4.m;
h4.toPrep(2);
h4.drive(() => m4.phase === PHASE.COMBAT);
h4.sched.advance(500);
m4.onLeave('p_2');
const f2 = m4.fields.find((f) => f.fieldId === 'n:p_2');
assert.ok(f2.battle.finished, 'its battle was force-ended');
assert.ok(m4.fields.filter((f) => f.fieldId !== 'n:p_2').every((f) => !f.battle.finished), 'the others still fight');
checkInvariants(m4);
h4.drive(() => m4.phase === PHASE.PREP && m4.round === 3);
assert.deepEqual(m4.alivePlayers().map((p) => p.playerId), ['p_0', 'p_1']);
assert.equal(h4.ps('p_2').eliminatedRound, 2);
checkInvariants(m4);
m4.dispose();
// only an eliminated spectator left → the match ends as a defeat ('eliminated'), not 'abandoned'
const h3 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 19, fake: true }).start();
h3.toPrep(2);
h3.ps('p_1').eliminate(2);
h3.m.onLeave('p_0');
assert.equal(h3.endedCount, 1);
assert.equal(h3.ended.reason, 'eliminated');
assert.ok(h3.lastTo('p_1', 'm.result'), 'the spectator gets the result');
h3.m.dispose();
});
test('dispose stops timers and battles; handle/hooks after dispose are inert', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 66, instant: false }).start();
h.m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(10);
h.m.dispose();
assert.equal(h.sched.pending(), 0);
const n = h.sent.length + h.bc.length;
h.sched.advance(100000);
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE });
h.m.onReconnect('p_0');
h.m.onDisconnect('p_0');
assert.equal(h.sent.length + h.bc.length, n, 'nothing is sent after dispose');
assert.equal(h.endedCount, 0);
});
test('emotes: relayed as m.emote with a 1 s cooldown; g.watch during prep scouts a teammate board', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 67, fake: true }).start();
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.emote', id: 'autochess_battle_happy' }), { ok: true });
assert.deepEqual(h.lastBc('m.emote'), { t: 'm.emote', playerId: 'p_0', id: 'autochess_battle_happy' });
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'slug_autochess_battle_thanks' }).error, ERR.RATE);
h.sched.advance(1000);
// only the official emote ids are relayed (defence in depth: handle() may be called without validateC2S)
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'autochess_room_hello' }).error, ERR.BAD_MSG); // room-scene emote
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'happy' }).error, ERR.BAD_MSG); // v1 short id
assert.deepEqual(m.handle('p_0', { t: 'g.emote', id: 'slug_autochess_battle_thanks' }), { ok: true });
h.toPrep(1);
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const f = h.lastTo('p_0', 'm.field');
assert.equal(f.fieldId, 'n:p_1');
assert.equal(f.prep, true);
assert.ok(Array.isArray(f.units));
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'n:nobody' }).error, ERR.BAD_TARGET);
m.dispose();
});
test('unknown / wrong-phase intents are rejected without throwing', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 68 }).start();
const m = h.m;
assert.equal(m.handle('nobody', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
assert.equal(m.handle('ai_0', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
assert.equal(m.handle('p_0', { t: 'room.start' }).error, ERR.BAD_MSG);
assert.equal(m.handle('p_0', null).error, ERR.BAD_MSG);
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, ERR.WRONG_PHASE);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0 }).error, ERR.WRONG_PHASE);
assert.equal(m.handle('p_0', { t: 'g.band', bandId: 'band_bldsk' }).error, ERR.WRONG_PHASE);
assert.equal(m.errorCount, 0);
m.dispose();
});
test('degraded data: a match without a chess pool ends immediately with an error summary (no throw)', () => {
const out = { ended: null, bc: [] };
const m = new Match({
roomCode: 'X', mode: 'coop', difficulty: 'HARD', modeId: 'mode_multi_hard', seed: 1,
seats: [{ seat: 0, playerId: 'p', name: 'P', isBot: false, connected: true }],
data: { config: {} }, log: { info() {}, warn() {}, error() {} },
send: () => true, broadcast: (x) => out.bc.push(x), onEnd: (s) => { out.ended = s; },
});
m.start();
assert.ok(out.ended);
assert.equal(out.ended.reason, 'error');
m.dispose();
});
test('m.public follows player-state changes (shop level, board count, bonds) within the throttle window', () => {
const h = makeMatch({ mode: 'solo', seed: 69 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
ps.funds = 50;
m.handle('p_0', { t: 'g.levelUp' });
h.sched.advance(150);
assert.equal(h.lastBc('m.public').players[0].shopLevel, 2);
m.dispose();
});