feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout
Ported by hand: - #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃 chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝 chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines are identical to #109's tier1/2/3/4/6 diffs. - #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the skill's own mods), and W5D's 冲锋号令 DP start effect is kept. - #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty name (the Chinese text is the PR's), plus its English line in public/i18n/en.json. - data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/ file changed. - docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are always on"), SIM §7.4 (both generic passive cases). - Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js / snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js / plan.mjs, #74 plan.mjs / docs. Where the PRs met feedback5 work: - 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so. - Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the generic:passive buff). Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed and fields the unit that explains it. - #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low, bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low, bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1, 琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype). - #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退): roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high, bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun. - #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3, coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted. - #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low, bond-visiShip-high. - #66 + #160: roster-008, roster-027. - #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06, coop2-HARD-4, coop2-NORMAL-14-standins. - all three: roster-010, roster-012, roster-031. 69 scenarios. Every other digest is unchanged.
This commit is contained in:
commit
a0a7660287
55 files changed
+2140
-736
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA, makeMatch } from './harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { bondSnapshot } from '../../server/match/bondsMeta.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, compactResult } from '../../server/sim/spec.js';
|
||||
import { validateClientResult } from '../../server/match/fields.js';
|
||||
import { helperStats, helperOrder } from '../../server/match/unite.js';
|
||||
|
||||
const IDS = ['maniShip', 'emptyShip', 'soloShip', 'suntShip'];
|
||||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||||
const bonds = () => Object.fromEntries(IDS.map((id) => [id, { count: 2, active: true, tier: 1, layers: 20 }]));
|
||||
|
||||
test('hidden-layer bonds retain saved layers, prep gains, pending gains and battle input layers', () => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, fake: true }).start();
|
||||
try {
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
for (const id of IDS) {
|
||||
assert.equal(gd.bond(id).noStack, true, id);
|
||||
ps.bondCountBonus[id] = gd.bond(id).thresholds[0];
|
||||
ps.layers[id] = 50;
|
||||
}
|
||||
ps.bondCountBonus.deputShip = 2;
|
||||
ps.recompute();
|
||||
for (const id of IDS) {
|
||||
assert.equal(ps.bonds[id].active, true);
|
||||
assert.equal(ps.layers[id], 50, 'recompute must not erase saved layers');
|
||||
assert.equal(ps.addLayers(id, 8, { requireActive: true }), 8);
|
||||
assert.equal(ps.addLayers(id, 8, { requireActive: false }), 8);
|
||||
}
|
||||
h.m.dispatch(ps, 'onPrepEnd', { round: 1 });
|
||||
for (const id of IDS) assert.equal(ps.layers[id], 68, '助力 prep-end traits can add hidden layers');
|
||||
ps.pendingLayerGains = Object.fromEntries(IDS.map((id) => [id, 9]));
|
||||
for (const id of IDS) {
|
||||
assert.equal(ps.bondsView()[id].layers, 77);
|
||||
assert.equal(bondSnapshot(ps.bonds)[id].layers, 68, 'battle input keeps settled layers');
|
||||
}
|
||||
} finally { h.m.dispose(); }
|
||||
});
|
||||
|
||||
test('hidden-layer gains run through the simulator hooks, event stream and result', () => {
|
||||
const spec = buildBattleSpec({ stageId: 'act2autochess_m01', timeLimit: 1, content: 'none',
|
||||
players: [{ playerId: 'p_0', units: [], bonds: bonds() }], flags: { layerGainsEnabled: true } });
|
||||
const b = createBattleFromSpec(spec, DATA, { quiet: true });
|
||||
const calls = [];
|
||||
b.on('layerGain', (c) => calls.push(c.bondId));
|
||||
for (const id of IDS) {
|
||||
assert.equal(b.getPlayer('p_0').bonds[id].layers, 20);
|
||||
assert.equal(b.addLayers('p_0', id, 4), 4);
|
||||
assert.equal(b.getPlayer('p_0').bonds[id].layers, 24);
|
||||
}
|
||||
assert.deepEqual(calls, IDS);
|
||||
assert.deepEqual(b.drainEvents().filter((e) => e[0] === 'layer').map((e) => e[2]), IDS);
|
||||
b.forceEnd();
|
||||
assert.deepEqual(b.result().perPlayer.p_0.layerGains, Object.fromEntries(IDS.map((id) => [id, 4])));
|
||||
});
|
||||
|
||||
test('client validation accepts legal hidden-layer gains and still rejects malformed JSON values', () => {
|
||||
const spec = buildBattleSpec({ stageId: 'act2autochess_m01', timeLimit: 1, content: 'none',
|
||||
players: [{ playerId: 'p_0', units: [], bonds: bonds() }], flags: { layerGainsEnabled: true } });
|
||||
const b = createBattleFromSpec(spec, DATA, { quiet: true });
|
||||
for (const id of IDS) b.addLayers('p_0', id, 4);
|
||||
b.forceEnd();
|
||||
const raw = compactResult(b.result());
|
||||
assert.equal(validateClientResult(spec, raw, { gd }).ok, true);
|
||||
for (const value of [-1, '4', null, {}, 1000]) {
|
||||
const forged = structuredClone(b.result());
|
||||
forged.perPlayer.p_0.layerGains.soloShip = value;
|
||||
assert.equal(validateClientResult(spec, forged, { gd }).ok, false, JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('settlement retains hidden-layer gains from the server result', () => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, fake: true,
|
||||
script: () => ({ layerGains: { p_0: { soloShip: 12, suntShip: 9, yanShip: 3 } } }) }).start();
|
||||
try {
|
||||
h.toPrep(1);
|
||||
h.m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
h.toPrep(2);
|
||||
assert.equal(h.ps('p_0').layers.soloShip, 12);
|
||||
assert.equal(h.ps('p_0').layers.suntShip, 9);
|
||||
assert.equal(h.ps('p_0').layers.yanShip, 3);
|
||||
} finally { h.m.dispose(); }
|
||||
});
|
||||
|
||||
test('hidden layers contribute to unite pair ordering while inactive layers do not', () => {
|
||||
const player = (id, seat, layers) => ({ playerId: id, seat, deployCount: 1, board: new Map(),
|
||||
layers: { soloShip: layers, suntShip: 999 }, bonds: {
|
||||
soloShip: { active: true, layers }, suntShip: { active: false, layers: 999 },
|
||||
} });
|
||||
const a = player('p_0', 0, 5), b = player('p_1', 1, 8), m = { gd }, results = new Map();
|
||||
assert.equal(helperStats(m, b, results).layers, 8);
|
||||
assert.deepEqual(helperOrder(m, [a, b], results).map((p) => p.playerId), ['p_1', 'p_0']);
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { Match } from '../../server/match/Match.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
@@ -23,7 +24,8 @@ let srv = null;
|
||||
const clients = [];
|
||||
|
||||
async function server() {
|
||||
if (!srv) srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: FastMatch });
|
||||
// Issue #144: in the one-human + AI draft, seed 69 makes the AI take band_bldsk first.
|
||||
if (!srv) srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: FastMatch, seedFn: () => 69 });
|
||||
return srv;
|
||||
}
|
||||
async function player(name, token) {
|
||||
@@ -38,6 +40,23 @@ async function player(name, token) {
|
||||
}
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
|
||||
/** Pick from the current public draft, including the AI's choices; never rely on a timeout after a refused pick. */
|
||||
async function pickCoopBands(players) {
|
||||
const pending = new Map(players.map((c) => [c.id, c]));
|
||||
let frame;
|
||||
while (pending.size) {
|
||||
frame = await players[0].waitFor('m.public', (p) => p.phase === 'BAND_DRAFT' && pending.has(p.draft?.turn), 10000);
|
||||
assert.equal(frame.draft.order.length, frame.players.length);
|
||||
const taken = new Set(Object.values(frame.draft.picks));
|
||||
const band = Object.values(getData().bands).find((b) =>
|
||||
(!Array.isArray(b.modeTypeList) || b.modeTypeList.includes('MULTI')) && !taken.has(b.bandId));
|
||||
assert.ok(band, 'a co-op strategy is still available');
|
||||
await ok(pending.get(frame.draft.turn), { t: 'g.band', bandId: band.bandId });
|
||||
pending.delete(frame.draft.turn);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
for (const c of clients) await c.terminate().catch(() => {});
|
||||
if (srv) await srv.close();
|
||||
@@ -101,15 +120,7 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match
|
||||
for (const c of [a, b]) await c.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await ok(b, { t: 'g.infoReady' });
|
||||
const draft = await a.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT' && p.draft);
|
||||
assert.equal(draft.draft.order.length, 3);
|
||||
// humans pick when it is their turn (bots pick themselves)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const p = await a.waitFor('m.public', (x) => x.phase !== 'BAND_DRAFT' || [a.id, b.id].includes(x.draft && x.draft.turn), 10000);
|
||||
if (p.phase !== 'BAND_DRAFT') break;
|
||||
const who = p.draft.turn === a.id ? a : b;
|
||||
await who.request({ t: 'g.band', bandId: 'band_bldsk' });
|
||||
}
|
||||
await pickCoopBands([a, b]);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||
// B drops and comes back with its token
|
||||
await b.terminate();
|
||||
@@ -120,7 +131,9 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match
|
||||
assert.equal(priv.playerId, b2.id);
|
||||
await ok(a, { t: 'g.ready', ready: true });
|
||||
await ok(b2, { t: 'g.ready', ready: true });
|
||||
await a.waitFor('m.public', (p) => p.phase === 'COMBAT', 10000);
|
||||
// Instant simulated battles can finish before the throttled public phase is broadcast, as in the solo test.
|
||||
const start = await a.waitFor('b.start', (x) => x.authoritative, 10000);
|
||||
assert.equal(start.fieldId, `n:${a.id}`);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
|
||||
await ok(a, { t: 'g.leave' });
|
||||
await ok(b2, { t: 'g.leave' });
|
||||
@@ -154,11 +167,8 @@ test('co-op spectator over websockets: watches the real match like an eliminated
|
||||
assert.equal(r.code, 'SPECTATOR', msg.t);
|
||||
}
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
for (let i = 0; i < 40; i++) { // every draft frame in order: pick on the own turn (the AI picks at once)
|
||||
const p = await a.waitFor('m.public', (x) => x.phase !== 'INFO_CHECK', 10000);
|
||||
if (p.phase !== 'BAND_DRAFT') break;
|
||||
if (p.draft && p.draft.turn === a.id && !p.draft.picks[a.id]) await ok(a, { t: 'g.band', bandId: 'band_bldsk' });
|
||||
}
|
||||
const draft = await pickCoopBands([a]);
|
||||
assert.ok(Object.values(draft.draft.picks).includes('band_bldsk'), 'seed 69: the AI already took the default');
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||
for (const msg of [{ t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.refresh' }]) {
|
||||
const r = await s.request(msg);
|
||||
|
||||
Reference in new issue
Block a user