Files
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

226 lines
12 KiB
JavaScript
Raw Permalink 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.
// Effect registry & dispatcher (effectsMeta.js): keys, hook routing, garrison eventType mapping, 投资人 repeat,
// ctx helpers, error isolation, depth cap, content override of built-ins.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MetaRegistry, createRegistry, HOOKS, GARRISON_HOOK, MAX_DEPTH } from '../../server/match/effectsMeta.js';
import { registerBuiltins, BUILTIN_EFFECT_KEYS } from '../../server/match/builtinMeta.js';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
test('registry: key validation, function sugar, last registration wins, helpers', () => {
const reg = new MetaRegistry();
assert.throws(() => reg.register('nope', {}), TypeError);
assert.throws(() => reg.register('band:', {}), TypeError);
assert.throws(() => reg.register('band:x', 5), TypeError);
reg.register('band:x', () => 1);
assert.equal(typeof reg.get('band:x').run, 'function');
const h2 = { onBuy() {} };
reg.band('x', h2);
assert.equal(reg.get('band:x'), h2);
reg.item('chess_item_1_03_e_b', { onEquip() {} });
assert.ok(reg.has('item:chess_item_1_03_e'), 'item keys drop the _a/_b suffix');
reg.global('g', {});
assert.equal(reg.globals().length, 1);
assert.equal(reg.get('band:none'), null);
assert.ok(HOOKS.includes('onBattleStart') && HOOKS.includes('onChoicePick'));
assert.equal(GARRISON_HOOK.SERVER_PREP_FIN, 'onPrepEnd');
});
test('built-ins register consume-on-equip items by buff key; createRegistry = built-ins + content', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
for (const k of ['item:chess_item_1_03_e', 'item:chess_item_3_12_e', 'item:chess_item_1_04_e', 'item:chess_item_5_06_e', 'item:chess_item_6_08_e', 'item:chess_item_6_02_m']) assert.ok(reg.has(k), k);
for (const k of BUILTIN_EFFECT_KEYS) assert.ok(reg.has(k), k);
const full = createRegistry();
assert.ok(full.has('item:chess_item_1_03_e'));
});
function metaMatch(reg, o = {}) {
const h = makeMatch({ mode: 'solo', seed: o.seed ?? 70, registry: reg, fake: true, ...o }).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.funds = 50;
ps.recompute();
return { h, m: h.m, ps };
}
test('dispatch order and hooks: global → band → bond → garrison → item → effect; onPrice/onSold/onBuy/onRefresh/onLevelUp', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
const calls = [];
reg.global('spy', { onBuy: () => calls.push('global'), onRefresh: () => calls.push('refresh'), onLevelUp: (ctx, ev) => calls.push(`level${ev.level}`) });
reg.band('band_bldsk', { onBuy: () => calls.push('band') });
reg.bond('yanShip', { onBuy: () => calls.push('bond') });
reg.effect('spy', { onBuy: (ctx) => calls.push(`effect:${ctx.source.ref.id}`) });
// a price modifier (content e.g. 休露丝): chess slots cost 1
reg.global('price', { onPrice: (ctx, ev) => { if (ev.kind === 'chess') { ctx.modifyPrice(+5); ctx.setPrice(1); } } });
// selling pays 3 instead of 1
reg.global('sell', { onSold: (ctx, ev) => { ev.gain = 3; } });
const { m, ps } = metaMatch(reg);
ps.effects.push({ id: 'e1', key: 'effect:spy', name: 'spy', battle: false });
const slot = ps.shop.slots.findIndex((s) => s && s.kind === 'chess');
assert.equal(ps.privateView().shop.slots[slot].price, 1, 'onPrice is reflected in the view');
const funds = ps.funds;
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot }), { ok: true });
assert.equal(ps.funds, funds - 1);
assert.deepEqual(calls, ['global', 'band', 'bond', 'effect:e1']);
const piece = ps.hand.find(Boolean);
const f2 = ps.funds;
m.handle('p_0', { t: 'g.sell', uid: piece.uid });
assert.equal(ps.funds, f2 + 3);
m.handle('p_0', { t: 'g.refresh' });
m.handle('p_0', { t: 'g.levelUp' });
assert.ok(calls.includes('refresh') && calls.includes('level2'));
m.dispose();
});
test('garrisons fire on their eventType only; hand pieces skip character_target_inboard; 投资人 doubles 获得时', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
const fired = [];
const gains = [];
const gainG = Object.values(DATA.garrisons).find((g) => g.eventType === 'SERVER_GAIN' && g.owners.some((o) => DATA.chess[o] && DATA.chess[o].visible));
const finG = Object.values(DATA.garrisons).find((g) => g.eventType === 'SERVER_PREP_FIN' && g.bbStr && g.bbStr.conditionkey === 'character_target_inboard' && g.owners.some((o) => DATA.chess[o] && DATA.chess[o].visible && !DATA.chess[o].isGolden));
reg.garrison(gainG.effectKey, { run: (ctx, ev) => gains.push([ctx.source.garrisonId, ev.piece.id]) });
reg.garrison(finG.effectKey, { onPrepEnd: (ctx) => fired.push([ctx.source.garrisonId, ctx.source.where]) });
const { h, m, ps } = metaMatch(reg, { seed: 71 });
const owner = gainG.owners.find((o) => DATA.chess[o] && DATA.chess[o].visible && !DATA.chess[o].isGolden) || gainG.owners[0];
ps.acquireChess(owner, { source: 'test' });
const mine = gains.filter(([gid]) => DATA.chess[owner].garrisonIds.includes(gid));
assert.ok(mine.length >= 1, 'SERVER_GAIN garrison ran on gain');
gains.length = 0;
// 投资人 active → ×2
ps.bondCountBonus.investShip = 3;
ps.recompute();
assert.equal(ps.bonds.investShip.active, true);
const base = DATA.chess[owner].baseId;
if (ps.countCopies(base) < 2) {
ps.acquireChess(owner, { source: 'test' });
assert.equal(gains.filter(([gid]) => DATA.chess[owner].garrisonIds.includes(gid)).length, 2 * mine.length, '获得时 ×2');
}
ps.layers.investShip = 100;
ps.recompute();
assert.equal(m.dispatcher.investRepeat(ps), 3);
// prep-end garrison: board yes, hand no (inboard condition)
const finOwner = finG.owners.find((o) => DATA.chess[o] && DATA.chess[o].visible && !DATA.chess[o].isGolden);
for (const p of ps.hand) if (p) ps.returnCopies(p);
ps.hand.fill(null);
const onHand = give(m, ps, finOwner);
m.dispatch(ps, 'onPrepEnd', {});
assert.equal(fired.length, 0, 'inboard garrison does not fire from the hand');
const tile = [...ps.deployMap()].find(([, v]) => v === 'melee' || DATA.chess[finOwner].position === 'RANGED')[0].split(',').map(Number);
ps.hand[ps.hand.indexOf(onHand)] = null;
ps.board.set(`${tile[0]},${tile[1]}`, onHand);
m.dispatch(ps, 'onPrepEnd', {});
assert.deepEqual(fired.map((x) => x[1]), ['board']);
void h;
m.dispose();
});
test('ctx helpers: funds, layers (requireActive), grants (pool-aware), effects with counters, teammates, errors isolated', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
let captured = null;
reg.global('ctx', { onRoundStart: (ctx) => { captured = ctx; } });
reg.global('boom', { onRefresh: () => { throw new Error('content bug'); } });
reg.global('loop', { onGain: (ctx) => { ctx.grantItem('chess_item_1_01_e_a'); } });
const h = makeMatch({ mode: 'coop', humans: 2, seed: 72, registry: reg, fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
assert.ok(captured, 'onRoundStart ran');
const ctx = m.dispatcher && (() => { let c = null; reg.global('grab', { onLevelUp: (x) => { c = x; } }); ps.funds = 50; m.handle('p_0', { t: 'g.levelUp' }); return c; })();
assert.equal(ctx.playerId, 'p_0');
const f = ps.funds;
ctx.addFunds(3);
assert.equal(ps.funds, f + 3);
ctx.addFunds(-1000);
assert.equal(ps.funds, 0, 'funds never go negative');
assert.equal(ctx.addLayers('yanShip', 5, { requireActive: true }), 0, 'inactive bond: no gain');
assert.equal(ctx.addLayers('yanShip', 5), 5);
assert.equal(ctx.layers('yanShip'), 5);
assert.equal(ctx.addLayers('nope', 5), 0);
const id = chessOfTier(4).find((c) => m.pool.has(c));
const left = m.pool.left(id);
const got = ctx.grantChess(id);
assert.ok(got && got.id === id);
assert.equal(m.pool.left(id), left - 1);
const drained = m.pool.take(id, 100);
assert.equal(ctx.grantChess(id), null, 'pool empty → the grant fails');
m.pool.give(id, drained);
assert.equal(ctx.grantChess('not_a_chess'), null);
ctx.grantFreeRefresh(2);
assert.equal(ps.shop.freeRefreshes, 2);
const ref = ctx.addEffect({ id: 'x1', name: '测试', desc: 'd', iconKind: 'team', counter: 3 });
assert.equal(ctx.effect('x1'), ref);
assert.ok(ps.privateView().effects.some((e) => e.id === 'x1' && e.counter === 3));
ctx.setEffectCounter('x1', 1);
assert.equal(ref.counter, 1);
assert.ok(ctx.removeEffect('x1'));
assert.equal(ctx.teammates().length, 1);
assert.equal(ctx.teammates()[0].playerId, 'p_1');
ctx.teammates()[0].addFunds(2);
// a throwing handler never breaks the intent
const errs = m.dispatcher.errors;
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { ok: true });
assert.ok(m.dispatcher.errors > errs);
// recursive grants are bounded by the depth cap (onGain → grantItem → onGain …)
ctx.grantItem('chess_item_1_01_e_a');
assert.ok(MAX_DEPTH >= 4);
checkInvariants(m);
m.dispose();
});
test('content overrides a built-in by registering the same key; consume items without a handler are rejected cleanly', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
reg.item('chess_item_1_03_e', { onEquip: (ctx) => ctx.addFunds(100) });
const { m, ps } = metaMatch(reg, { seed: 73 });
const a = give(m, ps, chessOfTier(1).find((c) => m.pool.has(c)));
const coin = giveItem(m, ps, 'chess_item_1_03_e_a');
ps.funds = 0;
m.handle('p_0', { t: 'g.equip', itemUid: coin.uid, targetUid: a.uid });
assert.equal(ps.funds, 100);
reg.unregister('item:chess_item_4_01_e');
const pager = giveItem(m, ps, 'chess_item_4_01_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: pager.uid, targetUid: a.uid }), { error: 'BAD_TARGET', detail: 'effect not available' });
assert.ok(ps.find(pager.uid), 'the item stays in the hand');
m.dispose();
});
test('onBattleStart may edit the PlayerBattleInput; onBattleResult sees the result', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
const results = [];
reg.global('battle', {
onBattleStart: (ctx, ev) => { ev.input.playerEffects.push({ id: 'meta-test', key: null, source: 'team', params: { x: 1 } }); },
onBattleResult: (ctx, ev) => results.push([ctx.playerId, ev.lpLoss, ev.perfect]),
});
const { h, m } = metaMatch(reg, { seed: 74 });
h.drive(() => m.phase === PHASE.SETTLE);
const { FakeBattle } = awaitFake;
const b = FakeBattle.instances.find((x) => x.kind === 'normal');
assert.ok(b.opts.players[0].playerEffects.some((e) => e.id === 'meta-test'));
assert.deepEqual(results, [['p_0', 0, true]]);
m.dispose();
});
const awaitFake = await import('./fakeBattle.js');
test('choice:<id> handler overrides the family default, runs once, and other sources observe the pick', async () => {
const { applyCard } = await import('../../server/match/choices.js');
const reg = registerBuiltins(new MetaRegistry(), DATA);
let own = 0;
const seen = [];
reg.choice('allybuff_select_3', { onChoicePick: (ctx, ev) => { own++; ctx.addEffect({ id: 'wealth', key: 'choice:allybuff_select_3', name: '财富', battle: false }); } });
reg.global('obs', { onChoicePick: (ctx, ev) => seen.push([ctx.playerId, ev.card.id, ev.forTeammate]) });
const h = makeMatch({ mode: 'coop', humans: 2, seed: 75, registry: reg, fake: true }).start();
h.toPrep(1);
const m = h.m;
const a = h.ps('p_0');
const funds = a.funds;
applyCard(m, a, { kind: 'tactic', id: 'allybuff_select_3', name: '财富', desc: '', team: true, family: 'tactic', idx: 0 });
assert.equal(own, 2, 'picker + teammate (team card), once each');
assert.equal(a.funds, funds, 'the default (+1 fund) was replaced by the handler');
assert.deepEqual(seen, [['p_0', 'allybuff_select_3', false], ['p_1', 'allybuff_select_3', true]]);
assert.ok(a.effects.some((e) => e.id === 'wealth'));
m.dispose();
});