Files
ark-stronghold-protocol/test/match/connection.test.js
T
Sukvii 56a7b918a9 feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move
Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md
§2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess
choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The
owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules.
Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 /
险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a
reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without
a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while
it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty
scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback
picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over
a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer
goes with it; invariants.js flags an offer outside its owner's own open prep.
Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits
of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys.
Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs
121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs
121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario
and family is unchanged; node tools/golden.mjs --twice is identical.
Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps
the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before
endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline
player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's
pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js,
unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason,
ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
2026-10-08 20:45:54 +08:00

347 lines
18 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, giveItem, 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.personalChoice, 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('教鞭: simultaneous private choices survive reconnect; foreign, duplicate and old IDs cannot add bounties', () => {
const h = makeMatch({ mode: 'coop', humans: 2, spectators: ['watcher'], seed: 63, fake: true }).start();
h.toPrep(1);
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
for (const ps of [a, b]) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
}
const aChoice = a.privateView().personalChoice, bChoice = b.privateView().personalChoice;
assert.notEqual(aChoice.id, bChoice.id);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: bChoice.id }).error, ERR.BAD_TARGET);
assert.equal(a.bounties.length + b.bounties.length, 0);
m.onDisconnect('p_0');
m.onReconnect('p_0');
assert.deepEqual(h.lastTo('p_0', 'm.private').personalChoice, aChoice);
m.flush(true);
assert.deepEqual(h.lastTo('p_1', 'm.private').personalChoice, bChoice);
assert.ok(!h.allTo('watcher', 'm.private').length);
assert.ok(!JSON.stringify(m.publicView()).includes(aChoice.id));
assert.ok(!JSON.stringify(m.prepFieldMeta(a)).includes(aChoice.id));
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }), { ok: true });
m.flush(true);
assert.equal(h.lastTo('p_0', 'm.private').personalChoice, null);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
const next = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(next.uid, 10, 5), { ok: true });
const nextId = a.personalChoice.id;
assert.notEqual(nextId, aChoice.id);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
assert.equal(a.personalChoice.id, nextId);
assert.equal(a.bounties.length, 1);
m.onDisconnect('p_1');
h.sched.advance(m.deadline - h.sched.now() + 1);
m.onReconnect('p_1');
assert.equal(h.lastTo('p_1', 'm.private').personalChoice, null);
assert.equal(b.bounties.length, 1, 'offline choice picked at the existing PREP deadline');
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();
});