0.2.5-dev: 驭械术师 normal attacks keep the operator's own hit plus one drone hit, the drone multiplier and per-target map reset on redeploy, 洛洛 S2 keeps its lock until a kill or the skill ends (PRs #537 / #536 / #535 by @Xinglan233, ported); hit tiles use the official round-half-to-even with an edge inset (PR #520 by @Cloudnyco, ported); bots softly avoid living teammates' opening factions until they are eliminated (PR #518 by @IceCodeNew, ported with changes); golden: 43 more scenarios move, each named in docs/history/0.2.5.md
This commit is contained in:
1 parent
7fd0478dc0
commit
e0f3771821
41 files changed
+1939
-779
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
Reference in new issue
Block a user