feedback5: merge W5C — 琳琅诗怀雅 S2 bomb on x-6 (#116), 新硎 / 沉沙 one 断刃 layer (#107), the owner's ACTIVE_RANGE trigger rule, AUTO-skill audit (伺夜 S2, 浊心斯卡蒂 S2)

# Conflicts:
#	data/backups.json
#	tools/build-data.mjs
This commit is contained in:
sganggs committed 2026-10-05 17:51:44 +08:00
commit 65ac4afbd8
32 files changed
+2432 -1959

No files matched your search

+50 -8
View File
@@ -38,6 +38,7 @@ import { isDeepStrictEqual } from 'node:util';
import { Grid, DEPLOY_REFUSED_TILES } from '../server/sim/grid.js';
import { bandBondIds } from '../shared/bandBonds.js';
import { statusKey, composeUnitRecord, standInRecord, unitForm } from '../shared/standIn.js';
import { extendedGrid } from '../shared/loadoutRecord.js';
// ===== CLI & IO ==================================================================================
@@ -473,6 +474,26 @@ const STANDIN_TRIGGER_DEVIATIONS = Object.freeze({
char_610_acfend: { skchr_acfend_1: 'DEFAULT', skchr_acfend_2: 'DEFAULT', skchr_acfend_3: 'DEFAULT' }, // Mechanist S1 结构稳定 / S2 不变性原理 / S3 应力倒置
});
/**
* The attack range a MANUAL skill gives while it runs, when its text changes the attack range (ATTACK_RANGE_CHANGE): its
* own rangeId grid, else the operator's range grown by `ability_range_forward_extend` ("攻击距离+N": 空弦 S3, 史尔特尔
* S2 / S3, 异客 S2 — the kits' targeting.rangeExtend, extendedGrid as Battle._refreshRange); null for any other skill and
* for "被动效果:攻击范围扩大" (引星棘刺 S3: her own range while she carries it — shared/loadoutRecord.js attackRangeGrid).
*/
function activeAttackGrid(skill, baseGrid) {
const desc = skill.desc || '';
if (!ATTACK_RANGE_CHANGE.test(desc) || /被动效果:攻击范围扩大/.test(desc)) return null;
if (skill.rangeGrid?.length) return skill.rangeGrid.map((p) => p.slice());
const n = Math.round(Number(skill.bb?.ability_range_forward_extend) || 0);
return n > 0 && baseGrid?.length ? extendedGrid(baseGrid, n) : null;
}
/** True when grid `a` holds every tile of grid `b` and at least one more (`[dRow, dCol]` pairs, facing RIGHT). */
function strictlyContains(a, b) {
const sa = new Set(a.map(([r, c]) => `${r},${c}`)), sb = new Set(b.map(([r, c]) => `${r},${c}`));
return sa.size > sb.size && [...sb].every((k) => sa.has(k));
}
/**
* Resolve the auto-cast rule of a skill record (PRTS 卫戍协议/帮助 §作战阶段 技能操作 — the official skill strategies;
* DESIGN §5.6):
@@ -488,13 +509,20 @@ const STANDIN_TRIGGER_DEVIATIONS = Object.freeze({
* - else DEFAULT (the basic strategy: ready + about to attack / heal);
* - last, the deliberate deviations (TRIGGER_DEVIATIONS, per chess and skill; STANDIN_TRIGGER_DEVIATIONS, per stand-in
* unit and skill): `rule` from the table, `rawRule` the official row (a SKILL_RANGE deviation takes the skill's own
* range as `customRangeGrid`).
* @param {object} skill record from buildSkill (skillId, skillType, desc, rangeGrid)
* @param {{operator?: boolean, chessId?: string, unitCharId?: string}} opts operator = a chess (the 技能范围 strategy is
* written for 干员; summons keep DEFAULT); chessId = the chess's NORMAL id (TRIGGER_DEVIATIONS key); unitCharId = the
* character of a backups.json unit form (STANDIN_TRIGGER_DEVIATIONS key)
* range as `customRangeGrid`);
* - and the owner's rule of 2026-10-05, a deliberate deviation like §21.29 (community report 「有的干员开技能后的攻击范围比
* 平时攻击范围大,但是怪走到平时的攻击范围内才会开技能」): an operator's MANUAL skill left on the basic strategy (DEFAULT,
* no deviation of the table) whose attack range while it runs (activeAttackGrid) strictly contains the operator's own
* range casts as soon as an enemy — a heal skill: an injured ally — is inside that larger range: ACTIVE_RANGE,
* `customRangeGrid` = the running range, `rawRule` the official row. Other rows keep their rule (TAKE_DAMAGE waits for a
* hit, SEARCH reads the initial range by its official row, CUSTOM_RANGE / SP_FULL / … have their own).
* @param {object} skill record from buildSkill (skillId, skillType, desc, rangeGrid, bb)
* @param {{operator?: boolean, chessId?: string, unitCharId?: string, baseGrid?: number[][]|null}} opts operator = a
* chess (the 技能范围 strategy is written for 干员; summons keep DEFAULT); chessId = the chess's NORMAL id
* (TRIGGER_DEVIATIONS key); unitCharId = the character of a backups.json unit form (STANDIN_TRIGGER_DEVIATIONS key);
* baseGrid = the operator's own range at that status (ACTIVE_RANGE)
*/
function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false, chessId = null, unitCharId = null } = {}) {
function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false, chessId = null, unitCharId = null, baseGrid = null } = {}) {
const rows = Object.values(ctx.ac.skillTriggerDataList || {});
const manual = skill.skillType === 'MANUAL';
const pick =
@@ -514,6 +542,10 @@ function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false,
}
if (deviation) return { rule: deviation, rawRule, customRangeGrid: null };
const rule = TRIGGER_RENAME[rawRule] || rawRule;
if (rule === 'DEFAULT' && operator && manual && baseGrid?.length) {
const active = activeAttackGrid(skill, baseGrid);
if (active && strictlyContains(active, baseGrid)) return { rule: 'ACTIVE_RANGE', rawRule, customRangeGrid: active };
}
let customRangeGrid = null;
if (rawRule === 'CUSTOM_RANGE_SEARCH_ENEMY') {
const rid = ctx.ac.skillRangeDict?.[skill.skillId];
@@ -886,7 +918,7 @@ function buildChess(ctx) {
if (!se?.skillId || (i !== sIdx && !unlocked(se.unlockCond, phase, level))) return;
const s = buildSkill(ctx, se.skillId, skillLevel, null, `chess ${chessId}`);
if (!s) return;
s.trigger = resolveTrigger(ctx, char, shop.charId, i, s, { operator: true, chessId: baseId });
s.trigger = resolveTrigger(ctx, char, shop.charId, i, s, { operator: true, chessId: baseId, baseGrid: rec.rangeGrid });
s.index = i;
s.overrideTokenKey = se.overrideTokenKey || null;
skillRecs.push(s);
@@ -1080,7 +1112,7 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
if (!se?.skillId || (i !== skillIndex && !unlocked(se.unlockCond, phase, level))) return;
const s = buildSkill(ctx, se.skillId, skillLevel, null, label);
if (!s) return;
s.trigger = resolveTrigger(ctx, char, charId, i, s, { operator: true, chessId, unitCharId: chessId ? null : charId });
s.trigger = resolveTrigger(ctx, char, charId, i, s, { operator: true, chessId, unitCharId: chessId ? null : charId, baseGrid: rangeGrid(ctx, rangeId) });
s.index = i;
s.overrideTokenKey = se.overrideTokenKey || null;
skills.push(s);
@@ -3406,6 +3438,16 @@ function validateAll(f) {
}
}
}
// the owner's ACTIVE_RANGE rule (2026-10-05): a MANUAL skill of the basic strategy whose running range strictly contains
// the record's own range, and no other
for (const c of Object.values(chess)) {
for (const s of c.skills || []) {
if (s.trigger?.rule !== 'ACTIVE_RANGE') continue;
if (s.skillType !== 'MANUAL' || s.trigger.rawRule !== 'DEFAULT' || !c.rangeGrid?.length || !strictlyContains(s.trigger.customRangeGrid || [], c.rangeGrid)) {
err(`chess ${c.chessId} ${s.skillId}: ACTIVE_RANGE needs a MANUAL basic-strategy skill whose customRangeGrid strictly contains the record's range`);
}
}
}
// 补位 / 自选 (data/backups.json, DATA.md §18): a PRESET chess is its own backup; every NORMAL chess (both forms)
// composes into its stand-in (shared/standIn.js standInRecord) with the chess's skill and module; every DIY slot is a
// DIY chess and every prototype pick of its tier has a form for both slot statuses; derived bonds exist