0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
1 parent
bc50cac01f
commit
221e6b8dd2
134 files changed
+4590
-1201
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { BOND_LAYER_CAP, PHASE } from '../../shared/constants.js';
|
||||
|
||||
const cues = h => h.sent.filter(([, msg]) => msg.t === 'm.layer');
|
||||
function prep(t) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, fake: true }).start().toPrep(1);
|
||||
t.after(() => h.m.dispose());
|
||||
h.sent.length = 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
test('prep gains unicast the actual increase once, even for an inactive bond', t => {
|
||||
const h = prep(t), p = h.ps('p_0');
|
||||
assert.equal(p.bonds.sargonShip?.active ?? false, false);
|
||||
assert.equal(p.addLayers('sargonShip', 3), 3);
|
||||
assert.deepEqual(cues(h), [['p_0', { t: 'm.layer', playerId: 'p_0', bondId: 'sargonShip', add: 3 }]]);
|
||||
assert.equal(h.bc.some(msg => msg.t === 'm.layer'), false);
|
||||
p.layers.sargonShip = BOND_LAYER_CAP - 1;
|
||||
p.addLayers('sargonShip', 10);
|
||||
assert.equal(cues(h).at(-1)[1].add, 1);
|
||||
const n = cues(h).length;
|
||||
for (const v of [1, 0, -2, NaN, Infinity]) p.addLayers('sargonShip', v);
|
||||
p.addLayers('not-a-bond', 2);
|
||||
p.addLayers('kjeragShip', 2, { requireActive: true });
|
||||
assert.equal(cues(h).length, n);
|
||||
});
|
||||
|
||||
test('only live prep phases emit, never settlement/backfill or resynchronization views', t => {
|
||||
const h = prep(t), p = h.ps('p_0');
|
||||
for (const phase of Object.values(PHASE)) {
|
||||
h.m.phase = phase; h.sent.length = 0;
|
||||
p.addLayers('sargonShip', 1);
|
||||
assert.equal(cues(h).length, [PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(phase) ? 1 : 0, phase);
|
||||
}
|
||||
h.m.phase = PHASE.PREP; h.sent.length = 0;
|
||||
p.recompute(); p.privateView(); h.m.publicView();
|
||||
assert.equal(cues(h).length, 0);
|
||||
});
|
||||
@@ -264,9 +264,11 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
const [b1, b2] = fields(h);
|
||||
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
|
||||
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
|
||||
const authorities = [b1, b2].map(f => [...f.players].sort((a, b) => h.ps(a).seat - h.ps(b).seat)[0]);
|
||||
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', authorities[0], 'client', authorities[1]], 'lowest seat of each pair');
|
||||
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
|
||||
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
|
||||
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(),
|
||||
[b1, b2].flatMap((f, i) => f.players.map(pid => `${pid}:${f.fieldId}:${pid === authorities[i] ? 'A' : 'r'}`)).sort());
|
||||
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
|
||||
const max = m.bossPool.maxHp;
|
||||
h.sched.advance(3000);
|
||||
@@ -597,7 +599,7 @@ test('boss bounty eligibility: forced real results on a team victory qualify; sy
|
||||
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
|
||||
const { h, m, f } = faWithForger(seed);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
const ps = f.players.map((pid) => h.ps(pid));
|
||||
const ps = f.players.map((pid) => h.ps(pid)).sort((a, b) => a.seat - b.seat); // return payouts by player, not random side
|
||||
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||||
const before = ps.map((p) => p.pendingFunds);
|
||||
const lp0 = m.teamLp;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { computeBonds } from '../../server/match/bondsMeta.js';
|
||||
|
||||
function emptyPrep() {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 11, fake: true }).start().toPrep(1);
|
||||
const p = h.ps('p_0');
|
||||
for (const x of p.allChess()) p.returnCopies(x);
|
||||
p.board.clear(); p.hand.fill(null); p.temp.fill(null); p.offers.length = 0; p.bandId = null; p.recompute();
|
||||
return { h, m: h.m, p };
|
||||
}
|
||||
|
||||
for (const g of Object.values(DATA.garrisons).filter(g => g.effectKey === 'SERVER_GAIN_EQUIP')) {
|
||||
for (const owner of g.owners) test(`fixed gift ${g.garrisonId}/${owner} is produced past stock and still occupies it`, () => {
|
||||
const { m, p } = emptyPrep(), id = g.bbStr.chess, cap = m.itemPool.cap(id);
|
||||
assert.ok(Number.isInteger(cap));
|
||||
for (let i = 0; i < cap; i++) assert.ok(p.acquireItem(id));
|
||||
assert.equal(m.itemPool.left(id), 0);
|
||||
assert.ok(p.acquireChess(owner, { fromPool: false }));
|
||||
assert.equal(m.itemPool.held(id), cap + g.bb.count, 'the trait must finish all grants, including merges');
|
||||
assert.equal(m.itemPool.left(id), 0, 'overstock prevents new shop draws');
|
||||
assert.equal(p.acquireItem(id), null, 'an ordinary stock-backed grant is still refused');
|
||||
checkInvariants(m); m.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test('unlimited fixed-item gifts do not exempt unrelated strategy equipment from stock', () => {
|
||||
const { m, p } = emptyPrep(), id = 'chess_item_5_07_e_a';
|
||||
assert.ok(p.acquireItem(id)); assert.ok(p.acquireItem(id));
|
||||
assert.equal(p.acquireItem(id, { requirePool: false }), null, 'commercial packaging is not a fixed operator gift');
|
||||
assert.equal(m.itemPool.held(id), 2); checkInvariants(m); m.dispose();
|
||||
});
|
||||
|
||||
for (const invest of [false, true]) test(`repeated Muelsyse acquisition and elite gifts remain unlimited (investor=${invest})`, () => {
|
||||
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
|
||||
if (invest) {
|
||||
const members = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('investShip')).slice(0, 3);
|
||||
for (const c of members) give(m, p, c.chessId);
|
||||
}
|
||||
assert.equal(m.dispatcher.investRepeat(p), invest ? 2 : 1);
|
||||
const expected = invest ? [2, 4, 8, 10, 12] : [1, 2, 4, 5, 6];
|
||||
for (const n of expected) {
|
||||
assert.ok(p.acquireChess('chess_char_6_11_a'));
|
||||
assert.equal(m.itemPool.held(id), n); checkInvariants(m);
|
||||
// Stow the non-merging isomorphs on holders so this stock test does not exhaust the player's inventory.
|
||||
for (const item of [...p.hand, ...p.temp].filter(x => x?.kind === 'item')) {
|
||||
const holder = p.allChess().find(c => c.items.length < m.gd.equipPerChess);
|
||||
if (holder) assert.ok(p.equip(item.uid, holder.uid).ok);
|
||||
}
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('Damazti strategy grants its fixed item at rounds 5/10/15 past cap; releasing overflow does not free stock early', () => {
|
||||
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
|
||||
for (let i = 0; i < 3; i++) assert.ok(p.acquireItem(id));
|
||||
p.bandId = 'band_damaztic'; p.recompute();
|
||||
for (const [i, round] of [5, 10, 15].entries()) {
|
||||
m.round = round; m.dispatch(p, 'onRoundStart', { round });
|
||||
assert.equal(m.itemPool.held(id), 4 + i); checkInvariants(m);
|
||||
}
|
||||
const copy = () => [...p.hand, ...p.temp].find(x => x?.kind === 'item' && x.id === id);
|
||||
for (let i = 0; i < 3; i++) { assert.ok(p.destroy(copy().uid).ok); assert.equal(m.itemPool.left(id), 0); }
|
||||
assert.ok(p.destroy(copy().uid).ok);
|
||||
assert.equal(m.itemPool.held(id), 2); assert.equal(m.itemPool.left(id), 1);
|
||||
checkInvariants(m); m.dispose();
|
||||
});
|
||||
|
||||
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`seven distinct Yan operators plus Yan-converted ${mu} activate nine Yan`, () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const seven = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7);
|
||||
assert.equal(seven.length, 7);
|
||||
const pieces = seven.map((c, i) => ({ uid: i + 1, kind: 'chess', id: c.chessId, items: [] }));
|
||||
pieces.push({ uid: 8, kind: 'chess', id: mu, items: [{ id: 'chess_item_6_09_e_a' }, { id: 'chess_item_3_04_e_a' }] });
|
||||
const state = { board: new Map(pieces.map((p, i) => [`${9 + i % 4},${3 + Math.floor(i / 4)}`, p])), hand: [], layers: {} };
|
||||
const b = computeBonds(gd, state);
|
||||
assert.equal(b.yanShip.count, 9); assert.equal(b.yanShip.tier, 3); assert.equal(b.yanShip.harmony, 1);
|
||||
pieces[7].items.pop();
|
||||
const withoutConversion = computeBonds(gd, state);
|
||||
assert.equal(withoutConversion.yanShip.count, 8); assert.equal(withoutConversion.yanShip.tier, 2);
|
||||
});
|
||||
@@ -221,7 +221,7 @@ test('onLeave: a quitter has no place in the Final Assault pairing or the boss p
|
||||
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.deepEqual(m.fields.map((f) => [...f.players].sort()), [[...alive].sort()], '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();
|
||||
|
||||
@@ -173,7 +173,9 @@ test('buy → 3 copies merge into the elite (the stock pays the copies); sell /
|
||||
for (let i = 0; i < 8; i++) gain(p0, T5A);
|
||||
assert.equal(p0.diyStock.left(T5A), 0);
|
||||
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: 4, frozen: false, sold: false };
|
||||
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'SOLD_OUT');
|
||||
assert.ok(m.handle('p_0', { t: 'g.buy', slot: 0 }).ok, 'a displayed operator remains purchasable after exhaustion');
|
||||
assert.equal(p0.allChess().filter(p => p0.gd.baseIdOf(p.id) === T5A).reduce((n, p) => n + p.poolCopies, 0), 9);
|
||||
assert.equal(p0.diyStock.left(T5A), 0, 'the extra copy does not reopen available stock');
|
||||
h.invariants();
|
||||
// reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level
|
||||
clear(p0);
|
||||
|
||||
@@ -103,15 +103,17 @@ test('shop slots per level (+ the item slot), buy prices by tier, SOLD_OUT, NO_F
|
||||
assert.equal(ps.hand[9].id, slot0.id, 'hand fills right→left');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.SOLD_OUT });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 15 }).error, ERR.BAD_TARGET);
|
||||
// a display is not a reservation: an empty pool makes the buy fail
|
||||
// a display is not a reservation, but its operator remains purchasable after the pool is exhausted (#498)
|
||||
const s1 = ps.shop.slots[1];
|
||||
const taken = m.pool.take(s1.id, 100);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.SOLD_OUT });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { ok: true });
|
||||
assert.equal(m.pool.entries.get(s1.id).left, -1);
|
||||
m.pool.give(s1.id, taken);
|
||||
// full hand blocks buys
|
||||
const fillers = chessOfTier(3).filter((x) => m.pool.has(x) && x !== s1.id);
|
||||
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i]);
|
||||
if (ps.countCopies(m.gd.baseIdOf(s1.id)) === 0) assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
|
||||
ps.shop.slots[1] = { ...s1, sold: false };
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
|
||||
const itemSlot = ps.shop.slots.findIndex((s) => s && s.kind === 'item');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: itemSlot }), { error: ERR.HAND_FULL });
|
||||
checkInvariants(m);
|
||||
|
||||
@@ -17,6 +17,9 @@ import { teamFrameIds } from '../../public/js/ui/gameLogic.js';
|
||||
function bossPrep(humans, seed) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans, seed, fake: true }).start();
|
||||
const m = h.m;
|
||||
// Explicit layout fixture: these cases test mirrored metadata, not the random draw (covered separately).
|
||||
const ids = m.order.map(p => p.playerId);
|
||||
m.bossPairs = [ids.slice(0, 2), ids.slice(2)].filter(g => g.length);
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
|
||||
for (const ps of m.players.values()) { ps.ready = false; for (const k of [...ps.board.keys()]) ps.board.delete(k); ps.recompute(); }
|
||||
m.flush(true);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { leaderStand } from '../../public/js/render/prepfield.js';
|
||||
function bossPrep(seed) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start();
|
||||
const m = h.m;
|
||||
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // explicit mirrored-layout fixture, not the pairing policy
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
|
||||
return h;
|
||||
}
|
||||
@@ -64,6 +65,7 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
|
||||
test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start();
|
||||
const m = h.m;
|
||||
m.bossPairs = [['p_0', 'p_1'], ['p_2']]; // keep this scout fixture's named left/right roles
|
||||
h.toPrep(1);
|
||||
h.ps('p_2').eliminate(1);
|
||||
h.toPrep(2);
|
||||
|
||||
@@ -101,6 +101,7 @@ test('client combat: an eliminated human is started on the field of the player i
|
||||
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
|
||||
const m = h.m;
|
||||
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // named watcher-role fixture; random assignment has its own tests
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||||
h.ps('p_3').eliminate(13);
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
|
||||
|
||||
@@ -6,15 +6,54 @@ import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible } from '../../s
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
|
||||
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
|
||||
|
||||
test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
|
||||
test('pairing is seeded, independent of input order, includes everyone once and can draw every partner', () => {
|
||||
const p = (seat) => ({ seat, playerId: `s${seat}` });
|
||||
assert.deepEqual(pairPlayers([p(0)]).map((g) => g.map((x) => x.seat)), [[0]]);
|
||||
assert.deepEqual(pairPlayers([p(2), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 2]]);
|
||||
assert.deepEqual(pairPlayers([p(3), p(1), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 1], [3]]);
|
||||
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
|
||||
const partners = new Set(), lone = new Set();
|
||||
for (let seed = 1; seed <= 64; seed++) for (const n of [1, 2, 3, 4]) {
|
||||
const alive = Array.from({ length: n }, (_, i) => p(i));
|
||||
const groups = pairPlayers(alive, createRng(seed));
|
||||
assert.deepEqual(groups, pairPlayers([...alive].reverse(), createRng(seed)));
|
||||
assert.equal(groups.length, Math.ceil(n / 2));
|
||||
assert.deepEqual(groups.flat().map(x => x.seat).sort(), alive.map(x => x.seat));
|
||||
if (n === 4) partners.add(groups.find(g => g.some(x => x.seat === 0)).find(x => x.seat !== 0).seat);
|
||||
if (n === 3) lone.add(groups.find(g => g.length === 1)[0].seat);
|
||||
}
|
||||
assert.deepEqual([...partners].sort(), [1, 2, 3]);
|
||||
assert.deepEqual([...lone].sort(), [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('previous boss partners and sides persist; only orphaned survivors form a new pair', () => {
|
||||
const all = [0, 1, 2, 3].map(seat => ({ seat, playerId: `s${seat}` }));
|
||||
const previous = [['s3', 's0'], ['s2', 's1']];
|
||||
const ids = groups => groups.map(g => g.map(p => p.playerId));
|
||||
assert.deepEqual(ids(pairPlayers(all, createRng(99), previous)), previous);
|
||||
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat !== 0), createRng(99), previous)), [['s2', 's1'], ['s3']]);
|
||||
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat > 1), createRng(99), previous)), [['s3', 's2']]);
|
||||
});
|
||||
|
||||
test('boss preview, actual fields and Hidden Core retain one per-match draw', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 4, seed: 31, fake: true,
|
||||
script: b => ['boss', 'hidden'].includes(b.kind) ? { bossDps: 1e9 } : {} }).start();
|
||||
try {
|
||||
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
|
||||
const pairs = structuredClone(h.m.bossPairs);
|
||||
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
|
||||
for (const p of h.m.order) {
|
||||
p.bondCountBonus.yanShip = 3; p.layers.yanShip = 400; p.recompute();
|
||||
const group = h.m.bossGroupOf(p);
|
||||
assert.equal(group.side, pairs.find(g => g.includes(p.playerId)).indexOf(p.playerId) === 0 ? 'L' : 'R');
|
||||
}
|
||||
h.drive(() => h.m.phase === PHASE.FINAL_ASSAULT);
|
||||
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
|
||||
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 15);
|
||||
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
|
||||
h.drive(() => h.m.phase === PHASE.HIDDEN_CORE);
|
||||
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
|
||||
} finally { h.m.dispose(); }
|
||||
});
|
||||
|
||||
test('boss pool = bloodPoint[difficulty] × the players alive at the fight\'s start (solo × 1) × tuning; config restores the fixed pool; shared and never negative', () => {
|
||||
@@ -200,22 +239,24 @@ test('Final Assault: a fighting player may only watch its own boss field; elimin
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||||
h.ps('p_3').eliminate(13);
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
|
||||
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b2' }).error, 'BAD_TARGET', 'the other group is hidden');
|
||||
assert.equal(m.handle('p_2', { t: 'g.watch', fieldId: 'b1' }).error, 'BAD_TARGET');
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
|
||||
const pair = m.fields.find(f => f.players.length === 2), lone = m.fields.find(f => f.players.length === 1);
|
||||
assert.deepEqual(m.fields.flatMap(f => f.players).sort(), ['p_0', 'p_1', 'p_2']);
|
||||
const [first, second] = pair.players, third = lone.players[0];
|
||||
assert.equal(m.handle(first, { t: 'g.watch', fieldId: lone.fieldId }).error, 'BAD_TARGET', 'the other group is hidden');
|
||||
assert.equal(m.handle(third, { t: 'g.watch', fieldId: pair.fieldId }).error, 'BAD_TARGET');
|
||||
assert.deepEqual(m.handle(second, { t: 'g.watch', fieldId: pair.fieldId }), { ok: true });
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b2' }), { ok: true }, 'an eliminated player spectates freely');
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
|
||||
assert.equal(m.watchers.get('p_0'), 'b1', 'the refused watch keeps the default (own) field');
|
||||
assert.equal(m.watchers.get(first), pair.fieldId, 'the refused watch keeps the default (own) field');
|
||||
// the prep-scouting id of a player of the other group is no way around the rule, and never detaches the viewer
|
||||
const scouted = h.allTo('p_0', 'm.field').length;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'BAD_TARGET', detail: 'no such field' });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
|
||||
assert.equal(h.allTo('p_0', 'm.field').length, scouted, 'no board of the other group was sent');
|
||||
assert.equal(m.watchers.get('p_0'), 'b1', 'still streaming the own boss field');
|
||||
const snaps = h.allTo('p_0', 'b.snap').length;
|
||||
const scouted = h.allTo(first, 'm.field').length;
|
||||
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${third}` }), { error: 'BAD_TARGET', detail: 'no such field' });
|
||||
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${second}` }), { error: 'BAD_TARGET', detail: 'no such field' });
|
||||
assert.equal(h.allTo(first, 'm.field').length, scouted, 'no board of the other group was sent');
|
||||
assert.equal(m.watchers.get(first), pair.fieldId, 'still streaming the own boss field');
|
||||
const snaps = h.allTo(first, 'b.snap').length;
|
||||
h.sched.advance(500);
|
||||
assert.ok(h.allTo('p_0', 'b.snap').length > snaps, 'b.snap of b1 keeps coming');
|
||||
assert.ok(h.allTo(first, 'b.snap').length > snaps, 'snapshots of the own field keep coming');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -43,8 +43,9 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
|
||||
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 side = f.opts.players.find(p => p.playerId === s.ownerPlayerId)?.side;
|
||||
assert.ok(side === 'L' || side === 'R', 'bounty has an actual field owner');
|
||||
assert.ok(side === 'L' ? rt.end[1] < 10 : rt.end[1] > 10, `owner's ${side} bounty goes to its own goal (${rt.end})`);
|
||||
}
|
||||
const pending = p0.pendingFunds;
|
||||
const end = h.runToEnd();
|
||||
|
||||
@@ -156,14 +156,16 @@ test('merge whose copies are not deployed (temp) with a full hand overflows the
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('reward pick respects the pool and a full hand; offers expire at prep end', () => {
|
||||
test('displayed operator rewards survive stock exhaustion but respect a full hand and expire at prep end', () => {
|
||||
const { h, m, ps } = prep(24);
|
||||
ps.pushRewardOffer('merge');
|
||||
const offer = ps.offers[0];
|
||||
const id0 = offer.slots[0].id;
|
||||
const taken = m.pool.take(id0, 100);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.SOLD_OUT });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { ok: true });
|
||||
assert.equal(m.pool.entries.get(id0).left, -1);
|
||||
m.pool.give(id0, taken);
|
||||
ps.pushRewardOffer('merge', { ids: [id0] });
|
||||
const others = chessOfTier(3).filter((c) => m.pool.has(c) && !offer.slots.some((s) => s.id === c));
|
||||
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL });
|
||||
|
||||
@@ -145,13 +145,14 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
|
||||
h.setStage(STAGE);
|
||||
h.toPrep(14);
|
||||
const m = h.m;
|
||||
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossR');
|
||||
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossL', 'the odd player alone on the left half');
|
||||
m.onLeave('p_0');
|
||||
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossL');
|
||||
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossR');
|
||||
const [left, right] = m.bossPairs.find(g => g.length === 2), lone = m.bossPairs.find(g => g.length === 1)[0];
|
||||
assert.equal(m.deployFieldOf(h.ps(right)), 'bossR');
|
||||
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossL', 'the odd player alone on the left half');
|
||||
m.onLeave(left);
|
||||
assert.equal(m.deployFieldOf(h.ps(right)), 'bossL');
|
||||
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossR');
|
||||
// re-checked at once (Match._quit recomputes the players left), not at their next action
|
||||
for (const [id, field] of [['p_1', 'bossL'], ['p_2', 'bossR']]) {
|
||||
for (const [id, field] of [[right, 'bossL'], [lone, 'bossR']]) {
|
||||
assert.equal(h.ps(id)._deployField, field, `${id} board re-checked on ${field}`);
|
||||
assert.equal(h.ps(id)._legalityStale, false);
|
||||
}
|
||||
|
||||
+20
-2
@@ -123,10 +123,10 @@ test('effect-only items are never shop items: not in shopItemsByTier, never in t
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
|
||||
test('维多利亚 25-layer reward: the 4 special 维式重锤 (and nothing else)', () => {
|
||||
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
|
||||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||||
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
|
||||
for (const pid of ['pool_equip_vict']) {
|
||||
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
|
||||
@@ -135,6 +135,24 @@ test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('洛洛 and 焰尾 use the measured eight-item and 10/10/3 subsets; exhausted shop items are excluded', () => {
|
||||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||||
const rock = DATA.choices.pools.pool_equip_rockr.weighted;
|
||||
assert.deepEqual(rock.map(([id, n]) => [m.gd.item(id).name, n]), [
|
||||
['维式重锤', 10], ['战栗维式重锤', 2], ['坚固维式重锤', 2], ['加速维式重锤', 2], ['灼燃维式重锤', 2],
|
||||
['有限加速器', 1], ['护盾无人机', 4], ['激光发射器', 4],
|
||||
]);
|
||||
assert.deepEqual(DATA.choices.pools.pool_char_pinus.weighted.map(([id, n]) => [m.gd.chess(id).name, n]), [['野鬃', 10], ['灰毫', 10], ['远牙', 3]]);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 300; i++) seen.add(m.rollPool('pool_equip_rockr').id);
|
||||
assert.deepEqual([...seen].sort(), rock.map(x => x[0]).sort());
|
||||
const normal = rock[0][0], ps = m.order[0];
|
||||
for (let i = 0; i < m.itemPool.cap(normal); i++) assert.ok(ps.acquireItem(normal));
|
||||
assert.equal(m.itemPool.left(normal), 0);
|
||||
for (let i = 0; i < 100; i++) assert.notEqual(m.rollPool('pool_equip_rockr').id, normal);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
|
||||
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
|
||||
const gd = new GameData(DATA, modeId);
|
||||
|
||||
@@ -7,6 +7,33 @@ import { cardPickable } from '../../public/js/ui/choiceOverlay.js';
|
||||
|
||||
const PACK = 'chess_item_5_07_e_a';
|
||||
const byName = name => Object.values(DATA.chess).find(c => !c.isGolden && c.name === name).chessId;
|
||||
|
||||
test('four already displayed operator offers may exceed stock; selling first repays the deficit', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 4, seed: 11, fake: true }).start().toPrep();
|
||||
const m = h.m, id = byName('溯光星源'), ps = [...m.players.values()];
|
||||
assert.equal(m.pool.cap(id), 10);
|
||||
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) ps[i].acquireChess(id);
|
||||
assert.equal(m.pool.left(id), 1);
|
||||
for (const p of ps) {
|
||||
p.funds = 100;
|
||||
p.shop.slots[0] = { kind: 'chess', id, basePrice: 3, frozen: true, sold: false };
|
||||
}
|
||||
assert.equal(m.pool.left(id), 1, 'displaying an offer does not reserve stock');
|
||||
for (const p of ps) {
|
||||
const funds = p.funds; assert.ok(p.buy(0).ok, 'every pre-existing offer stays purchasable');
|
||||
assert.equal(p.funds, funds - 3); assert.equal(p.shop.slots[0].sold, true);
|
||||
assert.equal(p.buy(0).error, 'SOLD_OUT', 'the same offer cannot be bought twice');
|
||||
}
|
||||
assert.equal(m.pool.entries.get(id).left, -3); assert.equal(m.pool.left(id), 0);
|
||||
assert.equal(m.pool.roll(m.rngShop, { tier: 6, filter: candidate => candidate === id }), null);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const piece = ps[i].allChess().find(c => c.id === id); assert.ok(piece);
|
||||
assert.ok(ps[i].sell(piece.uid).ok);
|
||||
assert.equal(m.pool.left(id), Math.max(0, i - 2));
|
||||
checkInvariants(m);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
test('a duplicate draft card becomes unavailable when the preceding player exhausts equipment stock', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
|
||||
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
|
||||
@@ -29,6 +56,17 @@ test('measured operator capacities and exceptions: co-op doubles shared stock, n
|
||||
assert.equal(solo.itemPoolCopies(PACK), 2); assert.equal(coop.itemPoolCopies(PACK), 2);
|
||||
});
|
||||
|
||||
test('an operator reward shown before stock exhaustion still arrives and records its copy', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
|
||||
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1'), id = byName('溯光星源');
|
||||
a.pushRewardOffer('test', { ids: [id] });
|
||||
for (let i = 0; i < m.pool.cap(id); i++) b.acquireChess(id);
|
||||
assert.equal(m.pool.left(id), 0);
|
||||
assert.ok(a.pickReward(0).ok); assert.equal(a.offers.length, 0);
|
||||
assert.ok(a.allChess().some(c => c.id === id));
|
||||
assert.equal(m.pool.entries.get(id).left, -1); checkInvariants(m); m.dispose();
|
||||
});
|
||||
|
||||
test('shared equipment occupancy includes hand/equipped/elite, never displayed offers; destroying frees stock', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
|
||||
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const MEDICS = new Set(['char_605_cmedic', 'char_613_acmedc']);
|
||||
const medics = b => b.allyUnits.filter(u => MEDICS.has(u.defId) && u.alive && !u.removed);
|
||||
const clean = () => ({ leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 });
|
||||
function setup(t) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334401, fake: true }).start().toPrep();
|
||||
t.after(() => h.m.dispose()); h.setStage('act2autochess_m01');
|
||||
for (const p of h.m.order) p.bandId = null;
|
||||
h.ps('p_0').bandId = 'band_amedic'; return h;
|
||||
}
|
||||
function field(h, elites = 0) {
|
||||
const opts = h.m._normalOpts(h.ps('p_1'));
|
||||
opts.players[0].units = ['chess_char_1_01_b', 'chess_char_1_02_b'].slice(0, elites)
|
||||
.map((chessId, i) => ({ uid: 100 + i, kind: 'chess', chessId, row: 10 + i, col: 4 }));
|
||||
const b = new Battle({ ...opts, data: getDefaultSource(), spawns: [], timeLimit: 300, autoFinish: false });
|
||||
b.start(); assert.equal(b.errorCount, 0); return b;
|
||||
}
|
||||
|
||||
for (const reason of ['eliminated', 'quit']) for (const elites of [0, 2]) {
|
||||
test(`Touch team grant survives holder ${reason}, receiver elites=${elites}`, t => {
|
||||
const h = setup(t), before = field(h, elites), expected = elites ? 'char_613_acmedc' : 'char_605_cmedic';
|
||||
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
|
||||
if (reason === 'quit') h.m.onLeave('p_0');
|
||||
else {
|
||||
h.ps('p_0').lp = 1;
|
||||
h.m.lastResults = new Map(h.m.order.map(p => [p.playerId, clean()]));
|
||||
h.m.lastResults.set('p_0', { ...clean(), perfect: false, leaked: [{ enemyKey: 'enemy_1422_lrsldr', counted: true }] });
|
||||
h.m.settle(null, null); h.toPrep(2); h.setStage('act2autochess_m01');
|
||||
}
|
||||
assert.equal(h.ps('p_0').alive, false);
|
||||
assert.equal(h.ps('p_1').privateView().prepMapChars.length, 1);
|
||||
const after = field(h, elites);
|
||||
assert.deepEqual(medics(after).map(u => u.defId), [expected]);
|
||||
assert.equal(after.players[0].input.contentInfo.teammates.some(p => p.playerId === 'p_0'), false,
|
||||
'retaining Touch does not revive dead teammate contexts');
|
||||
for (let i = 0; i < 30; i++) before.step();
|
||||
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
|
||||
h.ps('p_0').bandId = 'band_amiya';
|
||||
assert.equal(field(h).players[0].input.contentInfo.matchBands.includes('band_amiya'), false,
|
||||
'other eliminated strategies are not retained by this exception');
|
||||
});
|
||||
}
|
||||
|
||||
test('Touch requires a confirmed selection and does not leak to another match', t => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
|
||||
t.after(() => h.m.dispose()); h.drive(() => h.m.phase === 'BAND_DRAFT', { ready: false });
|
||||
const holder = h.m.players.get(h.m.draftTurn());
|
||||
assert.equal(h.m.bandFocus(holder, 'band_amedic').ok, true);
|
||||
assert.equal(h.m.teamMedicGranted(), false);
|
||||
assert.equal(h.m.pickBand(holder, 'band_amedic').ok, true);
|
||||
h.m.onLeave(holder.playerId); assert.equal(h.m.teamMedicGranted(), true);
|
||||
const next = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
|
||||
t.after(() => next.m.dispose()); assert.equal(next.m.teamMedicGranted(), false);
|
||||
});
|
||||
@@ -20,8 +20,10 @@ import { createBattleRunner } from '../../public/js/battle/runner.js';
|
||||
import { createStore, initialState } from '../../public/js/store.js';
|
||||
import * as specMod from '../../server/sim/spec.js';
|
||||
import { DataSource } from '../../server/sim/simdata.js';
|
||||
import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||
import { DATA, makeMatch, checkInvariants, legalTileFor } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { ownerBoard } from '../../public/js/ui/watchBonds.js';
|
||||
import { bondMembers, diyGetter, diyRecordFor, memberHeadCount } from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid);
|
||||
const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null;
|
||||
@@ -273,3 +275,94 @@ test('runner: a battle that disables gains (联防 / boss) publishes no live lay
|
||||
assert.deepEqual(r.store.get().match.battle.bondLayers, {});
|
||||
r.runner.dispose();
|
||||
});
|
||||
|
||||
const DIY_SLOT = 'chess_char_5_diy1_a';
|
||||
const HUANG_PICK = { charId: 'char_017_huang', skillIndex: 1, uniEquipId: 'uniequip_002_huang' };
|
||||
const SIEGE_PICK = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
const DIY_DATA = { chess: DATA.chess, backups: DATA.backups };
|
||||
const getChess = (id) => DATA.chess[id] || null;
|
||||
|
||||
/** The same record lookups as BondPopup: own picks from m.private, a teammate's from each field unit. */
|
||||
function diyRows(bondId, priv) {
|
||||
const get = diyGetter(getChess, priv, DIY_DATA);
|
||||
const record = (p) => (p.diy ? diyRecordFor(getChess(p.id), p.diy, DIY_DATA) : null) || get(p.id);
|
||||
return bondMembers(DATA.bonds[bondId], priv, [], get, () => null, record);
|
||||
}
|
||||
|
||||
/** Controlled match fixture: legal seat picks, acquire/merge and g.move; the match builds the unmodified b.start. */
|
||||
function diyWatchMatch(elite) {
|
||||
const seats = [SIEGE_PICK, HUANG_PICK].map((pick, seat) => ({
|
||||
seat, playerId: `p_${seat}`, name: `P${seat}`, isBot: false, connected: true, diy: { [DIY_SLOT]: pick },
|
||||
}));
|
||||
const h = makeMatch({ mode: 'coop', seats, spectators: ['s_spec'], seed: 7309, clientCombat: true, clients: false }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_1');
|
||||
let piece;
|
||||
for (let i = 0; i < (elite ? 3 : 1); i++) piece = ps.acquireChess(DIY_SLOT);
|
||||
assert.ok(piece);
|
||||
const tile = legalTileFor(h.m, ps, piece.id);
|
||||
assert.ok(tile);
|
||||
assert.deepEqual(h.m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
return h;
|
||||
}
|
||||
|
||||
for (const elite of [false, true]) test(`runner: watched ${elite ? 'elite' : 'normal'} DIY operator is a bond member before and after deployment`, async (t) => {
|
||||
const h = diyWatchMatch(elite);
|
||||
t.after(() => h.m.dispose());
|
||||
const assertHuang = (priv) => {
|
||||
for (const bondId of ['yanShip', 'victoriaShip']) {
|
||||
const rows = diyRows(bondId, priv);
|
||||
assert.equal(memberHeadCount(rows, false), 1, `${bondId}: the fielded DIY member counts once`);
|
||||
const row = rows.find((x) => x.diy);
|
||||
assert.deepEqual([row.id, row.rec.charId, row.name, row.onBoard, row.rec.isGolden], [DIY_SLOT, HUANG_PICK.charId, '煌', true, false]);
|
||||
assert.deepEqual(row.pick, HUANG_PICK, 'the watched owner\'s skill and module, not our pick in the same slot');
|
||||
}
|
||||
};
|
||||
assertHuang(ownerBoard(h.m.prepFieldMeta(h.ps('p_1')), 'p_1'));
|
||||
assert.deepEqual(diyRows('victoriaShip', h.ps('p_0').privateView()).filter((x) => x.diy).map((x) => [x.rec.charId, x.owned]), [[SIEGE_PICK.charId, false]], 'our unbought pick stays our own');
|
||||
assert.equal(diyRows('yanShip', h.ps('p_0').privateView()).some((x) => x.diy), false);
|
||||
for (const pid of ['p_0', 'p_1']) assert.deepEqual(h.m.handle(pid, { t: 'g.ready', ready: true }), { ok: true });
|
||||
h.runToPhase(PHASE.COMBAT, 1);
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
|
||||
assert.deepEqual(h.m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
const start = JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start')));
|
||||
assert.deepEqual([start.watch, start.authoritative, start.elapsed], [true, false, 0]);
|
||||
assert.equal(start.spec.players[0].units[0].chessId, elite ? DIY_SLOT.replace(/_a$/, '_b') : DIY_SLOT);
|
||||
assert.deepEqual(start.spec.players[0].units[0].diy, HUANG_PICK);
|
||||
const r = rig();
|
||||
t.after(() => r.runner.dispose());
|
||||
r.net.emit('b.start', start);
|
||||
await r.settle();
|
||||
const field = r.store.get().match.field;
|
||||
assert.equal(field.units.some((u) => u.kind === 'op'), false, 'the initial meta precedes deployment');
|
||||
const board = () => ownerBoard(field, 'p_1', r.runner.ownerOps('p_1', start.fieldId));
|
||||
t.diagnostic(JSON.stringify({ elite, specPick: start.spec.players[0].units[0].diy, initialMetaOps: field.units.filter((u) => u.kind === 'op').length, runnerOps: r.runner.ownerOps('p_1'), yanMembers: memberHeadCount(diyRows('yanShip', board()), false) }));
|
||||
assertHuang(board());
|
||||
r.advance(20000, 50);
|
||||
const unit = r.runner._entries.get(start.battleId).battle.allyUnits.find((u) => u.kind === 'op');
|
||||
assert.ok(unit.deployed, 'the real simulation deployed the operator');
|
||||
assertHuang(board());
|
||||
const op = r.runner.ownerOps('p_1')[0];
|
||||
assert.notEqual(op.diy, unit.def.loadout.diy, 'the popup receives a copy of the frozen sim pick');
|
||||
op.diy.charId = SIEGE_PICK.charId;
|
||||
assert.deepEqual(r.runner.ownerOps('p_1')[0].diy, HUANG_PICK, 'a consumer cannot change the battle or the next read');
|
||||
assert.deepEqual(r.runner.ownerOps('p_0'), [], 'another owner');
|
||||
assert.deepEqual(r.runner.ownerOps('p_1', 'n:p_0'), [], 'another field');
|
||||
// A fresh runner after a spectator reconnect, and switching back from another field, retain the member too.
|
||||
const starts = h.allTo('s_spec', 'b.start').length;
|
||||
h.sched.advance(1000);
|
||||
h.m.addSpectator('s_spec');
|
||||
assert.equal(h.allTo('s_spec', 'b.start').length, starts + 1, 'the spectator reconnect emits a fresh b.start');
|
||||
assert.ok(h.lastTo('s_spec', 'b.start').elapsed > 0, 'the resumed replica catches up to the match clock');
|
||||
const resumed = rig();
|
||||
t.after(() => resumed.runner.dispose());
|
||||
resumed.net.emit('b.start', JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start'))));
|
||||
await resumed.settle();
|
||||
assertHuang(ownerBoard(resumed.store.get().match.field, 'p_1', resumed.runner.ownerOps('p_1')));
|
||||
r.net.emit('b.start', h.lastTo('p_0', 'b.start'));
|
||||
await r.settle();
|
||||
assert.deepEqual(r.runner.ownerOps('p_1'), [], 'switching fields drops the other field\'s members');
|
||||
r.net.emit('b.start', start);
|
||||
await r.settle();
|
||||
assertHuang(ownerBoard(r.store.get().match.field, 'p_1', r.runner.ownerOps('p_1')));
|
||||
});
|
||||
Reference in new issue
Block a user