0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -16,6 +16,11 @@
|
||||
// (withHeavyHitters): the bots' own hits of ≥ 300000 came from whatever 维多利亚 / 精准 / 奥术 mix they drafted — none
|
||||
// since 0.2.0's CB3 (the 受到攻击时 counters answer every damage instance) re-drafted seed 22, whose best hit is now
|
||||
// about half the limit; the cancel path itself is unchanged (the earlier captures still cancel the same hits).
|
||||
// 无来源 HP (drone 死亡集群 links and 剑 / 锤 transfers) likewise, on a fourth copy (withTransferWindow). Shared co-op
|
||||
// stock (DESIGN §29.8) re-drafted seed 22: the 999-layer boards now clear in tens of seconds, before a drone is killed
|
||||
// on every field and before a blade takes a transferable hit (a forced replace is not damage). The transfer path is
|
||||
// unchanged — the 0.2.3 specs of this seed still lose leader HP on every field under this sim. The copy keeps the
|
||||
// drafted operators and holds 维多利亚 / 精准 / 奥术 / 卡西米尔 / 绝技 at 1 layer so the fight lasts long enough.
|
||||
//
|
||||
// Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1)
|
||||
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
|
||||
@@ -117,6 +122,23 @@ function withHeavyHitters(spec) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of `spec` whose 维多利亚 / 精准 / 奥术 / 卡西米尔 / 绝技 layers are held at 1. At 999 those bonds end seed 22's
|
||||
* shared-stock boards before a 死亡集群 drone dies on every field and before a 斩胄之剑 / 破胄之锤 takes damage
|
||||
* (DESIGN §29.8). One layer keeps the bond and lets the parts land; the operators stay the ones the bots drafted.
|
||||
*/
|
||||
const TRANSFER_BONDS = ['victoriaShip', 'preciShip', 'arcaneShip', 'kazimierzShip', 'suntShip'];
|
||||
function withTransferWindow(spec) {
|
||||
const s = JSON.parse(JSON.stringify(spec));
|
||||
for (const p of s.players || []) {
|
||||
for (const id of TRANSFER_BONDS) {
|
||||
const b = p.bonds && p.bonds[id];
|
||||
if (b && b.active && b.layers > 1) b.layers = 1;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns
|
||||
* its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses).
|
||||
@@ -158,9 +180,9 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () =
|
||||
test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => {
|
||||
const specs = captureBossSpecs();
|
||||
assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']);
|
||||
// the four as captured, the same four with a 麻痹 source on every field (withTearGas), and with 限伤-sized hits
|
||||
// on every field (withHeavyHitters)
|
||||
const runs = [...specs, ...specs.map(withTearGas), ...specs.map(withHeavyHitters)];
|
||||
// the four as captured, the same four with a 麻痹 source on every field (withTearGas), with 限伤-sized hits
|
||||
// on every field (withHeavyHitters), and with the damage bonds held at 1 layer (withTransferWindow)
|
||||
const runs = [...specs, ...specs.map(withTearGas), ...specs.map(withHeavyHitters), ...specs.map(withTransferWindow)];
|
||||
const ds = new DataSource(DATA, null);
|
||||
const node = runs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS));
|
||||
|
||||
@@ -180,7 +202,7 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () =
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
|
||||
const copies = ['', ' +催泪瓦斯', ' +heavy hitters'];
|
||||
const copies = ['', ' +催泪瓦斯', ' +heavy hitters', ' +transfer window'];
|
||||
runs.forEach((spec, i) => {
|
||||
const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}${copies[Math.floor(i / specs.length)]}`;
|
||||
assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`);
|
||||
@@ -193,11 +215,12 @@ describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () =
|
||||
});
|
||||
const real = node.slice(0, specs.length);
|
||||
const gassed = node.slice(specs.length, 2 * specs.length);
|
||||
const heavy = node.slice(2 * specs.length);
|
||||
const heavy = node.slice(2 * specs.length, 3 * specs.length);
|
||||
const opened = node.slice(3 * specs.length);
|
||||
const sum = (rows, k) => rows.reduce((a, r) => a + (r.fx[k] || 0), 0);
|
||||
assert.ok(sum(heavy, 'hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${heavy.map((r) => r.fx.hitCap || 0)} with the heavy hitters, ${sum(real, 'hitCap')} on the bots' own boards)`);
|
||||
assert.ok(sum(gassed, 'palsy') >= 1, `瘫痪 occurred with the 催泪瓦斯 (${gassed.map((r) => r.fx.palsy || 0)})`);
|
||||
assert.ok(real.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${real.map((r) => r.hpLoss)})`);
|
||||
assert.ok(opened.every((r) => r.hpLoss >= 1), `holding the damage bonds at 1 layer, every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${opened.map((r) => r.hpLoss)}; the 999-layer boards: ${real.map((r) => r.hpLoss)})`);
|
||||
// 奥术 on the bots' own boards or on the heavy-hitter copy (洛洛 + 深靛 = 奥术 2): seed 22's bots stopped drafting it
|
||||
// when 0.2.0's last fixes re-drafted the earlier rounds
|
||||
assert.ok([...real, ...heavy].some((r) => r.maxArcane === 1), `the leader carried 奥术 (bots' boards ${real.map((r) => r.maxArcane)}, heavy hitters ${heavy.map((r) => r.maxArcane)})`);
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
// taken — the two-helper 联防 field and a Final Assault / Hidden Core pair field: raidTile takes the tiles of
|
||||
// Battle.onFieldBoard (the board of any player of the field). The lone helper's empty half, a solo boss field's other
|
||||
// half and the hand rows stay closed; 乌尔比安's S3 【移动】 keeps his own board.
|
||||
// 0.2.4 (#475, maintainer-approved): only multiplayer boss/hidden Raid may also land on the empty half's board.
|
||||
// Run: node --test test/sim/feedback5-raid-own-board.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { makeBattle, flatStage, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const ID = 'chess_char_6_13_a'; // 新约能天使 (RANGED), given the 突袭 bond as the 转职球 / 突袭手雷 would
|
||||
@@ -26,8 +27,8 @@ const BONDS = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
|
||||
const dummy = enemyRec({ key: 'dummy', hp: 1e9, speed: 0, mass: 0 });
|
||||
const defs = { enemies: { dummy }, chess: { [ID]: raider } };
|
||||
|
||||
function jump({ kind, stageId, players, enemyPos, seconds = 11 }) {
|
||||
const h = makeBattle({ kind, stageId, autoFinish: false, timeLimit: 60, defs, players });
|
||||
function jump({ kind, stageId, players, enemyPos, seconds = 11, modeId }) {
|
||||
const h = makeBattle({ kind, stageId, autoFinish: false, timeLimit: 60, defs, players, modeId });
|
||||
h.step();
|
||||
const mine = h.b.allyUnits.filter((u) => u.defId === ID);
|
||||
const homes = mine.map((u) => [u.tileR, u.tileC]);
|
||||
@@ -44,7 +45,7 @@ const mate = (playerId, s, o = {}) => seat(playerId, s, { ...o, bonds: {}, units
|
||||
|
||||
test('boss field (solo 最终攻势): a ranged 突袭 member never lands on the hand row 0 or the 临时整备区 row 1', () => {
|
||||
// 战场#01: the enemy on the telin (1,3); before the fix the member landed on the hand tile (0,3)
|
||||
const { h, mine } = jump({ kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [1, 3] });
|
||||
const { h, mine } = jump({ modeId: 'mode_single_normal', kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [1, 3] });
|
||||
const u = mine[0];
|
||||
assert.deepEqual([u.tileR, u.tileC], [2, 3], 'the board tile next to it, not (0,3)');
|
||||
assert.ok(h.b.onOwnBoard(u.player, u.tileR, u.tileC));
|
||||
@@ -55,7 +56,7 @@ test('boss field (solo 最终攻势): a ranged 突袭 member never lands on the
|
||||
// no partner: the field's boards are its own (the right half stays closed)
|
||||
for (let r = 0; r <= 5; r++) for (let c = 0; c <= 20; c++) assert.equal(h.b.onFieldBoard(r, c), h.b.onOwnBoard(u.player, r, c), `(${r},${c})`);
|
||||
// an enemy only the temp row could reach: it stays home and does not hop
|
||||
const far = jump({ kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [0, 9] });
|
||||
const far = jump({ modeId: 'mode_single_normal', kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [0, 9] });
|
||||
assert.deepEqual([far.mine[0].tileR, far.mine[0].tileC], far.homes[0]);
|
||||
});
|
||||
|
||||
@@ -124,3 +125,84 @@ test('联防 with two helpers (escaped_multi): a member lands on its teammate\'s
|
||||
assert.ok(ul.b.onFieldBoard(10, 13) && !ul.b.onOwnBoard(a.player, 10, 13));
|
||||
assert.deepEqual([a.tileR, a.tileC], [10, 7], 'he stays on his own half');
|
||||
});
|
||||
|
||||
for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) {
|
||||
test(`#475 ${kind}, lone ${side} multiplayer player: ranged Raid reaches the empty half, without opening its hand rows`, () => {
|
||||
const c = side === 'L' ? 17 : 3;
|
||||
const players = [seat('p1', 0, { side, units: [unitAt(12, 3)] })];
|
||||
for (const modeId of ['mode_multi_normal', 'mode_multi_hard']) {
|
||||
const { h, mine } = jump({ kind, modeId, stageId: 'act1autochess_m01', players, enemyPos: [1, c] });
|
||||
assert.deepEqual([mine[0].tileR, mine[0].tileC], [2, c]);
|
||||
assert.equal(h.b.onFieldBoard(2, c), false, 'the general board contract still excludes an absent teammate');
|
||||
const far = jump({ kind, modeId, stageId: 'act1autochess_m01', players, enemyPos: [0, c] });
|
||||
assert.deepEqual([far.mine[0].tileR, far.mine[0].tileC], far.homes[0], 'no legal board tile reaches the hand-only target');
|
||||
}
|
||||
for (const modeId of ['mode_single_normal', 'mode_single_hard']) {
|
||||
const solo = jump({ kind, modeId, stageId: 'act1autochess_m01', players, enemyPos: [1, c] });
|
||||
assert.deepEqual([solo.mine[0].tileR, solo.mine[0].tileC], solo.homes[0], 'actual solo mode keeps its own half');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Give the member only its own tile as attack range, so each case has exactly one possible landing. All tested
|
||||
// off-board tiles are intentionally buildable: the scope predicate, not incidental terrain, must reject them.
|
||||
function emptyHalfLanding({ kind = 'boss', side = 'L', pos, glyph = 'r', ranged = false, reserved = false }) {
|
||||
const stage = flatStage({ rows: Object.fromEntries(Array.from({ length: 7 }, (_, r) => [r, 'r'.repeat(21)])) });
|
||||
stage.rows[pos[0]] = stage.rows[pos[0]].slice(0, pos[1]) + glyph + stage.rows[pos[0]].slice(pos[1] + 1);
|
||||
const h = makeBattle({
|
||||
kind, modeId: 'mode_multi_normal', stage, autoFinish: false,
|
||||
defs: { enemies: { dummy }, chess: {
|
||||
probe: chessRec({ id: 'probe', position: ranged ? 'RANGED' : 'MELEE', bonds: ['raidShip'], rangeGrid: [[0, 0]], skill: null }),
|
||||
guard: chessRec({ id: 'guard', skill: null, stats: { respawnTime: 60 } }),
|
||||
} },
|
||||
players: [seat('p1', 0, { side, units: [unitAt(12, 3, 1, 'probe'),
|
||||
...(reserved ? [{ ...unitAt(pos[0], pos[1], 2, 'guard'), abs: true }] : []),
|
||||
] })],
|
||||
});
|
||||
h.step();
|
||||
h.spawn('dummy', { pos });
|
||||
return h;
|
||||
}
|
||||
|
||||
for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) {
|
||||
test(`#475 ${kind}, lone ${side} multiplayer player: melee Raid reaches the empty half`, () => {
|
||||
const pos = [3, side === 'L' ? 17 : 3];
|
||||
const h = emptyHalfLanding({ kind, side, pos });
|
||||
const u = h.unit('probe');
|
||||
h.run(11);
|
||||
assert.deepEqual([u.tileR, u.tileC], pos);
|
||||
assert.equal(h.b.onFieldBoard(...pos), false);
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('#475: even buildable hand, temporary-storage, side-margin and off-rect tiles remain invalid Raid landings', () => {
|
||||
for (const pos of [[0, 3], [1, 3], [0, 17], [1, 17], [3, 1], [3, 19], [6, 17]]) {
|
||||
const h = emptyHalfLanding({ pos, ranged: true });
|
||||
const u = h.unit('probe'), home = [u.tileR, u.tileC];
|
||||
h.run(11);
|
||||
assert.deepEqual([u.tileR, u.tileC], home, `excluded tile ${pos}`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('#475: empty-half Raid still respects melee/high-ground, water and occupied/downed-unit reservations', () => {
|
||||
for (const glyph of ['h', 'd']) {
|
||||
const h = emptyHalfLanding({ pos: [3, 17], glyph });
|
||||
const u = h.unit('probe'), home = [u.tileR, u.tileC];
|
||||
assert.equal(h.b.grid.canStand(3, 17), false, `terrain ${glyph} is undeployable for melee`);
|
||||
h.run(11);
|
||||
assert.deepEqual([u.tileR, u.tileC], home, `terrain ${glyph}`);
|
||||
}
|
||||
const h = emptyHalfLanding({ pos: [3, 17], reserved: true });
|
||||
const u = h.unit('probe'), guard = h.unit('guard'), home = [u.tileR, u.tileC];
|
||||
h.run(11);
|
||||
assert.deepEqual([u.tileR, u.tileC], home, 'living occupant');
|
||||
h.b.kill(guard, null);
|
||||
assert.equal(h.b.isReservedTile(3, 17), true, 'downed operator reserves its rest tile');
|
||||
h.run(11);
|
||||
assert.deepEqual([u.tileR, u.tileC], home, 'downed occupant');
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
});
|
||||
Reference in new issue
Block a user