feedback5: merge WE2 — fidelity (data / match / UI): summon hand counts from the tokens' hidden talents (麦哲伦 / 令 3, 白铁 2), 望's 棋子 on any tile, the 一会儿见! marker no hand card, TRY_SEARCH_ALLY_SKILL → SKILL_RANGE + allies, 斩业星熊's normal attack phys, IS / RA-only modules refused (SO-A / SO-B / RA-A), 黑键 S2 at full SP with a free tile, 重构体 / 战术锚点 placement, 塑心's cap per trait, random grants draw 自选 stock, diyBanned shown, AI 托管 buys 自选, 谬因 S2 / 薇薇安娜 S3 area triggers, #148 regression test, tier*.js / diyRecord shims removed; golden: coop2-NORMAL-70-diy, diy-045 / 078 / 114 / 115 / 116 moved (WE2's own), the W4B moves kept

This commit is contained in:
sganggs committed 2026-10-06 16:15:52 +08:00
commit 723ee72d38
79 files changed
+1389 -405

No files matched your search

@@ -0,0 +1,87 @@
// test/content/feedback5-we2-area-triggers.test.js — 0.2.0 WE2 #16: kit-side larger-range triggers where no data range can
// carry the owner's ACTIVE_RANGE rule (2026-10-05: a MANUAL skill whose running range strictly contains the operator's own
// casts as soon as an enemy is inside that larger range):
// * 谬因 S2 临界瞬爆 — the beam's unbounded path (O1's report): content trigger range = the path she would fire along now;
// * 薇薇安娜 S3 “明灭” — from its second cast of a deployment on 3-2 (O11's report): ACTIVE_RANGE after the first cast ends
// (skills.js setTrigger), DEFAULT again at her next deployment.
// Run: node --test test/content/feedback5-we2-area-triggers.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** `charId` as uid 1 at (row, col) facing RIGHT with skill `skill`. */
function field(charId, { skill, row, col, tier = 5, seed = 5 }) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpInit: 99, dpPerSec: 0, dpMax: 999, startOpCooldown: 0 }, hooks: ['skillStart', 'skillEnd'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId, skillIndex: skill, uniEquipId: null }, elite: false, row, col }],
});
h.step();
return { h, u: h.unit(1) };
}
test('#16 谬因 S2: an enemy on the beam\'s line beyond her own 6 tiles casts it (data DEFAULT alone would wait); one off the line does not', () => {
// her own range (9, 2) … (9, 7); the beam reaches the end of the row
{
const { h, u } = field('char_4229_aphris', { skill: 1, row: 9, col: 2 });
assert.equal(u.skill.rule, 'DEFAULT');
assert.equal(u.skill.triggerRanges.length, 1, 'the beam path is a content trigger range');
h.spawn('enemy_dummy', { pos: [10, 12] }); // off the line
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'off the line: no cast');
h.spawn('enemy_dummy', { pos: [9, 13] }); // on the line, far beyond (9, 7)
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'on the beam\'s line: cast');
done(h);
}
// a flyer on the line too (the beam hits air units)
{
const { h, u } = field('char_4229_aphris', { skill: 1, row: 9, col: 2 });
h.spawn('enemy_fly', { pos: [9, 14] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
done(h);
}
// S1 / S3 get no such range
for (const skill of [0, 2]) {
const { h, u } = field('char_4229_aphris', { skill, row: 9, col: 2 });
assert.equal(u.skill.triggerRanges.length, 0, `S${skill + 1}`);
done(h);
}
});
test('#16 薇薇安娜 S3: the first cast of a deployment by the data\'s DEFAULT; once it ended, an enemy on the 3-2 beyond her own reach casts the next one (ACTIVE_RANGE); a redeploy goes back to DEFAULT', () => {
const { h, u } = field('char_4098_vvana', { skill: 2, row: 10, col: 3 });
assert.equal(u.skill.rule, 'DEFAULT');
// (10, 6): on her 3-2 (cols 3–6), outside her own 1-1 (cols 3–4)
const far = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'first cast: DEFAULT on her own range — the far enemy does not cast it');
// an enemy in her own range: the first cast; then let it run out
const near = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'first cast');
assert.ok(h.runUntil(() => !u.skill.active, 30), 'it ends');
assert.deepEqual([u.skill.rule, u.skill.triggerGrid], ['ACTIVE_RANGE', [[0, 0], [0, 1], [0, 2], [0, 3]]], 'from now on the 3-2');
h.b.kill(near, null);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 2, 4), 'the far enemy alone casts the second one');
void far;
// a redeploy: the count and the rule start again
assert.ok(h.runUntil(() => !u.skill.active, 40));
h.b.retreat(u, { reason: 'retreat' });
h.step();
assert.ok(h.b.redeploy(u, { free: true }));
h.step();
assert.deepEqual([u.skill.rule, u.skill.triggerGrid], ['DEFAULT', null], 'the next deployment: the data\'s rule');
done(h);
});
@@ -0,0 +1,40 @@
// test/content/feedback5-we2-garrison90.test.js — 0.2.0 WE2 #30: 塑心's 特质 garrison_90 (bond_actived_maxstack) "<战斗中>
// 开启技能时,同一行每有1名干员,当前已激活且层数最多的盟约层数+1(每场作战至多10层)" (elite +2, at most 20): the per-battle cap is
// the trait's own, whichever bond is the highest at each gain — a change of the highest bond grants no fresh cap
// (server/sim/content/garrisons/battle.js fireGain; PR #178 review).
// Run: node --test test/content/feedback5-we2-garrison90.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const B = (layers = 0) => ({ count: 3, active: true, tier: 1, layers });
/** 塑心's trait on 'op' with one row mate (2 in the row: +2 per skill, elite +4). */
function field(gid, bonds) {
const chess = { op: { ...chessRec({ id: 'op' }), garrisonIds: [gid] }, m1: { ...chessRec({ id: 'm1' }), garrisonIds: [] } };
const h = makeBattle({
seed: 3, bonds, defs: { chess, enemies: {} }, autoFinish: false, timeLimit: 400,
units: [{ chessId: 'op', row: 10, col: 4 }, { chessId: 'm1', row: 10, col: 6 }],
});
h.step(1);
return h;
}
const skill = (h) => h.b.emit('skillStart', { unit: h.unit('op'), skill: h.unit('op').skill, reason: 'test' });
const gains = (h) => ({ ...h.result().perPlayer.p1.layerGains });
test('#30 塑心 garrison_90: at most 10 (elite 20) layers per battle in all, even when the highest bond changes', () => {
for (const [gid, per, cap] of [['garrison_90_a', 2, 10], ['garrison_90_b', 4, 20]]) {
const h = field(gid, { yanShip: B(20), steadShip: B(15) });
const n1 = Math.floor(cap / per / 2); // half the cap on 炎 (the highest)
for (let i = 0; i < n1; i++) skill(h);
assert.deepEqual(gains(h), { yanShip: n1 * per }, `${gid}: 炎 is the highest`);
// 坚守 becomes the highest (an outside gain of 30), then many more casts
assert.equal(h.b.addLayers('p1', 'steadShip', 30, 'test'), 30);
for (let i = 0; i < 20; i++) skill(h);
const g = gains(h);
assert.equal(g.yanShip, n1 * per, `${gid}: 炎 kept its gain`);
assert.equal(g.steadShip - 30, cap - n1 * per, `${gid}: 坚守 got only what was left of the one cap`);
checkInvariants(h.b);
}
});
@@ -0,0 +1,48 @@
// test/content/feedback5-we2-raid-148.test.js — 0.2.0 WE2 #3 (GitHub #148 「突袭近战干员部署在高台」, a battle screenshot on the
// 深水区 map): reproduced on every battle stage — a melee 突袭 member's jump (content/bonds/addon/battle.js raidTile:
// grid.canStand for its position) lands on low ground only, never on a 高台; on 战场#08 (act2 m04) it may land on a 围墙 tile
// (tile_fence_bound: LOW, buildable ALL — deployable, it attacks from there and blocks no ground enemy, DESIGN §21.23), which
// the board draws as a raised concrete area inside an orange railing: what the screenshot most likely shows.
// Run: node --test test/content/feedback5-we2-raid-148.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { getData } from '../../server/data.js';
const D = getData({ log: { warn() {}, error() {}, info() {} } });
const STAGES = Object.keys(D.stages).filter((id) => D.stages[id].kind !== 'unite');
const defs = {
chess: { m: chessRec({ id: 'm', bonds: ['raidShip'], position: 'MELEE', range: [[0, 0], [0, 1]] }) },
enemies: { e: enemyRec({ key: 'e', hp: 1e9, speed: 0, mass: 0 }) },
};
const RAID = { raidShip: { count: 3, active: true, tier: 1, layers: 1 } };
test('#3 / GitHub #148: a melee 突袭 member never lands on a 高台 (any stage, any enemy tile of the field); 战场#08 lands it on 围墙 tiles', () => {
let jumps = 0, fence = 0;
for (const stageId of STAGES) {
const st = D.stages[stageId];
const tileAt = (r, c) => st.tiles[st.rows[r]?.[c]];
// the member's start: the first deployable melee tile of the field
let home = null;
for (let r = 12; r >= 9 && !home; r--) for (let c = 2; c <= 10 && !home; c++) {
const t = tileAt(r, c);
if (t && t.height === 'LOW' && (t.buildable === 'ALL' || t.buildable === 'MELEE') && t.tileKey === 'tile_road') home = [r, c];
}
if (!home) continue;
for (let er = 9; er <= 12; er++) for (let ec = 2; ec <= 10; ec++) {
const t = tileAt(er, ec);
if (!t || t.height !== 'LOW' || (er === home[0] && ec === home[1])) continue;
const h = makeBattle({ stageId, defs, bonds: RAID, enemies: [{ key: 'e', pos: [er, ec] }], units: [{ chessId: 'm', row: home[0], col: home[1] }], autoFinish: false, timeLimit: 60 });
const u = h.unit('m');
h.run(12);
if (u.tileR === home[0] && u.tileC === home[1]) continue;
jumps++;
const lt = tileAt(u.tileR, u.tileC);
assert.ok(lt && lt.height === 'LOW' && (lt.buildable === 'ALL' || lt.buildable === 'MELEE'), `${stageId}: enemy ${er},${ec} → ${u.tileR},${u.tileC} ${lt?.tileKey}/${lt?.height}`);
if (lt.tileKey === 'tile_fence_bound') fence++;
}
}
assert.ok(jumps > 50, `${jumps} jumps checked`);
assert.ok(fence > 0, 'some land on a 围墙 tile (the #148 look)');
});
@@ -0,0 +1,121 @@
// test/content/feedback5-we2-summon-hand.test.js — 0.2.0 WE2 #21: the hand count of every 自选 summoner's placeable summon
// is its deploy limit (PRTS 卫戍协议/帮助 §战斗部署 "根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区"), and the
// data's deploy limit / holding carry the tokens' own talent additions (tools/build-data.mjs tokenTalentDeckBonus: the
// hidden "TOKEN数" talent max_deploy_count / max_deck_stack_cnt on top of the attribute frame). Official counts: the owners'
// talents (麦哲伦 / 令 / 电弧 "最多同时部署3个", SUM-Y stage 2+ "最多同时部署4个", 白铁 / 娜斯提 "最多可部署2个", 望 "可以使用6枚
// 棋子", TRP-X +1), PRTS 幻影 备注 "最大可部署数量为3" (夜莺).
// #24: a summon no owner shows (displayTokenDict: the variants' `display` source) is its skill's own object, never a hand
// card — 予愿安洁莉娜 S3's “一会儿见!” (PRTS 予愿安洁莉娜 S3 备注: the skill deploys it at her initial tile).
// Run: node --test test/content/feedback5-we2-summon-hand.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { diyGameData } from '../../server/match/player/diy.js';
import { diyRecord, diySlot } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const DATA = { chess: CHESS, backups: BACKUPS };
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const STAGE = { 5: 1, 6: 3 };
const statusOf = (tier, elite) => (elite ? `2/60/7/${STAGE[tier]}` : '2/1/4/0');
/** The official hand count of `charId`'s summon under a module at its stage (null module = none). */
function official(charId, mod, stage) {
switch (charId) {
case 'char_248_mgllan': return mod === 'uniequip_003_mgllan' && stage >= 2 ? 4 : 3; // SUM-Y stage 2+: 最多同时部署4个
case 'char_2023_ling': return mod === 'uniequip_002_ling' && stage >= 2 ? 4 : 3; // SUM-Y stage 2+
case 'char_4195_radian': return 3;
case 'char_4072_ironmn': return 2;
case 'char_4212_nasti': return 2;
case 'char_179_cgbird': return 3; // PRTS 幻影 备注
case 'char_2027_wang': return mod === 'uniequip_002_wang' ? 7 : 6; // TRP-X +1
case 'char_4048_doroth': return 9; // 10 / 13, the hand's 9 cap
default: return 1;
}
}
const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard');
test('every 自选 summoner: the hand count of each placeable summon in every form, skill and module is the official one', () => {
const owners = new Map(); // charId → placeable token ids it makes
for (const [tid, t] of Object.entries(BACKUPS.tokens)) {
if (t.kind !== 'summon' || t.placeable !== true) continue;
for (const key of Object.keys(t.variants)) {
const cid = key.split('@')[0];
if (!owners.has(cid)) owners.set(cid, new Set());
owners.get(cid).add(tid);
}
}
assert.ok(['char_248_mgllan', 'char_2023_ling', 'char_4195_radian', 'char_4072_ironmn', 'char_179_cgbird', 'char_2027_wang'].every((c) => owners.has(c)));
let checked = 0;
for (const [charId] of owners) {
if (!BACKUPS.diy.ownedPool.includes(charId)) continue;
for (const tier of [5, 6]) {
for (const elite of [false, true]) {
const form = BACKUPS.units[charId].forms[statusOf(tier, elite)];
for (const mod of elite ? [null, ...(form.modules ?? []).map((m) => m.uniEquipId)] : [null]) {
for (const s of form.skills) {
const pick = { charId, skillIndex: s.index, uniEquipId: mod };
const slot = diySlot(SLOT[tier], DATA);
const id = elite ? slot.goldenId : slot.baseId;
const rec = diyRecord(SLOT[tier], pick, { elite, data: DATA });
assert.ok(rec, `${charId} T${tier} ${elite ? 'elite' : 'normal'} ${mod} S${s.index + 1}`);
const view = diyGameData(gd, new Map([[id, rec]]));
for (const { tokenId, count } of view.placeableTokens(id, { skillIndex: s.index, moduleId: mod })) {
assert.equal(count, official(charId, mod, elite ? STAGE[tier] : 0), `${charId} T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'} S${s.index + 1}: ${tokenId}`);
checked++;
}
}
}
}
}
}
assert.ok(checked > 200, `${checked} hand counts checked`);
});
test('the data: deploy limit / holding = the attribute frame + the token talents\' max_deploy_count / max_deck_stack_cnt (a module token part of the same talent replaces it)', () => {
const v = (tid, owner) => BACKUPS.tokens[tid].variants[owner];
// 麦哲伦: 1 + 2 standing, 0 + 5 held; SUM-Y +3 held, stage 3 its token part max_deploy_count 3 ⇒ 4
const d1 = 'token_10005_mgllan_drone1';
assert.deepEqual([v(d1, 'char_248_mgllan@2/1/4/0').stats.deployLimit, v(d1, 'char_248_mgllan@2/1/4/0').stats.deckStack], [3, 5]);
assert.deepEqual([v(d1, 'char_248_mgllan@2/60/7/1').byModule.uniequip_003_mgllan.stats.deployLimit, v(d1, 'char_248_mgllan@2/60/7/1').byModule.uniequip_003_mgllan.stats.deckStack], [3, 8]);
assert.deepEqual([v(d1, 'char_248_mgllan@2/60/7/3').byModule.uniequip_003_mgllan.stats.deployLimit, v(d1, 'char_248_mgllan@2/60/7/3').byModule.uniequip_003_mgllan.stats.deckStack], [4, 8]);
// 夜莺's 幻影: its visible talent's max_deploy_count 2 (RIN-Y stage 3 restates the same talent: not counted twice)
const ph = 'token_10003_cgbird_bird';
assert.equal(v(ph, 'char_179_cgbird@2/1/4/0').stats.deployLimit, 3);
assert.equal(v(ph, 'char_179_cgbird@2/60/7/3').byModule.uniequip_003_cgbird.stats.deployLimit, 3);
// 望's 棋子: the rank-2 talent (+1) is a potential talent — potential 0 keeps 6
assert.equal(v('token_10064_wang_stone1', 'char_2027_wang@2/1/4/0').stats.deployLimit, 6);
// the chess summons of tokens.json have no such talent: 凯瑟琳's 爬行号 2 of 3, everyone else 1
const tokens = load('tokens');
assert.equal(tokens.token_10041_cathy_catsld.variants.chess_char_4_11_a.stats.deployLimit, 2);
assert.equal(tokens.token_10000_silent_healrb.variants.chess_char_2_02_a.stats.deployLimit, 1);
});
test('#24 a summon no owner shows is never a hand card: 予愿安洁莉娜 S3\'s “一会儿见!” is not placeable, every placeable summon is displayed', () => {
const MARKER = 'token_10071_aglna2_agairp';
const AGLNA2 = 'char_1015_aglna2';
assert.equal(BACKUPS.tokens[MARKER].placeable, false);
const shows = (v) => (v.sources ?? []).includes('display') || Object.values(v.bySkill ?? {}).some((a) => (a.sources ?? []).includes('display'));
for (const t of [...Object.values(load('tokens')), ...Object.values(BACKUPS.tokens)]) {
if (t.kind !== 'summon') continue;
if (t.placeable) assert.ok(Object.values(t.variants).some(shows), `${t.tokenId} ${t.name}: placeable and displayed`);
else if (!Object.values(t.variants).some(shows)) assert.ok(t.displayType === 'HIDDEN' || t.tokenId === MARKER, `${t.tokenId} ${t.name}: undisplayed ⇒ HIDDEN (or the marker)`);
}
for (const tier of [5, 6]) {
for (const elite of [false, true]) {
const slot = diySlot(SLOT[tier], DATA);
const id = elite ? slot.goldenId : slot.baseId;
for (const skillIndex of [0, 1, 2]) {
const rec = diyRecord(SLOT[tier], { charId: AGLNA2, skillIndex, uniEquipId: null }, { elite, data: DATA });
const view = diyGameData(gd, new Map([[id, rec]]));
assert.deepEqual(view.placeableTokens(id, { skillIndex, moduleId: null }), [], `T${tier} ${elite ? 'elite' : 'normal'} S${skillIndex + 1}: no hand piece`);
}
}
}
});
+3 -1
View File
@@ -9,7 +9,9 @@ import { skillSpecSource } from '../../server/sim/content/index.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import { kitCoverage } from '../../tools/kit-coverage.mjs';
import { wolfShadows, wolfTacticalPoint } from '../../server/sim/content/tokens.js';
import KITS from '../../server/sim/content/kits/tier3.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
const KITS = TIER_KITS[2];
const ds = getDefaultSource();
/** Skill index of `skillId` on chess `id`. */
+3 -1
View File
@@ -5,9 +5,11 @@ import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import KITS from '../../server/sim/content/kits/tier3.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
import { COLS } from '../../server/sim/constants.js';
const KITS = TIER_KITS[2];
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
const BB = (id) => D(id).skill.bb;
+3 -1
View File
@@ -4,9 +4,11 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import kits from '../../server/sim/content/kits/tier4.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
const kits = TIER_KITS[3];
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
+3 -1
View File
@@ -3,7 +3,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import KITS from '../../server/sim/content/kits/tier5.js';
import { TIER_KITS } from '../../server/sim/content/kits/index.js';
const KITS = TIER_KITS[4];
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≉ ${b}`);
const dummy = (key = 'enemy_dummy', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
+2 -16
View File
@@ -39,12 +39,11 @@ const ANY_ATK = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c
const extras = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('aglna2:extra'));
/** 予愿安洁莉娜 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, skill = 0, row = 10, col = 4, others = [], seed = 5, marker = null } = {}) {
const piece = marker ? [{ uid: 9, kind: 'token', tokenId: MARKER, ownerUid: 1, row: marker[0], col: marker[1] }] : [];
function field({ tier = 5, elite = false, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'deploy', 'dodge'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: AGL, skillIndex: skill }, elite, row, col }, ...piece, ...others],
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: AGL, skillIndex: skill }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
@@ -349,19 +348,6 @@ test('S3 move: blocking nothing, she flies (2 tiles/s, 无敌, disarmed) to the
assert.equal(u.extraRangeKeys, null);
done(h);
}
// a placed “一会儿见!” piece never deploys by itself; it is the marker
{
const { h, u } = field({ tier: 6, elite: true, skill: 2, marker: [12, 9] });
const piece = h.b.allyUnits.find((t) => t.kind === 'token' && t.uid === 9);
assert.ok(piece && !piece.alive && piece.deferDeploy, 'not deployed at the start');
u.skill.gainSp(999);
h.spawn('enemy_fly', { pos: [11, 7] });
assert.ok(h.runUntil(() => u.tileR === 11 && u.tileC === 7, 5));
assert.ok(piece.alive && piece.tileR === 10 && piece.tileC === 4, 'the piece marks her deployment tile');
u.skill.end('test');
assert.ok(!piece.alive && !piece.removed, 'withdrawn, kept as a piece');
done(h);
}
// blocking something, she stays
{
const { h, u } = field({ tier: 5, skill: 2 });
+27 -11
View File
@@ -76,7 +76,7 @@ function storeUp(h, u, n, e, max = 60) {
return log;
}
test('黑键 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-14, ranged arts every 3 s that hits air units, blocks 1, ground-targetable, the data\'s DEFAULT triggers, 空 bond, no 特质', () => {
test('黑键 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 3-14, ranged arts every 3 s that hits air units, blocks 1, ground-targetable, the data\'s DEFAULT triggers (S2: the kit casts it), 空 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[EBN], KITS[EBN]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
@@ -88,7 +88,7 @@ test('黑键 in every 自选 form: his operator kit (all three skills authored),
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.res + (m?.attr.res ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 3], `${label(f)}: 秘术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
assert.equal(u.skill.rule, 'DEFAULT', `${label(f)}: the data's trigger`);
assert.equal(u.skill.rule, skill === 1 ? 'NEVER' : 'DEFAULT', `${label(f)}: the data's trigger (S2: the kit's own cast)`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
done(h);
@@ -292,16 +292,29 @@ test('S1 渐快急板 (MANUAL, data DEFAULT): 5 s, range 4-1, attack interval ×
}
});
test('S2 荒芜回响 (AUTO, data DEFAULT): spends every energy (elite first) on energies + 1 旧日残影 on free tiles of his range — the enemy\'s tile first, then the nearest; fewer tiles ⇒ fewer spent; no free tile ⇒ no cast (the charge back)', () => {
test('S2 荒芜回响 (AUTO: full SP and one free placeable tile in his range, no enemy needed — PRTS 备注, prefab _minTileNum 1): spends every energy (elite first) on energies + 1 旧日残影 on free tiles of his range — the enemy\'s tile first, then the nearest; fewer tiles ⇒ fewer spent; no free tile ⇒ no cast', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, MX]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.skillType, sk.bb.atk_scale, sk.bb.force, sk.spCost, sk.initSp], ['AUTO', elite ? 2 : 1.7, 1, elite ? 14 : 15, 0]);
// no enemy on the field: it casts as soon as its SP is full
{
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.equal(u.skill.rule, 'NEVER', 'the kit casts it');
let before = stored(u);
for (let i = 0; i < (sk.spCost + 1) * 30 && u.skill.activations === 0; i++) { before = stored(u); h.step(); }
assert.equal(u.skill.activations, 1, 'cast at full SP with no enemy anywhere');
approx(h.b.time, sk.spCost, `T${tier}: at ${sk.spCost} s`, 0.2);
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT && t.alive).length, before.n + before.e + 1, 'the energies stored meanwhile + 1 remnants');
assert.deepEqual(stored(u), { n: 0, e: 0 }, 'every energy spent');
done(h);
}
const { h, u } = field({ tier, elite, mod, skill: 1 });
const N = traitOf(tier, elite, mod).times;
holdSkill(u);
storeUp(h, u, N, 1);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'cast with an enemy in range');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), 'cast');
const rem = h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT);
assert.equal(rem.length, N + 2, `${N} + 1 energies + 1 remnants`);
assert.deepEqual([rem[0].tileR, rem[0].tileC], [10, 7], 'the enemy\'s tile first');
@@ -315,27 +328,30 @@ test('S2 荒芜回响 (AUTO, data DEFAULT): spends every energy (elite first) on
// the flat stage's edge (col 2: RANGED tiles; cols 0–1 none): facing LEFT from (10, 2) no tile of his range is free
{
const { h, u } = field({ tier: 5, skill: 1, row: 10, col: 2, dir: 'LEFT' });
holdSkill(u);
storeUp(h, u, 3, 1);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 1] });
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5), 'the DEFAULT condition holds (an enemy in range)');
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token').length, 0, 'no placeable tile: no remnant');
assert.ok(u.skill.ready, 'the charge is back');
u.skill.gainSp(999);
h.run(5);
assert.equal(u.skill.activations, 0, 'no placeable tile: no cast, an enemy in range notwithstanding');
assert.equal(h.b.allyUnits.filter((t) => t.kind === 'token').length, 0, 'no remnant');
assert.ok(u.skill.ready, 'the charge waits');
done(h);
}
// fewer free tiles than energies + 1: facing LEFT from (10, 3) only (10, 2) and (11, 2) are placeable
{
const { h, u } = field({ tier: 5, skill: 1, row: 10, col: 3, dir: 'LEFT' });
holdSkill(u);
storeUp(h, u, 3, 1);
u.skill.gainSp(999);
let atEnd = null;
h.b.on('skillEnd', (c) => { if (c.unit === u) atEnd = stored(u); });
h.spawn('enemy_dummy', { pos: [10, 2] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const rem = h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === REMNANT);
assert.deepEqual(rem.map((t) => [t.tileR, t.tileC]), [[10, 2], [11, 2]], 'the enemy\'s tile, then the other free one');
assert.deepEqual(atEnd, { n: 3, e: 0 }, 'one energy spent — the elite one first');
assert.ok(h.runUntil(() => energyHits(h, u).length === 3, 2), 'the attack that follows releases the three left');
assert.ok(h.runUntil(() => energyHits(h, u).length === 3, 4), 'his next attack (3 s interval) releases the three left');
done(h);
}
});
+1 -1
View File
@@ -62,7 +62,7 @@ test('斩业星熊 in every 自选 form: her operator kit (all three skills auth
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [HSG, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, 15], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 驭法铁卫`);
assert.equal(form.dmgType, 'arts', 'the data carries the skill-on type');
assert.equal(form.dmgType, 'phys', 'the data carries the normal attack\'s type (build-data classifyAttack: "技能开启时…法术伤害")');
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
+4 -3
View File
@@ -105,12 +105,13 @@ test('白铁 in every 自选 form: his kit (all three skills authored), stats +
{ ok: true, picks: { [SLOT[6]]: { charId: IRON, skillIndex: 2, uniEquipId: Y } } });
});
test('<支援装置> hand pieces: the picked skill\'s device only (variant sources), deploy limit 1 in the data (official 1 + max_deploy_count 1 = 2: reported), the module costs / redeploy times', () => {
test('<支援装置> hand pieces: the picked skill\'s device only (variant sources), deploy limit 2 in the data (1 + the hidden max_deploy_count 1: E2 "最多可部署2个"), the module costs / redeploy times', () => {
for (const [skill, id] of TOK.entries()) {
for (const s of [0, 1, 2]) assert.equal(tokOf(id, 6, true, s, null).sources.includes('skill'), s === skill, `${id} under S${s + 1}`);
const v = tokOf(id, 6, true, skill, null);
assert.equal(v.stats.deployLimit, 1, `${id}: the data's deploy limit`);
assert.equal(v.talents.find((t) => t.bb.max_deploy_count != null).bb.max_deploy_count, 1, `${id}: the hidden max_deploy_count (E2 "最多可部署2个")`);
assert.equal(v.talents.find((t) => t.bb.max_deploy_count != null).bb.max_deploy_count, 1, `${id}: the hidden max_deploy_count`);
assert.equal(v.stats.deployLimit, 2, `${id}: the data's deploy limit (tools/build-data.mjs tokenTalentDeckBonus)`);
assert.deepEqual([v.stats.deckStack, tokOf(id, 6, true, skill, X).stats.deckStack], [3, 4], `${id}: the holding 3 (CRA-X +1)`);
}
// CRA-X 部署费用 −1 / −1 / −4, CRA-Y 再部署时间 −5 / −5 / −10 (PRTS module page)
const cost = (mod) => TOK.map((id, s) => tokOf(id, 6, true, s, mod).stats.cost);
+5 -4
View File
@@ -98,13 +98,14 @@ test('电弧\'s skills in every form: the data\'s SP (time SP, spCost, initSp, c
}
});
test('a 自选 pick: 电弧 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks (the 岁的界园志异 modules SO-A / SO-B are not ISW-*)', () => {
test('a 自选 pick: 电弧 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks — without a module: the 岁的界园志异 modules SO-A / SO-B are refused like ISW-A (0.2.0 WE2)', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(RAD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(RAD), `tier ${t}`);
for (const uniEquipId of [SOA, SOB, null]) {
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } } });
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId: null } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId: null } } });
for (const uniEquipId of [SOA, SOB]) {
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: RAD, skillIndex: 0, uniEquipId } }, { data, kitted: KITTED_CHARS }).error, 'BAD_TARGET', uniEquipId);
}
});
+1 -1
View File
@@ -80,7 +80,7 @@ test('黍 in every 自选 form: her operator kit (all three skills authored), th
if (skill === 0) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies, u.skill.triggerHpAtMost], ['DEFAULT', 'DEFAULT', 'AUTO', true, 0.5], `${label(f)}: S1`);
if (skill === 1) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies], ['DEFAULT', 'TAKE_DAMAGE', 'MANUAL', false], `${label(f)}: S2 (the 重装 exception)`);
if (skill === 2) {
assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['TRY_SEARCH_ALLY_SKILL', 'TRY_SEARCH_ALLY_SKILL', 'MANUAL'], `${label(f)}: S3 data rule`);
assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.trigger.allies, sk.trigger.customRangeGrid, sk.skillType], ['SKILL_RANGE', 'TRY_SEARCH_ALLY_SKILL', true, sk.rangeGrid, 'MANUAL'], `${label(f)}: S3 data rule (build-data TRIGGER_ALLY_RULES)`);
assert.deepEqual([u.skill.rule, u.skill.triggerAllies, u.skill.triggerGrid], ['SKILL_RANGE', true, sk.rangeGrid], `${label(f)}: S3 engine rule`);
}
done(h);
+1 -1
View File
@@ -14,7 +14,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/tier6.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/ops/chess_char_1_15-pithst.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { COLS } from '../../server/sim/constants.js';