Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+163 -25
View File
@@ -418,23 +418,46 @@ function immunitiesOf(attrs) {
const TRIGGER_RENAME = { ALWAYS: 'SP_FULL', CUSTOM_RANGE_SEARCH_ENEMY: 'CUSTOM_RANGE' };
/**
* Resolve the auto-cast rule of a skill (research 03 Addendum C1): charId rows first (exact
* skillIndex, or -1 = all skills), then subProfession rows, then profession rows (skillIndex 0 =
* skill 1 only), else DEFAULT.
* A skill whose rangeId is its new ATTACK range — "攻击范围扩大 / 改变 / 缩小 / 缩短", "攻击距离+1 / 加长 / 缩短", "攻击范围与
* 溅射范围扩大" — and not a 技能范围 of its own (PRTS 卫戍协议/帮助 技能操作: "拥有技能范围的技能(非攻击距离增加)").
* "攻击范围内…" (an effect on the attack range) does not match.
*/
function resolveTrigger(ctx, char, charId, skillIdx, skillId) {
const ATTACK_RANGE_CHANGE = /攻击(?:范围|距离)(?:与溅射范围)?(?:扩大|改变|缩小|缩短|加长|增加|\+)/;
/**
* Resolve the auto-cast rule of a skill record (PRTS 卫戍协议/帮助 §作战阶段 技能操作 — the official skill strategies;
* DESIGN §5.6):
* - charId rows first (exact skillIndex, or −1 = every skill of the operator);
* - then the class rows, subProfession before profession. They name whole classes — PRTS "重装干员", "先锋-战术家、
* 先锋-执旗手、辅助-吟游者分支干员", "近卫-解放者、术师-阵法术师分支干员" — so they apply to EVERY skill index: the rows'
* skillIndex 0 is not "skill 1 only" (the 阵法术师 row has to cover 薄绿's default S2 — a phalanx never attacks while
* its skill is off, so the basic strategy could never cast it; "不受技能范围影响" of the 重装 row speaks of their skills
* with a 技能范围, which only S2/S3 have). They apply to MANUAL skills only: the strategies automate the manual 开启,
* an AUTO skill fires by its own rule (PRTS 古米 S1 备注: "此技能在存在生命值不满的可治疗角色时可触发");
* - else, for an operator's MANUAL skill with a 技能范围 (a rangeId that is not an attack-range change): SKILL_RANGE,
* "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能", customRangeGrid = the skill range;
* - else DEFAULT (the basic strategy: ready + about to attack / heal).
* @param {object} skill record from buildSkill (skillId, skillType, desc, rangeGrid)
* @param {{operator?: boolean}} opts operator = a chess (the 技能范围 strategy is written for 干员; summons keep DEFAULT)
*/
function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false } = {}) {
const rows = Object.values(ctx.ac.skillTriggerDataList || {});
const manual = skill.skillType === 'MANUAL';
const pick =
rows.find((r) => r.charId === charId && (r.skillIndex === skillIdx || r.skillIndex === -1)) ||
rows.find((r) => !r.charId && r.subProfessionId && r.subProfessionId === char.subProfessionId && r.skillIndex === skillIdx) ||
rows.find((r) => !r.charId && !r.subProfessionId && r.profession === char.profession && r.skillIndex === skillIdx);
(manual && rows.find((r) => !r.charId && r.subProfessionId && r.subProfessionId === char.subProfessionId)) ||
(manual && rows.find((r) => !r.charId && !r.subProfessionId && r.profession === char.profession)) ||
null;
if (!pick && operator && manual && skill.rangeGrid && !ATTACK_RANGE_CHANGE.test(skill.desc || '')) {
return { rule: 'SKILL_RANGE', rawRule: 'DEFAULT', customRangeGrid: skill.rangeGrid.map((p) => p.slice()) };
}
const rawRule = pick ? pick.skillTriggerType : 'DEFAULT';
const rule = TRIGGER_RENAME[rawRule] || rawRule;
let customRangeGrid = null;
if (rawRule === 'CUSTOM_RANGE_SEARCH_ENEMY') {
const rid = ctx.ac.skillRangeDict?.[skillId];
const rid = ctx.ac.skillRangeDict?.[skill.skillId];
customRangeGrid = rangeGrid(ctx, rid);
if (!customRangeGrid) warn(`skill ${skillId}: CUSTOM_RANGE trigger without skillRangeDict entry`);
if (!customRangeGrid) warn(`skill ${skill.skillId}: CUSTOM_RANGE trigger without skillRangeDict entry`);
}
return { rule, rawRule, customRangeGrid };
}
@@ -784,16 +807,16 @@ function buildChess(ctx) {
Object.assign(rec, traitDefault.classify);
// Every skill unlocked at the chess status (DESIGN §16), at the chess skill level; the trigger is
// resolved per skill index (official rows with skillIndex 0 apply to skill 1 only, -1 = all).
// resolved per skill (resolveTrigger: charId rows by index, class rows for every MANUAL skill, 技能范围).
const sIdx = shop.defaultSkillIndex ?? 0;
const sEntry = char.skills?.[sIdx];
const skillLevel = status.skillLevel || 1;
const skillRecs = [];
(char.skills || []).forEach((se, i) => {
if (!se?.skillId || (i !== sIdx && !unlocked(se.unlockCond, phase, level))) return;
const trig = resolveTrigger(ctx, char, shop.charId, i, se.skillId);
const s = buildSkill(ctx, se.skillId, skillLevel, trig, `chess ${chessId}`);
const s = buildSkill(ctx, se.skillId, skillLevel, null, `chess ${chessId}`);
if (!s) return;
s.trigger = resolveTrigger(ctx, char, shop.charId, i, s, { operator: true });
s.index = i;
s.overrideTokenKey = se.overrideTokenKey || null;
skillRecs.push(s);
@@ -965,9 +988,11 @@ function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel
const skills = char.skills || [];
let sIdx = skills[skillIndex]?.skillId ? skillIndex : skills.findIndex((x) => x && x.skillId);
const sId = sIdx >= 0 ? skills[sIdx].skillId : null;
const trig = sId ? resolveTrigger(ctx, char, tokenId, sIdx, sId) : null;
const skill = sId ? buildSkill(ctx, sId, skillLevel, trig, label) : null;
if (skill) skill.index = sIdx;
const skill = sId ? buildSkill(ctx, sId, skillLevel, null, label) : null;
if (skill) {
skill.trigger = resolveTrigger(ctx, char, tokenId, sIdx, skill);
skill.index = sIdx;
}
return {
phase: ph, level: lv,
stats: statsFrom(attrs, bonus),
@@ -994,9 +1019,32 @@ function enemyAsTokenStats(e) {
};
}
/**
* Abnormal effects (异常效果) summons hold from the start that no official table carries — the PRTS summon pages
* (召唤物 备注 "持有…"; user playtest #6 item 18). tokens.json `abnormal`; the sim gives the unit the matching flags
* (Battle._setupUnit):
* healFree — 禁疗 (HEAL_FREE, PRTS 异常效果 "无法成为治疗类能力的目标,且受到的治疗量变为0"): “小自在”, “耀阳”, 斯卡蒂的海嗣,
* 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态) (圣聆初雪 S2's frozen target);
* isolated — 孤立 (ALLY_TARGET_FREE, "无法被同阵营选中": no heal and no ally selection reaches it): “炎佑” (PRTS “炎佑”
* 天赋 "特殊机制|我方单位,孤立,可同时攻击3个目标"), 从不混淆的方向 (备注 "持有无敌、孤立…").
*/
const TOKEN_ABNORMAL = Object.freeze({
token_10015_dusk_drgn: ['healFree'], // “小自在”
token_10019_nearl2_sword: ['healFree'], // “耀阳”
token_10017_skadi2_dedant: ['healFree'], // 斯卡蒂的海嗣 (also 无敌)
token_10011_beewax_oblisk: ['healFree'], // 沙之碑
token_10030_mlyss_wtrman: ['healFree'], // 流形
token_10028_vigil_wolf: ['healFree'], // 狼群
token_10012_rosmon_shield: ['healFree'], // 迷迭香的战术装备
token_10040_siege2_vlion: ['healFree'], // 黄金盟誓
token_10058_sbell2_icetgt: ['healFree'], // 保护目标(冻结状态) (圣聆初雪 S2; also 无法撤退)
token_10039_ulpia_block: ['isolated'], // 从不混淆的方向 (also 无敌)
enemy_9012_acloon: ['isolated'], // “炎佑”
});
/**
* Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map
* characters (band_amedic 预备干员-医疗 / Touch).
* characters (band_amedic 预备干员-医疗 / Touch). `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function buildTokens(ctx, chess, tokenOwners, enemies) {
const { charTable, ac } = ctx;
@@ -1032,16 +1080,26 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
}
}
const first = variants[owners[0].chessId];
// Hand cards placed during the prep phase (`placeable`) are the summons of a TALENT shown by the shop state
// (tokenDisplayType DEFAULT: 浊心斯卡蒂 海嗣, 伺夜 狼群, 缪尔赛思 流形). A summon only a SKILL makes ("获得一个医疗无人机"
// 赫默 S2, "获得一个诅咒娃娃" 巫恋 S2) appears when that skill fires — its kit places it — never at battle start
// (user playtest #4: "赫默的无人机…是赫默开技能释放一次,不是开局直接就部署了").
const talentMade = owners.some((o) => o.sources.includes('talent') || (o.skillAlts || []).some((a) => a.sources.includes('talent')));
// Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
// §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
// state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
// 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
// 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
// placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
// missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
// 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
// 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
// (a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant (`sources`,
// `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
// takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
// SKILL_SUMMON_START_DEPLOY).
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
out[tokenId] = {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType: displayType(tokenId), placeable: displayType(tokenId) === 'DEFAULT' && talentMade,
displayType: displayType(tokenId), placeable: displayType(tokenId) !== 'HIDDEN' && produced,
owners: owners.map((o) => o.chessId),
// Defaults = first owner's variant; per-owner data in variants[chessId].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
@@ -1049,6 +1107,7 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
@@ -1064,7 +1123,8 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
displayType: null, placeable: false, owners: [],
stats: enemyAsTokenStats(loon), rangeGrid: null, dmgType: loon.stats.dmgType, attackKind: 'ranged',
projectile: 'bolt', canHitFly: true, skill: loon.skills?.[0] ? { skillId: loon.skills[0].prefabKey, bb: loon.skills[0].bb } : null,
skills: loon.skills, talents: loon.talents, deployLimit: 2, count: 1, variants: {},
skills: loon.skills, talents: loon.talents, deployLimit: 2, count: 1,
abnormal: [...TOKEN_ABNORMAL.enemy_9012_acloon], variants: {},
assets: { avatar: loon.iconId, spine: loon.spine, isEnemyModel: true },
};
} else warn('炎佑 enemy_9012_acloon missing from enemies');
@@ -1095,7 +1155,7 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
position: char.position, displayType: null, placeable: false, owners: [],
stats: v.stats, rangeGrid: v.rangeGrid, dmgType: v.dmgType, attackKind: v.attackKind, projectile: v.projectile,
canHitFly: v.canHitFly, skill: v.skill, talents: v.talents, trait: v.trait, phase: v.phase, level: v.level,
deployLimit: 1, count: 1, positions: positions.sort((a, b) => naturalCmp(a.alias, b.alias)), variants: {},
deployLimit: 1, count: 1, abnormal: [], positions: positions.sort((a, b) => naturalCmp(a.alias, b.alias)), variants: {},
assets: { avatar: charId, spine: charId },
source: 'band_amedic (aceffect_band_61 auto_chess_change_map)',
};
@@ -1568,6 +1628,51 @@ const HIT_AREAS = Object.freeze({
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
});
/**
* Official drawn size of enemy models (user playtest #6 item 9: 威龙 far too large) → enemies.json `modelScale`.
* The official client scales every Spine model in its battle prefab (`dyn/battle/prefabs/enemies/<prefab>.prefab`,
* bundles battle/enm_pfb_*.ab): world size = skeleton units × SkeletonDataAsset.scale (0.01 for all 1731 enemy
* skeletons, refs/arts/enm_art_*.ab) × the transform scale of Graphic / FaceSwitcher / Spine above the renderer. That
* transform product is MODEL_SCALE_STANDARD = 0.27 for 1454 of the 2147 enemy prefabs (2080 have exactly one Spine
* renderer; also the FaceSwitcher of the operators' battle skins), and differs for the rest: the drones are shrunk (威龙 0.16 — its skeleton is 35 % wider than
* 妖怪's, drawn at 0.20), the small 岁 relics enlarged (铜灯盘 0.5, 青铜镜 0.6). The renderer draws every skeleton at one
* UNIT.modelScale, so an enemy's `modelScale` = its prefab's product / 0.27 (4 decimals; absent when 1).
* Values: tools/local-extract/enemy_scales.py over the local client (2026-10-01), keyed by prefab (enemy_database
* `prefabKey`, "enemy_" dropped) and grouped by the product; every other prefab of data/enemies.json is 0.27.
*/
const MODEL_SCALE_STANDARD = 0.27;
const MODEL_SCALES = new Map([
[0.16, ['1005_yokai_3']],
[0.18, ['1042_frostd']],
[0.19, ['1112_emppnt', '1112_emppnt_2']],
[0.2, ['1005_yokai', '1040_bombd', '1041_lazerd', '1041_lazerd_2']],
[0.216, ['1067_snslime']],
[0.22, ['1005_yokai_2', '1017_defdrn']],
[0.23, ['1158_divman', '1161_tidmag', '1161_tidmag_2']],
[0.24, ['1009_lurker', '1019_jshoot', '1019_jshoot_2', '1043_zomsbr', '1071_dftman', '1072_dlancer', '1116_liprr', '1116_liprr_2',
'1118_lidbox_2', '1160_hvyslr', '1160_hvyslr_2', '1162_magmot', '1165_duhond', '1165_duhond_2', '1168_dumage', '1168_dumage_2',
'1183_mlasrt', '1195_sfyin', '1195_sfyin_2', '1197_sfshu', '1197_sfshu_2', '1199_sfjin', '1203_sfhu', '1203_sfhu_2', '1207_sfji',
'1207_sfji_2', '1209_sfden', '1209_sfden_2', '1267_nhpbr', '1267_nhpbr_2', '1269_nhfly', '1270_nhstlk', '1270_nhstlk_2',
'1272_nhtank', '1272_nhtank_2', '1273_stmgun_2', '1275_dwlock_2', '1500_skulsr', '2002_bearmi', '2003_rockman', '2004_balloon',
'2005_axetro', '2008_flking', '2034_sythef']],
[0.25, ['1166_dusbr', '1166_dusbr_2', '1169_duphlx', '1169_duphlx_2', '1229_darmy', '1229_darmy_2']],
[0.26, ['1000_gopro_2', '1023_jmage', '1025_reveng', '1026_aghost', '1046_agent', '1249_lysdb_2', '1251_lysyta', '1251_lysyta_2',
'1252_lysytb_2', '1254_lypa_2', '1283_sgkill', '1283_sgkill_2', '1516_jakill', '1517_xi', '2001_duckmi']],
[0.28, ['1006_shield', '1010_demon', '1010_demon_2', '1061_zomshd', '1062_rager_2', '1069_icebrk_2', '1119_vofsd', '1170_dushld',
'1170_dushld_2', '1172_dugago', '1172_dugago_2', '1174_duholy', '1174_duholy_2', '1175_dushdo_2', '2025_syufo']],
[0.29, ['1081_sotisd', '1513_dekght', '1513_dekght_2']],
[0.297, ['2009_csaudc']],
[0.3, ['1001_bigbo', '1045_hammer', '1045_hammer_2', '1121_lifbos', '1121_lifbos_2', '1501_demonk', '1535_wlfmster']],
[0.31, ['1006_shield_2']],
[0.34, ['1006_shield_3']],
[0.35, ['1092_mdgint']],
[0.4, ['1196_msfyin', '1196_msfyin_2', '1198_msfshu', '1198_msfshu_2', '1202_msfzhi', '1202_msfzhi_2']],
[0.5, ['1208_msfji', '1208_msfji_2', '1210_msfden', '1210_msfden_2']],
[0.6, ['1200_msfjin', '1200_msfjin_2', '1204_msfhu', '1204_msfhu_2']],
]);
const MODEL_SCALE_BY_PREFAB = new Map();
for (const [v, list] of MODEL_SCALES) for (const k of list) MODEL_SCALE_BY_PREFAB.set(`enemy_${k}`, Math.round((v / MODEL_SCALE_STANDARD) * 1e4) / 1e4);
/**
* Build data/enemies.json: base stats at the season level (randomEnemyAttributeDict.level, 0 for
* all), with the season-wide override level (level_autochess_enemy_data) applied.
@@ -1638,6 +1743,7 @@ function buildEnemies(ctx) {
const name = mv(data.name) || hb?.name || key;
const descRaw = mv(data.description);
const hitArea = HIT_AREAS[mv(data.prefabKey) || key] || null;
const modelScale = MODEL_SCALE_BY_PREFAB.get(mv(data.prefabKey) || key) ?? null;
out[key] = {
key, name, level: wantLevel, rank: mv(data.levelType, 'NORMAL'), handbookIndex: hb?.enemyIndex || null,
desc: stripRich(descRaw), descRaw: richRaw(descRaw),
@@ -1658,6 +1764,7 @@ function buildEnemies(ctx) {
seasonOverride: override ? Object.keys(definedFields(override)) : null,
iconId: key, spine: mv(data.prefabKey) || key,
...(hitArea ? { hitArea: { ...hitArea } } : {}),
...(modelScale != null && modelScale !== 1 ? { modelScale } : {}),
};
}
return out;
@@ -2204,6 +2311,29 @@ function buildBosses(ctx, enemies, waves) {
const ROMAN = { I: 1, II: 2, III: 3 };
/**
* Why a bounty card is NOT offered by the 机变 悬赏决策 draft (null = it is), user playtest #6 item 4. The draft's card
* pools are server-side (effectChoiceInfoDict has no event → effect list), so the pool follows the curated PRTS table
* 卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段 "机变阶段·敌人轮选" (it reorders and hides entries of the data on purpose):
* 'perfect' 战术特训 (perfect payout, incl. 无人机护障·P / 法术大师A2·多轮战术特训): listed under
* "※以下悬赏任务仅由法术教鞭生成"
* 'hidden' 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏 (enemyeffect_5..8): commented out of the table (the 鸭爵 strategy
* “神秘顾客” swaps those enemies into the waves instead)
* Everything else is drafted, as the table lists it: the "下场作战" and "接下来两场作战" kill bounties, 源石虫·特训
* ("但不获得资金") and the 7 multi-round cards ("之后 / 后续的每场作战": 山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), whose
* official text carries the red "每场" (`multiRound`, `rounds` 99 kept as the data has them; the server makes such a
* card last MULTI_ROUND_BOUNTY_BATTLES = 2 battles and rewrites its text — server/match/choices.js bountyBattles /
* bountyText, the user's call after playtest #6). The 战术特训 cards are what the
* 教鞭 Art offers (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"; server/sim/content/items/meta.js); nothing in
* act2 offers the 鸭爵 set.
*/
const HIDDEN_BOUNTY_IDS = new Set(['enemyeffect_5', 'enemyeffect_6', 'enemyeffect_7', 'enemyeffect_8']);
function bountyDraftExclusion(e, main) {
if (main.payout !== 'kill') return 'perfect';
if (HIDDEN_BOUNTY_IDS.has(e.effectId)) return 'hidden';
return null;
}
/** Classify a 机变 choice event id into a family. */
function choiceFamily(ev) {
if (ev.choiceType === 'BOUNTY_HUNT' || ev.choiceType === 'PERSONAL_CHOOSE') return 'bounty';
@@ -2251,9 +2381,13 @@ function buildChoices(ctx, effects, items, chess) {
const multiRound = main.rounds >= 99;
if (multiRound) tier = 2;
for (const a of adds) if (a.enemyKey && !ctx.enemyDb.has(a.enemyKey)) warn(`bounty ${e.effectId}: unknown enemy ${a.enemyKey}`);
const coin = e.enemyPrice || main.coin;
const draftExcluded = bountyDraftExclusion(e, main);
if (draftExcluded === 'hidden' && !/鸭爵|高普尼克|流泪小子|圆仔/.test(e.name || '')) warn(`bounty ${e.effectId} "${e.name}": expected one of the 鸭爵 set`);
bounty.push({
effectId: e.effectId, name: e.name, desc: e.desc, tier, coin: e.enemyPrice || main.coin,
effectId: e.effectId, name: e.name, desc: e.desc, tier, coin,
payout: main.payout, rounds: main.rounds, multiRound, enemyKey: main.enemyKey, count: main.count, adds,
draft: !draftExcluded, draftExcluded,
});
}
bounty.sort((a, b) => naturalCmp(a.effectId, b.effectId));
@@ -2314,7 +2448,7 @@ function buildChoices(ctx, effects, items, chess) {
return {
events,
families: {
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'cards.bounty' },
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'cards.bounty entries with draft: true (PRTS 敌人轮选: no 战术特训 — the 教鞭 Art offers those — and no 鸭爵 set)' },
supply: { name: '道具补给', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items in schedule[*].supplyTiers (duplicates allowed)' },
shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items of any tier I–VI (duplicates allowed)' },
tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' },
@@ -2657,6 +2791,10 @@ function validateAll(f) {
for (const id of [...(p.items || []), ...(p.weighted || []).map((x) => x[0])]) if (!id || !(items[id] || chess[id])) err(`pool ${pid}: unresolved entry ${id}`);
}
for (const id of Object.keys(SHOP_EXCLUDED_ITEMS)) if (!items[id] || items[id].itemType !== 'EQUIP' || items[id].isGolden) err(`SHOP_EXCLUDED_ITEMS: ${id} is not a normal EQUIP item`);
for (const [id, fl] of Object.entries(TOKEN_ABNORMAL)) {
if (!tokens[id]) err(`TOKEN_ABNORMAL: ${id} is not a token`);
for (const f of fl) if (f !== 'healFree' && f !== 'isolated') err(`TOKEN_ABNORMAL: ${id}: unknown effect ${f}`);
}
for (const t of Object.values(tokens)) {
if (!t.stats) err(`token ${t.tokenId}: no stats`);
for (const [o, v] of Object.entries(t.variants || {})) if (!Array.isArray(v.sources) || !v.sources.length) err(`token ${t.tokenId}@${o}: no sources`);
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Official drawn size of the enemy models, read from a locally installed Arknights client (optional, host-side).
The client scales every enemy Spine model in its battle prefab (dyn/battle/prefabs/enemies/<prefab>.prefab, bundles
battle/enm_pfb_*.ab): world size = skeleton units x SkeletonDataAsset.scale (0.01 for every enemy skeleton) x the
product of the transform scales from the prefab root down to the GameObject that carries the Spine MeshRenderer
(Graphic / FaceSwitcher / Spine). That product is 0.27 for most prefabs (the standard the remake's UNIT.modelScale
stands for) and differs for the rest (威龙 0.16, 妖怪 0.20, 青铜镜 0.6 ...).
This script prints the product of every prefab named by data/enemies.json (`spine` = enemy_database prefabKey) that is
not the standard, grouped by value, as the MODEL_SCALES table of tools/build-data.mjs (user playtest #6 item 9).
Usage:
.venv/bin/python tools/local-extract/enemy_scales.py [--game <AB root>] [--json]
Needs UnityPy + lz4 (tools/local-extract/requirements.txt); --json prints {prefab: product} for every prefab found.
"""
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from extract import CANDIDATES, ROOT # noqa: E402
STANDARD = 0.27
def group_table(scales, standard=STANDARD):
"""{prefab: product} -> the build-data MODEL_SCALES lines ([value, ['1005_yokai_3', ...]] sorted by value)."""
groups = {}
for prefab, v in scales.items():
if v is None or abs(v - standard) < 1e-6:
continue
groups.setdefault(round(abs(v), 4), []).append(re.sub(r'^enemy_', '', prefab))
lines = []
for v in sorted(groups):
names = ', '.join("'%s'" % n for n in sorted(groups[v]))
lines.append(' [%s, [%s]],' % (('%g' % v), names))
return lines
def renderer_scale(root_go, by_id):
"""Product of the transform scales from a prefab root to its (active) Spine MeshRenderer; None when not exactly one."""
found = []
def walk(god, cum, depth):
tr = None
has_mr = False
for c in god['m_Component']:
r = by_id.get(c['component']['m_PathID'])
if r is None:
continue
if r.type.name == 'Transform':
tr = r.read_typetree()
elif r.type.name == 'MeshRenderer':
has_mr = True
if tr is None:
return
s = tr['m_LocalScale']
cum = (cum[0] * s['x'], cum[1] * s['y'])
if has_mr and god.get('m_IsActive', True):
found.append(abs(cum[0]))
if depth > 6:
return
for ch in tr['m_Children']:
cr = by_id.get(ch['m_PathID'])
if cr is None:
continue
g = by_id.get(cr.read_typetree()['m_GameObject']['m_PathID'])
if g is not None:
walk(g.read_typetree(), cum, depth + 1)
walk(root_go, (1.0, 1.0), 0)
return round(found[0], 5) if len(found) == 1 else None
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--game', help='AB root of the local client (default: the known install locations)')
ap.add_argument('--json', action='store_true', help='print {prefab: product} instead of the table')
args = ap.parse_args()
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
if not ab_root or not (ab_root / 'battle').exists():
sys.exit('enemy_scales: no local client found (pass --game <AB root>)')
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
enemies = json.loads((ROOT / 'data' / 'enemies.json').read_text('utf8'))
wanted = {e.get('spine') or k for k, e in enemies.items()}
scales = {}
for f in sorted((ab_root / 'battle').glob('enm_pfb*.ab')):
env = UnityPy.load(str(f))
by_id = {o.path_id: o for o in env.objects}
for name, obj in env.container.items():
m = re.search(r'/enemies/(.+)\.prefab$', name)
if m and m.group(1) in wanted:
scales[m.group(1)] = renderer_scale(obj.read_typetree(), by_id)
missing = sorted(wanted - set(scales))
if missing:
print('// no prefab found: ' + ', '.join(missing), file=sys.stderr)
if args.json:
print(json.dumps(scales, indent=1, sort_keys=True))
else:
print('\n'.join(group_table(scales)))
if __name__ == '__main__':
main()