feedback5: merge W5M — the nine 0.1.4 PRs (#150 #66 #146 #74 #72 #103 #149 #109 #160) and W4A (高台 per the trait, 阿戈尔 revive slots, 联防 devour) ported into the split layout

Conflicts: server/match/bot.js (W4A's comment + W5K's player data view pgd), test/golden/diy.json (regenerated). data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 the moved roster / bonds / fields / matches / standins scenarios are exactly W5M's per-PR attribution (#160 skill events, #109 deploy-timed lifecycle, #66 灵巧 sources and hidden layers, 83fc408 歌蕾蒂娅 placement), plus coop2-NORMAL-70-diy (fields operators those PRs touch) and diy-051 / diy-052 (events.skill only: Misery's deploy passives, #160); against fb5-merge4 only feedback5's own additions (the diy kits, coop2-NORMAL-70-diy).
This commit is contained in:
sganggs committed 2026-10-06 00:33:15 +08:00
commit 9ce5435373
74 files changed
+2966 -1338

No files matched your search

+26 -27
View File
@@ -4,8 +4,8 @@
// stage legend (data/stages.json `tiles[glyph]`: height + buildable) and the stage devices:
// 'melee' — LOW tile with buildable ALL/MELEE, or a 深水区 under an active 特制水上平台 (waterPlatform, "在水上建立可以
// 部署任意单位的平台"): melee AND ranged chess may stand here ("所有行动内远程干员可部署在近战位").
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and elite
// 歌蕾蒂娅 carrying HOK-Y, placeClass below).
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and the
// melee chess whose trait reads 「可以放置于远程位」, positionClass below).
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds, the 深水区 — the legend's
// `buildable` is the effective type: tile_deepsea refuses deployment, PRTS 深水区 地形信息
// "拒绝部署(待补充)", although its level buildableType is ALL; player report #3 after 0.1.0, 战场#08's
@@ -23,10 +23,10 @@
// (10–12, 8), which are '#' on the normal field. The result equals stages[id].deployTiles.bossLeft / bossRight
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
//
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except elite 歌蕾蒂娅 carrying module
// HOK-Y 淡金坠饰 (uniequip_003_glady: `placeClass` / shared/highGround.js ⇒ any deployable tile). The branch trait
// 「可以放置于远程位」 is not read (owner's decision 2026-10-04, reversing DESIGN §22.6): 崖心, 见行者, a normal record,
// any other module and no module are ground-only. The equipped module is the player's loadout at place time.
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except a MELEE chess whose trait reads
// 「可以放置于远程位」 — the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, any module or none
// (shared/highGround.js ⇒ any deployable tile; the owner's decision of 2026-10-05, following PRTS). The loadout does
// not change the class.
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
// 缪尔赛思's 流形; PRTS 狼群 特性) also needs a tile of its owner's attack range: `ownerRangeKeys` = the owner's range
@@ -135,41 +135,40 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
}
/**
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. Widened to 'all' only for elite 歌蕾蒂娅
* carrying HOK-Y (`moduleId` = uniequip_003_glady, shared/highGround.js): she may stand on the ranged (高台) tiles.
* Any other module, no module, and every other MELEE record stay 'melee'. `chess.json` has no `placement` field.
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. A MELEE chess whose trait reads
* 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心, 见行者, normal and elite) is widened to 'all': it may also
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. `chess.json` has no `placement` field.
* @param {object|null} rec
* @param {string|null} [moduleId] equipped module; ignored for tokens and non-golden records
*/
export function positionClass(rec, moduleId = null) {
return meleeOnHighGround(rec, moduleId) ? 'all' : basePositionClass(rec);
export function positionClass(rec) {
return meleeOnHighGround(rec) ? 'all' : basePositionClass(rec);
}
/**
* Placement class of a record under a player's loadout (the module `ps.loadoutFor` resolves, defaults included).
* Tokens and records without a chess id ignore the loadout. A chess the player fields as its stand-in (0.2.0 补位,
* `ps.fieldRecord`) is placed by the stand-in's position — the body that is deployed (圣约送葬人's 预备干员-先锋 stands on
* the ground, 塞雷娅's Touch anywhere) [ASSUMED: the official deploy check reads the fielded character].
* @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null, fieldRecord?: (rec: object) => object }|null} ps
* Placement class of a record on a player's board — the server's single entry point for pieces (g.move, swaps, merges,
* the audit, the invariants, the bot). Since the owner's decision of 2026-10-05 the player's loadout no longer changes
* it (any module, or none). A chess the player fields as its stand-in (0.2.0 补位, `ps.fieldRecord`) is placed by the
* stand-in's position — the body that is deployed (圣约送葬人's 预备干员-先锋 stands on the ground, 塞雷娅's Touch
* anywhere) [ASSUMED: the official deploy check reads the fielded character]; tokens and records without a chess id
* are their own body.
* @param {{ fieldRecord?: (rec: object) => object }|null} ps
* @param {object|null} rec
*/
export function placeClass(ps, rec) {
if (!rec || !rec.chessId || !ps || typeof ps.loadoutFor !== 'function') return positionClass(rec);
let moduleId = null;
let body = rec;
if (!rec || !rec.chessId || !ps || typeof ps.fieldRecord !== 'function') return positionClass(rec);
let body;
try {
const lo = ps.loadoutFor(rec);
moduleId = lo && typeof lo.moduleId === 'string' ? lo.moduleId : null;
if (typeof ps.fieldRecord === 'function') body = ps.fieldRecord(rec) || rec;
body = ps.fieldRecord(rec) || rec;
} catch {
moduleId = null;
body = rec;
}
return positionClass(body, moduleId);
return positionClass(body);
}
/**
* The record's own position class ('melee' | 'ranged' | 'all'), without the placement widening — the class the battle
* plays (a MELEE operator still blocks on a ground tile; on a 高台 it blocks nothing): the bots' blocker test and the
* tiles they plan for it (bot.js).
* plays (a MELEE operator still blocks on a ground tile; on a 高台 it blocks nothing): the bots' blocker test and their
* 高台 preference for a widened melee chess (bot.js isBlocker / planLayoutSteps).
*/
export function basePositionClass(rec) {
const p = rec && typeof rec.position === 'string' ? rec.position.toUpperCase() : 'ALL';
+7 -4
View File
@@ -34,8 +34,10 @@
// covering units against the round's DEF / RES, blocker hold time, flyers only for anti-air) is maximized
// greedily — blockers first, then damage dealers by DPS, then healers — over every
// (legal tile, direction) pair of the server's deploy map (no 深水区). A MELEE operator is planned on the ground
// tiles, where it blocks — except elite 歌蕾蒂娅 carrying HOK-Y, who may also take a 高台 (placeClass; owner's
// decision 2026-10-04). She stays a blocker in the lineup (basePositionClass). Each unit's range grid — the one it is
// tiles, where it blocks — except one whose trait reads 「可以放置于远程位」 (歌蕾蒂娅, 崖心, 见行者, any module:
// placeClass 'all', shared/highGround.js), which takes a 高台 whose range covers the enemy road, else the ground
// (owner 2026-10-04 for the 高台 preference, 2026-10-05 for who may use one). It stays a blocker in the lineup
// (basePositionClass). Each unit's range grid — the one it is
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — is rotated per direction (DESIGN §3; RIGHT is tried first
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
@@ -954,8 +956,9 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
let bestV = -Infinity;
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
// ground tiles for a MELEE blocker. placeClass 'all' is only elite 歌蕾蒂娅 + HOK-Y: she may stand on a 高台,
// and when one of those tiles covers the enemy road she is planned there (owner 2026-10-04: the bot uses the 高台).
// ground tiles for a MELEE blocker. placeClass 'all' on a MELEE record is a chess whose trait reads 「可以放置于远程位」
// (歌蕾蒂娅, 崖心, 见行者, any module): it may stand on a 高台, and when one of those tiles covers the enemy road it is
// planned there even with the ground free (owner 2026-10-04: the bot uses the 高台; 2026-10-05: every such chess).
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null;
+2 -1
View File
@@ -37,7 +37,8 @@ export class PlayerPlacement {
_placementOf(piece) {
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
// elite 歌蕾蒂娅 + HOK-Y may use a 高台; the module is this player's loadout (owner's decision 2026-10-04)
// a melee chess whose trait reads 「可以放置于远程位」 may use a 高台, whatever its module (shared/highGround.js); a
// stand-in is placed by its own record (placeClass)
return placeClass(this, rec);
}
+15 -6
View File
@@ -10,7 +10,8 @@
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit;
// killed members keep providing it from their body tile until redeploy; retreat / forcedExit do not
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s; tier 2: ×power_weak_scale
// when the target is below hp_ratio at application. ONE instance per target whatever applies it — the
// two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高:
@@ -31,7 +32,7 @@
// + 脆弱 ×damage_scale for weak[limit] s
// 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time)
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready — a passive skill that is on counts, GitHub
// #49) with no enemy in range → "保留技力立即再部署"
// #49, and so does a deploy-timed skill while it runs, #109) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy it can reach: on a free tile its position may be deployed
// on from which its range covers that enemy (GitHub issue #51 [ASSUMED]: the first of the 8 most
// advanced that has such a tile; none → it stays and the next poll looks again, never a jump that
@@ -220,10 +221,14 @@ function updateAura(battle, st) {
next.clear();
const offs = st.auraWide ? N8 : N4;
for (const m of st.members[ID.skillful]) {
if (!onField(m)) continue;
next.add(m);
const active = onField(m);
// isDown also covers voluntary / forced exits since v0.1.2. Only a kill keeps the aura.
if (!active && (m.removeReason !== 'killed' || !battle.isDown(m))) continue;
if (active) next.add(m);
// A knocked-out member still covers neighbours around the tile where it waits to redeploy.
const [r, c] = active ? [m.tileR, m.tileC] : battle.restTile(m);
for (const [dr, dc] of offs) {
const a = battle.unitAt(m.tileR + dr, m.tileC + dc);
const a = battle.unitAt(r + dr, c + dc);
if (a && a.kind === 'op' && a.ownerId === st.pid && onField(a)) next.add(a);
}
}
@@ -317,8 +322,12 @@ function raidPoll(battle, st) {
// 缄默德克萨斯 jumping within her passive's 10 s with no enemy in range). The engine keeps a passive on for the whole
// deployment, so such a member may jump whenever nothing is in its range [ASSUMED: "技能就绪" of a passive = the
// skill being on]; the landing rule below keeps it from hopping. Here only — the global `ready` stays as it is.
// A deploy-timed skill (#109: kind 'duration' with no SP — spType 'none' — run from the deployment: 宴 S2, 斯卡蒂 S2,
// 伊内丝 S3, 缄默德克萨斯 S1–S3, 耀骑士临光 S2, the generic "部署后…N秒内" passives) counts the same way while its
// window runs — the 10 s of the reporter's footage; once it has ended only the idle trigger is left.
const sk = u.skill;
const ready = !!(sk && !sk.noSkill && (sk.ready || (sk.kind === 'passive' && sk.active)) && !(sk.active && sk.isTimed));
const deploySkillOn = !!(sk && sk.active && (sk.kind === 'passive' || sk.spType === 'none'));
const ready = !!(sk && !sk.noSkill && ((sk.ready && !(sk.active && sk.isTimed)) || deploySkillOn));
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length) continue;
+43 -38
View File
@@ -16,8 +16,8 @@
// 拉特兰 lateranoShip member ammo skills start with floor(ammo × (1 + base_ammo_percent + ammo_percent_per_stack·L));
// 6: every ammo used by a member → all members ATK +atk_per_consume (≤ max_atk_for_consume)
// 阿戈尔 egirShip members max HP +(base_max_hp + max_hp_per_stack·L) (直接乘算); battle start devour (see devour());
// 5: the first max_free_respawn_cnt members by position (the devour's order, fixed at battle
// start) each redeploy at once (free) on their first knock-out
// 5: the first max_free_respawn_cnt members knocked out (in knock-out order, the devour's food
// included) each redeploy at once (free) on that first knock-out
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
// time, and while hidden / end_duration s after, every 普通伤害 hit (siracusaRolls) procs (PRD,
// nominal `prob`) base_damage + damage_per_stack·L true damage + fear `fear` s
@@ -34,7 +34,7 @@
// Two players in one field: everything is per player (own members, own deployments); the 谢拉格 wind and 炎佑 act on
// every enemy of the field (debuffs on shared enemies help both, research 06 §8.6).
// Hook priorities: 拉特兰 skillStart −10 (after kits' own ammo changes), 阿戈尔 death 11 (5-tier revive, before 不屈's
// death 10), layerGain −100 (after 魔王-style modifiers).
// death 10; every `fatal` saver runs before any death hook), layerGain −100 (after 魔王-style modifiers).
import * as S from '../support/index.js';
import { mitigate } from '../../damage.js';
@@ -334,15 +334,14 @@ function installLaterano(battle, pid, bb, members) {
// "更靠左和靠上": left first (on the player's own board: mirrored players count from the field's right), then top first —
// row 0 is the BOTTOM row (DESIGN §3), so the top of the board is the highest row index. The order is a board position,
// independent of the members' directions (only "身前" follows each member's `dir`). The devour marks in this order and the
// 5-tier revive's beneficiaries are the first members in it (PRTS "从最先部署(更靠左和靠上的)的【阿戈尔】干员开始"; the
// battle's initial deployment order, Battle.start).
// independent of the members' directions (only "身前" follows each member's `dir`). The devour marks in this order (PRTS
// "从最先部署(更靠左和靠上的)的【阿戈尔】干员开始"; the battle's initial deployment order, Battle.start).
const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC);
const egirOrder = (a, b) => boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id;
/**
* 联防: an operator forced out at the deployment (carry.down, Battle.start, before battleStart) still stands on its
* deploy position. The devour and the 5-tier revive slots both count it there (PRTS 盟约记录 前3名 / 最先部署).
* deploy position for the devour (PRTS 盟约记录 最先部署). The forced exit is not a knock-out, so it takes no 5-tier slot.
*/
function egirDownAtStart(battle, u) {
return S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true
@@ -365,11 +364,14 @@ function egirDownAtStart(battle, u) {
* out and back in the same pass still gives its marks).
* 联防: the operators down since the end of their own combat (forced out by Battle.start) mark, are marked and resolve
* their marks like standing ones, but nothing resolves on them (below; per players' reports, owner's decision 2026-10-04).
* Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Whose operator stands in front does not matter (PRTS "依次吞噬身前一格干员", no own-side limit; the owner's decision of
* 2026-10-05 after GitHub #140 comment 4): on a shared field (联防, boss) a teammate's operator — standing, or entering
* 联防 down — is marked like an own one, gives the same base ATK / block count, and the chain goes on through it when it
* is an 阿戈尔 (S.isMember: its own bonds). Its knock-out is its owner's (their bonds' revives, 不屈 …), credited to the
* marker as usual. Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Tokens / devices / empty tiles are never devoured.
*/
function devour(battle, pid, bb, members) {
const memberSet = new Set(members);
// 联防: an operator down at the end of its own combat (carryState.down — Battle.start forced it out right before
// battleStart, FORCED_EXIT) takes part in the devour as if it stood on its tile, then stays out: it marks in its turn,
// it is "the unit in front" of another (the chain goes on through it when it is a member), its base ATK / block count
@@ -378,12 +380,14 @@ function devour(battle, pid, bb, members) {
// report #3, GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke.
const downAtStart = (u) => egirDownAtStart(battle, u);
const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort(egirOrder);
// the operator in front, whoever owns it (until 0.1.3 only the player's own): a living one, else one lying there since
// the 联防 start (carry.down, forced out) — a teammate's included
const opAt = (u) => {
const [r, c] = S.frontTile(u);
const a = S.allyAt(battle, r, c, pid);
const a = S.allyAt(battle, r, c);
if (a) return S.isOp(a) && a.alive ? a : null;
const d = battle.downOn(r, c);
return d && d.ownerId === pid && downAtStart(d) ? d : null;
return d && downAtStart(d) ? d : null;
};
const markedBy = new Map(); // marker → [targets]
const marks = [];
@@ -398,7 +402,8 @@ function devour(battle, pid, bb, members) {
if ((markedBy.get(t) ?? []).includes(m)) continue;
seen.add(t);
mine.push(t);
if (memberSet.has(t)) queue.push(t);
// through a marked 阿戈尔 (for the player's own operators: exactly its members; a teammate's by its own bonds)
if (S.isMember(battle, t, 'egirShip')) queue.push(t);
}
markedBy.set(m, mine);
if (!mine.length) continue;
@@ -437,37 +442,37 @@ function installEgir(battle, pid, bb, members) {
};
apply();
onLayers(battle, pid, 'egirShip', apply);
// 5: "前3名【阿戈尔】干员首次被击倒时立刻复活" — the 3 (bonds.json max_free_respawn_cnt) are the first 3 members BY POSITION,
// in the devour's order above = the battle's deployment order (PRTS 盟约记录 阿戈尔 备注 "从最先部署(更靠左和靠上的)的
// 【阿戈尔】干员开始"; players' videos: the revivers go by position, not by who is eaten or knocked out first). They are
// fixed at battle start, before the devour; each revives once, on its own first knock-out ('killed'), whatever the order
// of knock-outs, so a member the devour knocks out comes back only when it is one of them. Until 0.1.3 the first 3
// members knocked out took the revives and the devour's food spent them at t = 0. A member entering 联防 down (forced
// out before battleStart) keeps the slot its position earned: the forced exit is not a 击倒 (reason !== 'killed'), so
// the charge waits until they are knocked out after standing back up. A 调和 member counts like any other.
// [ASSUMED] a beneficiary 埃芒加德 / M3茧甲 save in place (items revivedInPlace, the stand-in for PRTS's 0-time redeploy)
// has used its revive (PRTS 不屈 备注: a held 复活 is consumed even when another effect redeploys the unit at once).
// PRTS: the knocked-out unit's next deployment has 0 redeploy time and 0 cost, i.e. it IS knocked out (被击倒 triggers,
// 克莱门莎, 幽灵鲨 … fire) and redeploys at once where it lies (the engine's rest tile, Battle._layBody: the tile it was
// knocked out on — a raid-relocated member comes back where it fell —, or its own home when it fell on another board
// piece's home; PRTS 卫戍协议/帮助 §作战阶段 单位部署) with full HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古).
// Death priority 11: before 不屈 (10), whose redeploy "also consumes a 复活 charge" — with this order the charge is
// always the one used, same outcome. The marks still pending on a revived member are cancelled (devour).
// 5: "前3名【阿戈尔】干员首次被击倒时立刻复活" — the 3 (bonds.json max_free_respawn_cnt) slots go to the first 3 members
// knocked out, in knock-out order: the owner's decision of 2026-10-05, following players' reports (GitHub #105, #140:
// "没被吃的阿戈尔干员也会占用复活名额" — with 0.1.3's fixed holders by position the uneaten front members held the
// slots, so whatever the food chain ate only 3 阿戈尔 stood). A knock-out by the battle-start devour counts like any
// other, so the eaten food usually takes them at t = 0 and survivors = uneaten members + 3. A member takes at most one
// slot, on its first knock-out ('killed') in the battle: its later knock-outs never take one, and a first knock-out
// after the 3 are gone stays down. However many members mark it, its pending marks are cancelled once it is knocked
// out (devour), so a multi-devoured member spends one slot at most. Its own saver acts first: every kit, item or band
// revive that prevents or replaces the knock-out is a `fatal` hook (斯卡蒂's DRE-Y −50, kit savers 10 … −60, 坚固维式
// 重锤 PRIO_REVIVE −100, M3茧甲 PRIO_RESPAWN −101, 埃芒加德 −110), so the unit is never knocked out and no slot is used —
// the slot waits for its first real knock-out [ASSUMED for 埃芒加德, a band: as M3茧甲, the item revive the owner named].
// An operator entering 联防 down (FORCED_EXIT) is not knocked out either; a 调和 member counts like any other. Each
// battle (normal, 联防, boss) counts its own. PRTS: the knocked-out unit's next deployment has 0 redeploy time and 0
// cost, i.e. it IS knocked out (被击倒 triggers, 克莱门莎, 幽灵鲨 … fire) and redeploys at once where it lies (the
// engine's rest tile, Battle._layBody: the tile it was knocked out on — a raid-relocated member comes back where it
// fell —, or its own home when it fell on another board piece's home; PRTS 卫戍协议/帮助 §作战阶段 单位部署) with full
// HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10), whose redeploy "also
// consumes a 复活 charge" — with this order the slot is always the one used, same outcome. Until 0.1.3 the slots were
// fixed at battle start for the first 3 members by position (§23.20); until 0.1.2 the first 3 knocked out took them
// but a revived member's pending marks could knock it out again (GitHub #33, fixed in devour since).
const max = reached(battle, pid, 'egirShip', bb.power_bond_char_cnt) ? Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0))) : 0;
const holders = new Map(); // beneficiary → its in-place revives (mem.revives) at battle start
battle.on('battleStart', () => {
for (const u of members.filter((x) => S.isOp(x) && (S.onField(x) || egirDownAtStart(battle, x))).sort(egirOrder).slice(0, max)) holders.set(u, u.mem.revives | 0);
devour(battle, pid, bb, members);
}, { once: true });
battle.on('battleStart', () => devour(battle, pid, bb, members), { once: true });
if (!(max > 0)) return;
const used = new Set();
const memberOps = new Set(members.filter(S.isOp));
const knockedOut = new Set(); // members whose first knock-out of this battle has happened
let revives = 0;
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !holders.has(u) || used.has(u)) return;
used.add(u);
if ((u.mem.revives | 0) !== holders.get(u)) return; // saved in place since the battle start: the revive is gone
if (u.alive || u.removed) return;
if (c.reason !== 'killed' || !memberOps.has(u) || knockedOut.has(u)) return;
knockedOut.add(u);
if (revives >= max || u.alive || u.removed) return;
if (!battle.redeploy(u, { free: true })) return;
revives++;
S.fxOn(battle, 'revive', u, 'bond:egirShip', 'respawn', { n: revives });
+20 -18
View File
@@ -28,9 +28,10 @@
// "附带…凋亡/灼燃/神经损伤" + ep_damage_ratio ⇒ element damage on hit (× damage dealt when the text says "伤害N%的…损伤",
// else × ATK); "屏障" + shield_max_hp_ratio / hp_ratio ⇒ self shield at start decaying over its duration (砾, 新约能天使);
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴; for the skill's own duration
// when an ON_DEPLOY passive says "部署后…" without one in its blackboard: 一击即退, duration 10 — PRTS 技能 持续 10) and
// the self/counter effects above — their scales describe procs (bombs, sword rain, counters) that need a kit.
// Passive skills with stat mods + bb.duration + "N秒内" use a deployment duration (宴), and so do the ON_DEPLOY passives that
// say "部署后…" with the skill's own duration and none in their blackboard (一击即退, duration 10 — PRTS 技能 持续 10):
// kind 'duration', activateOnDeploy (#109); other passives apply stat mods and the self/counter effects above — their
// scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
// "立即获得N点部署费用" + cost ⇒ +cost DP for the player at the start (冲锋号令); an AUTO skill that does nothing else
// fires as soon as its SP is full (as 德克萨斯's kit casts the same skill: kits/ops/chess_char_1_08-texas.js).
// genericTalents(def): the talents a generic kit can apply exactly — an unconditional stat line ("攻击力+8%",
@@ -112,10 +113,8 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
if (!sk) return null;
const g = getter(bb);
const ga = attackGetter(bb);
const kind = genericKind(sk, bb);
let kind = genericKind(sk, bb);
const desc = String(sk.description || '');
const passive = kind === 'passive';
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
// "受到攻击时…造成…" numbers belong to a counter effect (the operator's own, or an ally's: 刺玫 "该角色受到攻击时")
const counterCtx = /受到(敌人的)?攻击时/.test(desc);
const counterText = counterCtx && !/该(角色|干员|单位)受到攻击时/.test(desc);
@@ -154,9 +153,21 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
set('resIgnoreFlat', g('magic_resist_penetrate_fixed'));
set('defIgnoreFlat', g('def_penetrate_fixed'));
// Effect inference keeps the original kind; a deployment window only changes the lifecycle.
const passive = kind === 'passive';
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
// a passive stat buff limited in time ("部署后…在14秒内攻击力+65%", 宴) runs as a duration skill from every deployment;
// so does an ON_DEPLOY passive whose duration is the skill's own (一击即退 "部署后攻击力+X%,防御力+Y%", duration 10, no bb key)
const ownDuration = num(bb.duration) === undefined && sk.duration > 0 && /^部署后/.test(desc) ? sk.duration : 0;
const passiveTimed = passive && Object.keys(mods).length
? (num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : ownDuration)
: 0;
if (passiveTimed) kind = 'duration';
// ---- targeting / attack override (never for passives: their scales describe procs)
const targeting = {};
const attack = {};
if (passiveTimed && /攻击[^。;]*(造成|变为|变成)[^。;]*法术伤害|伤害类型变为法术/.test(desc)) attack.dmgType = 'arts';
const mt = ga('max_target');
if (!passive && mt !== undefined && mt > 0) targeting.maxTargets = Math.floor(mt);
const ext = g('ability_range_forward_extend');
@@ -278,17 +289,12 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
id: sk.id,
name: sk.name,
kind,
duration: kind === 'duration' ? sk.duration : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
duration: kind === 'duration' ? (passiveTimed || sk.duration) : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
ammo,
};
// passive stat buffs limited in time ("部署后…在14秒内攻击力+65%", 宴) become a timed buff at each deployment; so do the
// ON_DEPLOY passives whose duration is the skill's own (一击即退 "部署后攻击力+X%,防御力+Y%", duration 10, no bb key)
const ownDuration = num(bb.duration) === undefined && sk.duration > 0 && /^部署后/.test(desc) ? sk.duration : 0;
const passiveTimed = passive && Object.keys(mods).length
? (num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : ownDuration)
: 0;
if (Object.keys(mods).length && !passiveTimed) spec.mods = mods;
if (passiveTimed) Object.assign(spec, { activateOnDeploy: true, spCost: 0, spType: 'none', trigger: 'NEVER' });
if (Object.keys(mods).length) spec.mods = mods;
if (Object.keys(targeting).length) spec.targeting = targeting;
// instant/charges skills act on the next attack: mods/targeting without an explicit attack still need one
if (!Object.keys(attack).length && (kind === 'instant' || kind === 'charges') && (spec.mods || spec.targeting)) spec.attack = {};
@@ -303,10 +309,6 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
// ---- start / end effects
const starts = [];
const ends = [];
if (passiveTimed) {
const m = { ...mods };
starts.push(({ battle, unit }) => battle.addBuff(unit, { key: `generic:passive:${sk.id ?? 'skill'}`, duration: passiveTimed, mods: m, tags: ['skill'] }));
}
if (gainsDp) {
starts.push(({ battle, unit }) => {
battle.addDp(unit.ownerId, dpGain);
@@ -12,7 +12,6 @@ export default {
// hp_recovery_per_sec_by_max_hp_ratio × max HP per second (the 生命回复速度 attribute: works under her 武者 no-heal).
chess_char_1_18_a: (bb, chess) => {
const t = talentBb(chess, 0);
const buffKey = 'utage:s2';
const s1 = skillBbOf(chess, 'skchr_utage_1');
return {
skills: {
@@ -24,16 +23,16 @@ export default {
},
},
skill: {
kind: 'passive',
kind: 'duration', activateOnDeploy: true, duration: num(bb.duration), spCost: 0, spType: 'none', trigger: 'NEVER',
mods: { atkPct: num(bb.atk) },
onStart({ battle, unit }) {
const loss = unit.hp * num(bb.hp_ratio);
if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit });
battle.addBuff(unit, { key: buffKey, duration: num(bb.duration), mods: { atkPct: num(bb.atk) }, tags: ['skill'], visible: true });
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'breach' });
},
},
talents: [{ install(battle, unit) {
onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.findBuff(buffKey)) dmg.type = 'arts'; });
onHitBy(battle, unit, ({ dmg }) => { if (dmg.isAttack && dmg.type === 'phys' && unit.skill?.id === 'skchr_utage_2' && unit.skill.active) dmg.type = 'arts'; });
const maxAs = num(t.min_attack_speed), minHp = num(t.min_hp_ratio);
if (maxAs > 0 && minHp < 1) {
battle.on('tick', () => {
@@ -25,11 +25,8 @@ export default {
return {
skills: {
skchr_wildmn_1: {
kind: 'passive',
onStart({ battle, unit }) {
const d = num(r1?.duration);
if (d > 0) battle.addBuff(unit, { key: 'wildmn:s1', duration: d, mods: { aspd: num(r1?.bb?.attack_speed) }, tags: ['skill'], visible: true });
},
kind: 'duration', activateOnDeploy: true, duration: num(r1?.duration), spCost: 0, spType: 'none', trigger: 'NEVER',
mods: { aspd: num(r1?.bb?.attack_speed) },
},
},
skill: {
@@ -15,7 +15,7 @@ export default {
const t = talentBb(chess, 0);
return {
skill: {
kind: 'passive',
kind: 'duration', activateOnDeploy: true, duration: num(bb.duration, 10), spCost: 0, spType: 'none', trigger: 'NEVER',
onStart({ battle, unit }) {
const total = unit.s.maxHp * num(bb.hp_ratio);
const dur = num(bb.duration, 10);
@@ -34,10 +34,11 @@ export default {
});
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'gravel:rats'); },
},
skills: {
skchr_gravel_1: {
kind: 'passive',
kind: 'duration', activateOnDeploy: true, duration: num(bb.duration), spCost: 0, spType: 'none', trigger: 'NEVER',
onStart({ battle, unit }) {
const v = num(bb.def), dur = num(bb.duration);
if (!(v > 0) || !(dur > 0)) return;
@@ -52,6 +53,7 @@ export default {
});
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'gravel:shadow'); },
},
},
talents: [{ install(battle, unit) {
@@ -20,10 +20,8 @@ export default {
skills: altSkills(chess, d, bb, {
'skcom_quickattack[3]': statSkill,
skchr_skadi_2: (s) => ({
kind: 'passive',
onStart({ battle, unit }) { // "部署后N秒内攻击力+X" (a passive starts at every deployment)
battle.addBuff(unit, { key: 'skill:skadi_wave', duration: num(s.bb.duration, 20), mods: { atkPct: num(s.bb.atk) }, visible: true });
},
kind: 'duration', activateOnDeploy: true, duration: num(s.bb.duration, 20), spCost: 0, spType: 'none', trigger: 'NEVER',
mods: { atkPct: num(s.bb.atk) },
}),
}),
talents: [
@@ -39,21 +39,9 @@ export default withDefaults({
},
}),
skchr_ines_3: () => ({
kind: 'passive',
kind: 'duration', duration: s3Dur, spCost: 0, spType: 'none', trigger: 'NEVER',
onStart({ battle, unit }) {
if (!unit.mem.inesS3Placed) {
// 首次部署: place a 影哨 (her talent's sentry, left on retreat) and leave; 立刻刷新再部署时间 — the redeploy
// (auto, paying her DP cost like every redeploy) follows as soon as it is affordable
unit.mem.inesS3Placed = true;
battle.after(0, () => {
if (!unit.alive || !unit.deployed) return;
battle.retreat(unit, { reason: 'retreat' });
unit.respawnAt = battle.time;
battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
return;
}
battle.addBuff(unit, { key: 'ines:s3', duration: s3Dur, mods: { atkPct: num(bb.atk) }, visible: true });
battle.addBuff(unit, { key: 'ines:s3', mods: { atkPct: num(bb.atk) }, visible: true });
// 立刻收回影哨: the sentry flies back to her and hits ≤ max_target enemies on its way (within
// projectile_range of the segment, [ASSUMED] nearest to its start first)
const from = unit.mem.inesSentryAt;
@@ -77,6 +65,7 @@ export default withDefaults({
}
battle.fx('beam', { x: ax, y: ay, tx: bx, ty: by, id: unit.id });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'ines:s3'); },
}),
}),
skill: {
@@ -146,11 +135,27 @@ export default withDefaults({
],
install(battle, unit) {
if (S3) {
battle.on('deploy', (c) => {
if (c.unit !== unit || c.move) return;
if (unit.mem.inesS3Placed) {
unit.skill.activate('deploy');
return;
}
// 首次部署 only places a 影哨 and retreats; it is not a skill activation.
// 立刻刷新再部署时间: redeploy as soon as its DP cost is affordable.
unit.mem.inesS3Placed = true;
battle.after(0, () => {
if (!unit.alive || !unit.deployed) return;
battle.retreat(unit, { reason: 'retreat' });
unit.respawnAt = battle.time;
battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
}, { owner: unit });
// where the talent's 影哨 stays (every leave but an expiry), for the recall of the next deployment
battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 5 });
// 技能期间每对一个敌人造成伤害就获得1点部署费用
battle.on('damaged', (c) => {
if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.findBuff('ines:s3')) return;
if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.skill?.active || !(unit.skill.timeLeft > 0)) return;
battle.addDp(unit.ownerId, num(bb.cost, 1));
}, { owner: unit });
}
@@ -18,17 +18,12 @@ export default withDefaults({
const g = grid(def.skill?.rangeGrid) || [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]];
const S1 = isSel(def, 'skchr_texas2_1'), S2 = isSel(def, 'skchr_texas2_2');
const dur = num(def.skill?.duration, S1 ? 11 : S2 ? 8 : 6);
const talentAtk = (battle, unit) => battle.addBuff(unit, { key: 'texas2:rainAtk', duration: dur, mods: { atkPct: num(t0.atk, 0.2) } });
const castS1 = (battle, unit) => {
if (!unit.alive || !unit.deployed) return;
talentAtk(battle, unit);
battle.addBuff(unit, { key: 'texas2:drizzle', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
battle.fx('swordRain', { x: unit.x, y: unit.y, id: unit.id });
};
const castS2 = (battle, unit) => {
if (!unit.alive || !unit.deployed) return;
talentAtk(battle, unit);
battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
const mr = num(bb.magic_resistance, 0);
for (const e of targetsInGrid(battle, unit, g)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
@@ -39,16 +34,14 @@ export default withDefaults({
};
const castS3 = (battle, unit) => {
if (!unit.alive || !unit.deployed) return;
talentAtk(battle, unit);
for (const e of targetsInGrid(battle, unit, g)) {
for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['appear.atk_scale'], 1.15), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb['appear.stun'], 1.5), source: unit });
}
battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id });
unit.mem.rainUntil = battle.time + dur;
unit.mem.rainTimer?.cancel();
unit.mem.rainTimer = battle.every(Math.max(0.1, num(bb['texas2_s_3[sword].interval'], 1)), (b, sched) => {
if (!unit.alive || !unit.deployed || b.time > unit.mem.rainUntil + 1e-9) { sched.cancel(); return; }
if (!unit.alive || !unit.deployed || !unit.skill?.active) { sched.cancel(); return; }
const list = targetsInGrid(b, unit, g);
sortEnemyTargets(b, unit, list, null);
for (const e of list.slice(0, Math.max(1, Math.floor(num(bb.max_target, 2))))) {
@@ -57,28 +50,62 @@ export default withDefaults({
b.fx('swordRain', { x: e.x, y: e.y, id: e.id });
}
}, { owner: unit });
// Duration advances by dt in the ally phase; align the rain with that clock so the final wave lands before end.
unit.mem.rainTimer.due -= battle.dt;
};
const cast = S1 ? castS1 : S2 ? castS2 : castS3;
const skill = {
kind: 'duration', activateOnDeploy: true, duration: dur, spCost: 0, spType: 'none', trigger: 'NEVER',
mods: { atkPct: num(t0.atk, 0.2) + (S1 || S2 ? num(bb.atk) : 0) },
onStart({ battle, unit, reason }) {
if (reason === 'kill' && unit.mem.texasRecastPending) {
unit.mem.texasRecastPending = false; // the synchronous burst already performed this recast
return;
}
unit.mem.texasCasting = true;
cast(battle, unit);
unit.mem.texasCasting = false;
},
onEnd({ unit, reason }) {
if (reason === 'recast' && unit.mem.texasRecastPending) return;
unit.mem.rainTimer?.cancel();
},
};
return {
skill: { kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } },
skill,
skills: alt(def, {
skchr_texas2_1: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }),
skchr_texas2_2: () => ({ kind: 'passive', onStart({ battle, unit }) { cast(battle, unit); } }),
skchr_texas2_1: () => skill,
skchr_texas2_2: () => skill,
}),
// S2: 攻击变为二连击 while the passive lasts
trait: S2 ? { hitsFn: (b, u) => (u.findBuff('texas2:shower') ? 2 : 1) } : undefined,
trait: S2 ? { hitsFn: (b, u) => (u.skill?.active ? 2 : 1) } : undefined,
talents: [
{ install(battle, unit) { // 德克萨斯传统 (2nd half): first kill of each deployment ⇒ full heal + recast
// (the passive's deploy burst runs inside skill.reset, BEFORE the `deploy` hook: the per-deployment state is
// therefore reset when she leaves the field, so a kill by the deploy burst itself counts)
battle.on('death', (c) => { if (c.unit === unit) unit.mem.texasKilled = false; }, { owner: unit });
battle.on('death', (c) => {
if (c.unit !== unit) return;
unit.mem.texasKilled = unit.mem.texasCasting = unit.mem.texasRecastPending = false;
}, { owner: unit });
battle.on('kill', (c) => {
if (c.killer !== unit || !unit.alive || c.victim.side !== 'enemy' || unit.mem.texasKilled) return;
unit.mem.texasKilled = true;
battle.removeBuff(unit, 'texas2:swordplay');
battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio, 1), { self: true });
cast(battle, unit);
if (unit.mem.texasCasting) {
unit.mem.texasRecastPending = true;
cast(battle, unit); // preserve the burst's synchronous reentry and damage order
} else {
unit.skill.end('recast');
unit.skill.activate('kill', { free: true });
}
}, { owner: unit });
battle.on('skillStart', (c) => {
if (c.unit !== unit || !unit.mem.texasRecastPending) return;
// Complete the lifecycle after observers received the outer start; the burst already recast above.
unit.skill.end('recast');
unit.skill.activate('kill', { free: true });
}, { owner: unit, priority: -2000 });
} },
{ install(battle, unit) { // 德克萨斯剑术: until her first kill after each deployment: ASPD +8, −25 % damage taken
battle.on('deploy', (c) => {
@@ -94,7 +121,7 @@ export default withDefaults({
const dot = num(bb['attack@texas2_s_1[dot].dot_damage'], 260), dotIv = Math.max(0.1, num(bb['attack@texas2_s_1[dot].interval'], 1));
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.findBuff('texas2:drizzle')) return;
if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !unit.skill?.active) return;
battle.applyStatus(e, 'silence', { duration: sil, source: unit });
battle.addBuff(e, { key: `texas2:drizzleDot:${unit.id}`, duration: dotDur + 1e-6, interval: dotIv, source: unit, refresh: 'extend', // a re-hit refreshes it, the per-second ticks keep their rhythm
onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: dot, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) });
@@ -103,7 +130,7 @@ export default withDefaults({
if (S2) {
// 攻击…造成法术伤害 while the passive lasts (the double hit is the kit trait's hitsFn)
battle.on('hit', (c) => {
if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.findBuff('texas2:shower')) c.dmg.type = 'arts';
if (c.source === unit && c.dmg.isAttack && c.dmg.type === 'phys' && unit.skill?.active) c.dmg.type = 'arts';
}, { owner: unit, priority: 50 });
}
const a = num(tb.atk, 0); // module (elite): ATK +10 % with no ally on the 4 adjacent tiles
@@ -46,23 +46,24 @@ function nearl2(bb, chess, def) {
// (whole hits negated); then she withdraws and this redeploy time is ×respawn_time (×1 when the operator deployed
// right before her is 【卡西米尔】)
skchr_nearl2_2: {
kind: 'passive',
kind: 'duration', activateOnDeploy: true, duration: num(def?.skill?.duration, 22), spCost: 0, spType: 'none', trigger: 'NEVER',
mods: { atkPct: num(bb.atk) },
onStart({ battle, unit, skill }) {
const dur = num(skill.duration, num(def?.skill?.duration, 22));
if (!(dur > 0)) return;
const prev = lastOps.map?.get(unit.ownerId);
const combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip');
battle.addBuff(unit, { key: 'nearl2:night', mods: { atkPct: num(bb.atk) }, duration: dur, visible: true });
unit.mem.nearl2Combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip');
const hits = Math.floor(num(bb.times));
if (hits > 0) battle.addBuff(unit, { key: 'nearl2:shield', shieldHits: hits, duration: dur, visible: true });
const seq = unit.deploySeq;
battle.after(dur, () => {
if (!live(unit) || unit.deploySeq !== seq) return;
battle.retreat(unit, { reason: 'retreat' });
const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1);
if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul;
battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo });
}, { owner: unit });
},
onEnd({ battle, unit, reason }) {
battle.removeBuff(unit, 'nearl2:shield');
if (reason !== 'duration' || !live(unit)) return;
const combo = unit.mem.nearl2Combo;
battle.retreat(unit, { reason: 'retreat' });
const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1);
if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul;
battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo });
},
},
};
+22 -1
View File
@@ -57,6 +57,12 @@ const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
/** Every tile of the stage (GDGLOW_SKILL_2: the whole field). */
const ALL_TILES = new Set(Array.from({ length: ROWS * COLS }, (_, i) => i));
/**
* The 'skill' animation window the sim reports to the client: `unit.skillAnimUntil` (snapshot.js `animOf` → ANIM.SKILL,
* and the `['skill', id, 1]` / `0` events). An instant cast and a deploy-time passive both use it.
*/
const SKILL_ANIM_WINDOW = 0.5;
export class SkillRuntime {
/**
* @param {object} battle
@@ -216,12 +222,27 @@ export class SkillRuntime {
this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true);
// a free (spCost 0) non-passive skill is available once per deployment
if (this.spCost <= 0) this.charges = this.maxCharges;
if (this.spec.activateOnDeploy) this.activate('deploy');
}
_startPassive() {
this.active = true;
this._applyMods();
this._call('onStart', { reason: 'passive' });
// A deploy-time passive (琳琅诗怀雅 S1 仗义疏财 / S2 “见面礼”: kind 'passive' with no duration) never goes through
// `activate()`, so the client saw no 'skill' event at all and its model never played the skill clip the manifest
// carries for it (player report follow-up: the 57 instant clips with no Begin / own Idle — 2 of them passives;
// 凯瑟琳 S1 is `kind: instant` in the sim and already casts). Fire the same bounded window an instant cast uses, so
// the actor plays that clip once and then goes back to its idle with attacks on the normal clip. A passive WITH a
// duration (缄默德克萨斯 S1–S3, 野鬃 S1, 伊内丝 S3, 耀骑士临光 S2) is left alone: the sim keeps it active until
// death, so "how long should its stance show" is a separate question.
if (!this.noSkill && !(this.duration > 0)) {
const b = this.battle;
const u = this.unit;
b._ev(['skill', u.id, 1]);
u.skillAnimUntil = b.time + SKILL_ANIM_WINDOW;
b.after(SKILL_ANIM_WINDOW, () => { if (u.alive) b._ev(['skill', u.id, 0]); }, { owner: u });
}
}
_applyMods() {
@@ -454,7 +475,7 @@ export class SkillRuntime {
if (this.spec.mods || this.spec.flags || this.spec.targeting) this._applyMods();
}
b._ev(['skill', u.id, 1]);
u.skillAnimUntil = b.time + 0.5;
u.skillAnimUntil = b.time + SKILL_ANIM_WINDOW;
this._call('onStart', { reason });
if (b._hooks.skillStart) b.emit('skillStart', { unit: u, skill: this, reason });
// bullets added in skillStart (拉特兰's ×(1.05 + 0.015 × layers), 逃犯引渡手续, talents): the bar's full mark
+5 -2
View File
@@ -96,13 +96,16 @@ export function animOf(u, t) {
/** Snapshot tuple for one unit. */
export function unitTuple(u, t) {
const sk = u.skill;
const spMax = sk && !sk.noSkill ? sk.spCost : 0;
let spMax = sk && !sk.noSkill ? sk.spCost : 0;
let sp = sk && !sk.noSkill ? sk.sp : 0;
if (sk && sk.active && sk.isTimed) {
// show remaining duration/ammo as a draining bar — ammo out of the activation's real total (base + bullets added:
// 拉特兰, 逃犯引渡手续, refills; community report #35), so every bullet shortens it
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammoMax || sk.ammo || 0, sk.ammoLeft));
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration);
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) {
if (spMax === 0) spMax = sk.duration;
sp = spMax * (sk.timeLeft / sk.duration);
}
}
// hp is rounded up (a living unit never shows 0) but never above the rounded max HP
const maxHp = Math.max(1, Math.round(u.s.maxHp));