Files
ark-stronghold-protocol/test/match/bot-emotes.test.js
T
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

99 lines
5.8 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import * as BE from '../../server/match/botEmotes.js';
import { EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
import { makeMatch, give, giveItem, DATA } from './harness.js';
const E = (name) => `autochess_battle_${name}`;
function fixture() {
const human = { playerId: 'human', isBot: false, alive: true };
const ai = { playerId: 'ai', isBot: true, alive: true, lastEmoteAt: -Infinity };
const other = { playerId: 'other', isBot: true, alive: true, lastEmoteAt: -Infinity };
const messages = [];let now = 10000;
const m = { round: 1, order: [human, ai, other], lastResults: new Map(), sched: { now: () => now },
alivePlayers: () => m.order.filter((p) => p.alive), broadcast: (x) => messages.push(x) };
return { m, human, ai, other, messages, advance: () => { now += EMOTE_COOLDOWN_MS + 1; } };
}
test('AI emotes use the existing whitelist and cooldown, never impersonate a human', () => {
const { m, ai, human, messages, advance } = fixture();
assert.equal(BE.sendEmote(m, human, E('thanks')), false);
assert.equal(BE.sendEmote(m, ai, 'bad'), false);
assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
ai.alive = false;advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
assert.equal(messages.length, 2);
});
test('one eligible AI answers a human, cooling seats are skipped; AI messages do not recurse', () => {
const { m, human, ai, other, messages } = fixture();ai.lastEmoteAt = m.sched.now();
assert.equal(BE.onHumanEmote(m, human.playerId, E('thanks')), true);
assert.deepEqual(messages.map((x) => x.playerId), [other.playerId]);
assert.equal(BE.onHumanEmote(m, ai.playerId, E('thanks')), false);
assert.equal(BE.onHumanEmote(m, 'unknown', E('thanks')), false);
});
test('pure AI and solo games stay silent at every entry point', () => {
for (const pureAI of [true, false]) {
const { m, human, ai, messages } = fixture();m.order = pureAI ? [ai] : [human];
BE.sendEmote(m, ai, E('thanks'));BE.onHumanEmote(m, human.playerId, E('thanks'));
BE.onSettle(m);BE.onPickCard(m, ai, { kind: 'bounty' });BE.onStartUnite(m, { helpers: [ai] });
BE.onGiftTicker(m, ai, human.playerId);for (let n=0;n<3;n++) BE.onMerge(m, ai, { kind: 'chess' });
assert.deepEqual(messages, []);
}
});
test('settlement uses this round, breaks kill ties by damage and cannot repeat after cooldown', () => {
const { m, ai, other, messages, advance } = fixture();
ai.stats = { kills: 999 };other.stats = { kills: 0 };
m.lastResults.set(ai.playerId, { killed: 2, damageDealt: 10, leaked: [{ counted: true }], perfect: false });
m.lastResults.set(other.playerId, { killed: 2, damageDealt: 20, leaked: [], perfect: true });
assert.equal(BE.pickSettleEmote(m, ai), E('scared'));assert.equal(BE.pickSettleEmote(m, other), E('call'));
assert.equal(BE.onSettle(m), true);advance();assert.equal(BE.onSettle(m), false);
assert.equal(messages.length, 2);m.round++;assert.equal(BE.onSettle(m), true);
});
test('third chess merge reacts once per round, not to items or every later merge', () => {
const { m, ai, messages, advance } = fixture();
BE.onMerge(m, ai, { kind: 'item' });
for(let n=0;n<8;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
assert.equal(messages.length, 1);BE.resetRoundCounters(m);
for(let n=0;n<3;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
assert.equal(messages.length, 2);
});
test('gift reactions identify the source player rather than an operator display name', () => {
const { m, ai, human, other, messages } = fixture();
assert.equal(BE.onGiftTicker(m, ai, '琳琅诗怀雅'), false);
assert.equal(BE.onGiftTicker(m, ai, other.playerId), false);
assert.equal(BE.onGiftTicker(m, ai, human.playerId), true);
assert.equal(messages.length, 1);
});
test('environment switch suppresses every emote entry point', () => {
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `
import {sendEmote} from './server/match/botEmotes.js';
const ai={playerId:'ai',isBot:true,alive:true};
if(sendEmote({order:[ai,{isBot:false}]},ai,'autochess_battle_thanks')) process.exit(2);
`], { env: { ...process.env, SP_BOT_EMOTES: '0' }, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
});
test('actual match emote and merge hooks broadcast without errors', (t) => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 1, fake: true }).start().toPrep();t.after(() => h.m.dispose());
const [human, ai] = h.m.order;human.lastEmoteAt = ai.lastEmoteAt = -Infinity;
assert.equal(h.m.emote(human, E('thanks')).ok, true);
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1);
for (let n=0;n<3;n++) ai.acquireChess(c.chessId);
assert.equal(h.m._botEmotesMerges.get(ai.playerId), 1);
});
test('real beacon delivery from a human makes the receiving bot thank the correct player', (t) => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 31, fake: true }).start().toPrep();
t.after(() => h.m.dispose());
const [human, ai] = h.m.order;
ai.isBot = true; ai.lastEmoteAt = -Infinity;
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && c.bonds.length);
const carrier = give(h.m, human, c.chessId, 'hand');
const item = giveItem(h.m, human, 'chess_item_5_04_e_a');
assert.equal(h.m.handle(human.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }).ok, true);
h.m.round = 2;
h.m.dispatch(human, 'onRoundStart', { round: 2 });
assert.ok(h.bc.filter((e) => e.t === 'm.emote').some((e) => e.playerId === ai.playerId && e.id === E('thanks')));
assert.equal(human.effects.filter((e) => e.key === 'effect:builtin_gift').length, 0);
assert.equal(h.m.errorCount, 0);
});