diff --git a/shared/diy.js b/shared/diy.js index 7bca58b9..ca4e7597 100644 --- a/shared/diy.js +++ b/shared/diy.js @@ -12,7 +12,8 @@ // - no 特质 (PRTS "甄选加入的干员不会拥有任何特质"); bonds from the operator's factions (`diy.operators[id].bonds`); // - the normal form is E2 Lv1, skill rank 4, no module; the elite E2 Lv60, rank 7, its module at stage 1 (tier 5) or 3 // (tier 6) — the slot record's `status` (activity_table diyChessDict) picks the unit form; -// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none); a prototype carries the skill +// - an owned pick chooses its skill (any of 3) and module (any of its modules but a 集成战略-only one — ISW-A, "在集成战略 +// 中…" [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05] — or none); a prototype carries the skill // and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the // owner's decision of 2026-10-05); // - potential 0 for everyone [ASSUMED: no account]. @@ -24,6 +25,20 @@ import { composeUnitRecord, unitForm, statusKey } from './standIn.js'; /** The tiers that have 自选 slots. */ export const DIY_TIERS = Object.freeze([5, 6]); +/** + * Module types a 自选 pick never carries: the 集成战略 modules (ISW-A — their own effect applies only "在集成战略中") + * [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05]. + */ +export const DIY_EXCLUDED_MODULE_TYPE = /^ISW-/; + +/** + * Whether a module of a unit form (`forms[k].modules[]` entry) may be picked for a 自选 piece (DIY_EXCLUDED_MODULE_TYPE). + * @param {any} mod + */ +export function isDiyModule(mod) { + return isObj(mod) && typeof mod.uniEquipId === 'string' && !DIY_EXCLUDED_MODULE_TYPE.test(String(mod.typeName ?? '')); +} + /** * @typedef {{ charId: string, skillIndex?: number|null, uniEquipId?: string|null }} DiyPick a 自选 pick: the operator, * its skill index (0-based; a prototype's is its locked one) and module (uniEquipId; null / absent / 'none' = none) @@ -160,9 +175,11 @@ export function checkDiyPick(slotId, pick, data) { } if (!forms.every((f) => (f.skills ?? []).some((s) => s && s.index === skillIndex))) return { error: `${charId}: no skill ${skillIndex}` }; const elite = forms[1]; - if (uniEquipId !== null && !(elite.modules ?? []).some((m) => m && m.uniEquipId === uniEquipId)) { + const mod = uniEquipId !== null ? (elite.modules ?? []).find((m) => m && m.uniEquipId === uniEquipId) : null; + if (uniEquipId !== null && !mod) { return { error: `${charId}: no module ${uniEquipId} at stage ${slot.golden?.status?.equipLevel ?? '?'}` }; } + if (mod && !proto && !isDiyModule(mod)) return { error: `${charId}: ${uniEquipId} is a 集成战略 module (${mod.typeName})` }; return { ok: true, pick: { charId, skillIndex, uniEquipId } }; } diff --git a/test/diy.test.js b/test/diy.test.js index a9e141f0..a51799c6 100644 --- a/test/diy.test.js +++ b/test/diy.test.js @@ -8,7 +8,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, DIY_TIERS } from '../shared/diy.js'; +import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, isDiyModule, DIY_TIERS } from '../shared/diy.js'; import { unitForm, composeUnitRecord } from '../shared/standIn.js'; import { composeStats, composeTalents } from '../shared/loadoutRecord.js'; import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.js'; @@ -29,7 +29,8 @@ test('slots: two per tier (5, 6), each with its elite twin', () => { assert.deepEqual(DIY_TIERS, [5, 6]); assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]); assert.deepEqual(diySlot('chess_char_6_diy2_b', DATA) && { ...diySlot('chess_char_6_diy2_b', DATA), normal: undefined, golden: undefined }, - { baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, elite: true, normal: undefined, golden: undefined }); + { baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, shopLevel: 6, elite: true, normal: undefined, golden: undefined }); + assert.deepEqual([T5, T5B, T6, T6B].map((id) => diySlot(id, DATA).shopLevel), [5, 5, 6, 6], 'shopLevelDisplayDataDict: the 调度中心 level that lists the slot'); assert.equal(diySlot('chess_char_5_01_a', DATA), null, 'not a slot'); assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3'); }); @@ -102,6 +103,21 @@ test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick)); }); +test('checkDiyPick: an owned pick never carries a 集成战略 module (ISW-A) [ASSUMED, the owner\'s decision of 2026-10-05]', () => { + const backups = DATA.backups; + let checked = 0; + for (const charId of backups.diy.ownedPool) { + for (const [slot, key] of [[T5, '2/60/7/1'], [T6, '2/60/7/3']]) { + for (const mod of backups.units[charId].forms[key]?.modules ?? []) { + const c = checkDiyPick(slot, { charId, skillIndex: 0, uniEquipId: mod.uniEquipId }, DATA); + if (/^ISW-/.test(mod.typeName)) { assert.match(c.error || '', /集成战略 module/, `${charId} ${mod.uniEquipId}`); assert.equal(isDiyModule(mod), false); checked++; } + else assert.ok(c.ok, `${charId} ${mod.uniEquipId}: ${c.error}`); + } + } + } + assert.ok(checked >= 6, 'the ISW-A modules of the pool (凯尔希, 傀影, 菲亚梅塔, 提丰, 艾丽妮, 霍尔海雅) at both stages'); +}); + test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => { const cases = [[T5, false, '2/1/4/0', 0], [T5, true, '2/60/7/1', 1], [T6, false, '2/1/4/0', 0], [T6, true, '2/60/7/3', 3]]; for (const [slotId, elite, key, stage] of cases) {