0.2.5-dev: merge claude/036-sim

# Conflicts:
#	CHANGELOG.md
#	docs/history/0.2.5.md
#	test/golden/matches.json
This commit is contained in:
sganggs committed 2026-10-12 15:50:42 +08:00
commit 3dd2213e47
42 files changed
+2335 -961

No files matched your search

+66 -1
View File
@@ -7,12 +7,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
import { botPickBand, botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
import { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
test('#514: a living teammate soft-reserves an opening bond, and elimination releases it', () => {
const { m } = makeMatch({ humans: 2, difficulty: 'HARD', seed: 11 }).start().toPrep();
const [human, bot] = m.order;
try {
bot.isBot = true;
human.bandId = null;
const chessOf = (bond) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 3
&& Array.isArray(c.bonds) && c.bonds.includes(bond)
&& c.bonds.filter((b) => m.gd.bond(b)?.isCore).length === 1);
const usable = m.gd.bondIds.filter((id) => m.gd.bond(id)?.isCore && !m.gd.modeInactiveBonds.has(id) && chessOf(id));
assert.ok(usable.length >= 2, 'two core bonds with a single-core chess');
const [a, b] = usable;
give(m, bot, chessOf(a).chessId, 'hand');
give(m, bot, chessOf(b).chessId, 'hand');
const focus = () => { bot._botFocus = null; bot._botFocusId = null; return bondPlan(m, bot).focus; };
const natural = focus();
assert.ok(natural === a || natural === b, natural);
const other = natural === a ? b : a;
let found = false;
for (let layers = 10; layers <= 60 && !found; layers += 10) {
bot.layers[other] = layers;
if (focus() === other) found = true;
}
assert.equal(focus(), other, 'one layer step is enough for the other bond to lead');
const band = m.gd.bandIds().find((id) => {
const bonds = m.gd.bandBondIds(id);
return bonds.includes(other) && !bonds.includes(natural);
});
assert.ok(band, `a strategy names ${other} and not ${natural}`);
human.bandId = band;
assert.notEqual(focus(), other, 'the opening strategy pulls focus off the reserved bond');
human.alive = false;
assert.equal(focus(), other, 'elimination ends the reservation');
} finally { m.dispose(); }
});
test('#514: strategy selection stays possible, and less likely, while a teammate holds that opening', () => {
const { m } = makeMatch({ humans: 2, difficulty: 'HARD', seed: 3 }).start().toPrep();
const [human, bot] = m.order;
try {
bot.isBot = true;
human.bandId = null;
const tally = (id, n = 400) => {
let hits = 0;
for (let i = 0; i < n; i++) if (botPickBand(m, bot) === id) hits++;
return hits;
};
const open = new Map();
for (let i = 0; i < 400; i++) {
const id = botPickBand(m, bot);
open.set(id, (open.get(id) || 0) + 1);
}
const ranked = [...open.entries()].filter(([id]) => m.gd.bandBondIds(id).length > 0).sort((x, y) => y[1] - x[1]);
const [band, base] = ranked[0];
assert.ok(base > 5, `${band} is drawn often enough to compare (${base})`);
human.bandId = band;
const reserved = tally(band);
assert.ok(reserved > 0, 'a soft reservation can still draw the strategy');
assert.ok(reserved < base, `reserved ${reserved} times, unreserved ${base}`);
human.alive = false;
const released = tally(band);
assert.ok(released > reserved, `elimination raises the rate from ${reserved} to ${released}`);
} finally { m.dispose(); }
});
test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => {
const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
const [ps, mate, other] = m.order;
+75
View File
@@ -0,0 +1,75 @@
// Real production Match + Battle, legal normal intents from an empty board. Seed 8 fixes the shop/wave route.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Battle } from '../../server/sim/Battle.js';
import { makeMatch } from './harness.js';
test('Rockrock killed by a real enemy and automatically revived by Ermengard starts the same target at init', () => {
const observed = [];
class ObservedBattle extends Battle {
constructor(opts) {
super(opts);
const log = { round: opts.round, deaths: [], deploys: [], firstHits: [] };
observed.push(log);
const seen = new Set(), launches = new Map();
this.on('death', c => {
if (c.unit.defId === 'chess_char_2_10_a') log.deaths.push({ t: this.time, seq: c.unit.deploySeq,
target: c.unit.trait.funnelTarget, scale: c.unit.trait.funnelScale, reason: c.reason });
}, { priority: 1000 });
this.on('death', c => {
if (c.unit.defId === 'chess_char_2_10_a') log.deaths.at(-1).revivedBy = c.revivedBy;
}, { priority: -1000 });
this.on('deploy', c => {
if (c.unit.defId === 'chess_char_2_10_a') log.deploys.push({ seq: c.unit.deploySeq,
target: c.unit.trait.funnelTarget, scale: c.unit.trait.funnelScale });
}, { priority: -1000 });
this.on('attack', c => {
if (c.attacker.defId !== 'chess_char_2_10_a') return;
// This unmodified Rockrock route has no nested attack callback. The attack hook follows its shot creation.
launches.set(this._attackSeq, { t: this.time, seq: c.attacker.deploySeq });
});
this.on('damaged', c => {
const u = c.source;
const launch = launches.get(c.dmg?.attackId);
if (u?.defId !== 'chess_char_2_10_a' || seen.has(u.deploySeq) || launch?.seq !== u.deploySeq) return;
seen.add(u.deploySeq);
log.firstHits.push({ t: this.time, launchAt: launch.t, seq: u.deploySeq, target: c.target.id,
scale: u.trait.funnelScale, hpBefore: c.target.hp + c.amount });
});
}
}
const h = makeMatch({ mode: 'solo', seed: 8 });
h.m.BattleClass = ObservedBattle; // read-only observers, no combat state writes
const p = h.ps('p_0'), act = msg => assert.deepEqual(h.m.handle('p_0', msg), { ok: true });
try {
h.start().toPrep(1, { band: 'band_ermengard' });
act({ t: 'g.buy', slot: 0 });
act({ t: 'g.move', uid: p.hand.find(x => x?.id === 'chess_char_1_09_a').uid,
to: { area: 'board', row: 12, col: 6 }, dir: 'RIGHT' });
h.toPrep(2, { band: 'band_ermengard' });
act({ t: 'g.levelUp' });
h.toPrep(5, { band: 'band_ermengard' });
assert.equal(p.shop.slots[3].id, 'chess_char_2_10_a');
act({ t: 'g.buy', slot: 3 });
act({ t: 'g.move', uid: p.hand.find(x => x?.id === 'chess_char_2_10_a').uid,
to: { area: 'board', row: 12, col: 8 }, dir: 'RIGHT' });
h.toPrep(7, { band: 'band_ermengard' });
const log = observed.find(x => x.round === 6);
assert.ok(log?.deaths.length > 0);
const paired = log.deaths.map(d => ({ death: d, hit: log.firstHits.find(x => x.seq > d.seq) }))
.filter(x => x.hit && x.death.target === x.hit.target && x.death.scale > 0.2);
assert.ok(paired.length > 0, JSON.stringify(log));
for (const { death, hit } of paired) {
assert.equal(death.reason, 'killed');
assert.equal(death.revivedBy, 'band');
assert.ok(hit.launchAt >= death.t, 'fresh attack after the knockout and revival');
assert.ok(hit.hpBefore > 0, 'the same enemy was alive when the fresh drone hit');
const deploy = log.deploys.find(x => x.seq === hit.seq);
assert.equal(deploy.target, null);
assert.ok(Math.abs(deploy.scale - 0.2) < 1e-8);
assert.ok(Math.abs(hit.scale - 0.2) < 1e-8, JSON.stringify({ death, hit }));
}
assert.deepEqual(h.logs.error, []);
h.invariants();
} finally { h.m.dispose(); }
});