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:
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user