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
+11
-6
@@ -27,11 +27,13 @@
|
||||
// 「可以放置于远程位」 — 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).
|
||||
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except
|
||||
// a token marked `rangedTilesOnly` ("仅可以部署在…远程位": 凯尔希·思衡托's 战术锚点) — class 'high', the ranged tiles only.
|
||||
// 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
|
||||
// grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its facing around its board tile
|
||||
// (PlayerState._legal; player report #9 after 0.1.0).
|
||||
// 缪尔赛思's 流形; PRTS 狼群 特性; Mon3tr's 重构体 by her talent) also needs a tile of its owner's attack range:
|
||||
// `ownerRangeKeys` = the owner's range grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its
|
||||
// facing around its board tile (PlayerState._legal; player report #9 after 0.1.0); one marked `ownerRangeOutside`
|
||||
// ("…攻击范围外": the 战术锚点) a tile outside that range.
|
||||
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
|
||||
// `parseDir` validates an intent's optional direction.
|
||||
|
||||
@@ -135,12 +137,14 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. A MELEE chess whose trait reads
|
||||
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all' | 'high'. 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.
|
||||
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. A token marked `rangedTilesOnly` (tokens
|
||||
* "仅可以部署在…远程位": 战术锚点) is 'high': the ranged tiles only. `chess.json` has no `placement` field.
|
||||
* @param {object|null} rec
|
||||
*/
|
||||
export function positionClass(rec) {
|
||||
if (rec && rec.rangedTilesOnly === true) return 'high';
|
||||
return meleeOnHighGround(rec) ? 'all' : basePositionClass(rec);
|
||||
}
|
||||
|
||||
@@ -183,6 +187,7 @@ export function canPlace(map, pos, r, c) {
|
||||
const cls = map.get(tileKey(r, c));
|
||||
if (!cls) return false;
|
||||
if (pos === 'melee') return cls === 'melee';
|
||||
if (pos === 'high') return cls === 'ranged';
|
||||
return true; // ranged / all: melee tiles and ranged tiles
|
||||
}
|
||||
|
||||
|
||||
+29
-3
@@ -119,6 +119,12 @@ const MAX_REFRESHES = 16;
|
||||
const BENCH_BUDGET = 6;
|
||||
/** Shop level the bot aims for at the start of round r (index = round). */
|
||||
const LEVEL_TARGET = [1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 6];
|
||||
/**
|
||||
* AI 托管 of a human with 自选 picks (0.2.0; bots field none): the buy score of one of the player's own slotted pieces —
|
||||
* the operator the player chose to field (follow-up #13: under AI 托管 they were rarely bought) — and the 调度中心 is
|
||||
* levelled one round earlier for the step that opens a slotted tier (levelUp).
|
||||
*/
|
||||
export const DIY_PIECE_BONUS = 12;
|
||||
const TIER_POWER = [0, 10, 12.5, 15, 18, 21.5, 25];
|
||||
/** Prep-side 特质 that keep adding bond layers every round / every refresh (a player's main layer engine). */
|
||||
const RECURRING_TRAIT_EVENTS = new Set(['SERVER_PREP_START', 'SERVER_PREP_FIN', 'SERVER_REFRESH_SHOP']);
|
||||
@@ -563,6 +569,11 @@ function chooseLineup(m, ps, ctx) {
|
||||
return { set, bench, score };
|
||||
}
|
||||
|
||||
/** buyScore with a fresh context (tests). */
|
||||
export function buyScoreOf(m, ps, id) {
|
||||
return buyScore(m, ps, id, context(m, ps));
|
||||
}
|
||||
|
||||
/** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */
|
||||
function buyScore(m, ps, id, ctx) {
|
||||
const gd = ps?.gd || m.gd;
|
||||
@@ -589,6 +600,8 @@ function buyScore(m, ps, id, ctx) {
|
||||
if (isHealer(c)) s += ctx.roles.healers === 0 && m.round >= 3 ? 6 : ctx.roles.healers >= 2 ? -14 : -3;
|
||||
if (c.attackKind === 'none' && !isHealer(c)) s -= 4;
|
||||
if (ctx.model && !isHealer(c)) s += ARMOR_WEIGHT * 0.6 * (armorFit(ctx.model, c) - 0.6);
|
||||
// the player's own 自选 piece (a slotted slot: its composed record carries diyFor) — AI 托管 of a human with picks
|
||||
if (c.diyFor) s += DIY_PIECE_BONUS;
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -956,16 +969,18 @@ 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;
|
||||
// and an outside-bound one (凯尔希·思衡托's 战术锚点): never a tile of its owner's range
|
||||
const without = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
|
||||
// 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 cls = p.kind === 'token' ? positionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
|
||||
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
|
||||
let bestHigh = null;
|
||||
let bestHighV = -Infinity;
|
||||
for (const [r, c] of legalTiles(map, cls)) {
|
||||
const k = tileKey(r, c);
|
||||
if (taken.has(k) || (within && !within.has(k))) continue;
|
||||
if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue;
|
||||
const noise = m.rngBots() * 1e-6;
|
||||
const seen = new Set();
|
||||
for (const dir of PLAN_DIRS) {
|
||||
@@ -1383,7 +1398,16 @@ function maybeFreeze(m, ps) {
|
||||
if (ps.shop.slots.some((s) => s && !s.sold && s.kind === 'chess' && ps.priceOf(s) > ps.funds && ps.completesChessMerge(s.id))) tryDo(() => ps.freeze());
|
||||
}
|
||||
|
||||
function levelUp(m, ps, { spare = false } = {}) {
|
||||
/** Whether level `lv` of the 调度中心 opens one of the player's slotted 自选 pieces with copies left (player/diy.js). */
|
||||
export function diyOpensAt(ps, lv) {
|
||||
const st = ps.diyStock;
|
||||
if (!st || !st.entries || !st.entries.size) return false;
|
||||
for (const e of st.entries.values()) if (e.shopLevel === lv && e.left > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The 调度中心 level-ups of a bot's prep (exported for tests). */
|
||||
export function levelUp(m, ps, { spare = false } = {}) {
|
||||
const gd = ps?.gd || m.gd;
|
||||
for (let guard = 0; guard < 6; guard++) {
|
||||
if (ps.shop.level >= gd.maxShopLevel) return;
|
||||
@@ -1396,6 +1420,8 @@ function levelUp(m, ps, { spare = false } = {}) {
|
||||
const boardReady = ps.allChess().length >= (r <= 4 ? ps.deployCap : Math.min(ps.deployCap, 6));
|
||||
let want = price === 0;
|
||||
if (!want && ps.shop.level < target && (boardReady || r >= 6)) want = true;
|
||||
// a human's 自选 slots of the next level (AI 托管): that step one round earlier (DIY_PIECE_BONUS)
|
||||
if (!want && boardReady && ps.shop.level < nextTarget && diyOpensAt(ps, ps.shop.level + 1)) want = true;
|
||||
if (!want && ps.shop.level < nextTarget && price <= 2 && boardReady) want = true;
|
||||
if (!want && spare && ps.funds >= price + 1 + fundsReserve(m, ps) && ps.shop.level < nextTarget + 1 && boardReady) want = true;
|
||||
if (!want && ps.funds >= price + 14) want = true;
|
||||
|
||||
@@ -482,9 +482,13 @@ function applyDefault(m, ps, card) {
|
||||
handled = true;
|
||||
break;
|
||||
case 'single_special_choice_gain_bond_chess': {
|
||||
// the player's 自选 stock joins the draw, its bonds read through the player's view (player/diy.js diyStockEntries)
|
||||
const pgd = ps.gd || gd;
|
||||
const hasBond = (cid) => { const c = pgd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); };
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } })
|
||||
|| m.pool.roll(m.rngMeta, { maxTier: 6, filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } });
|
||||
const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null;
|
||||
const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: hasBond, extra })
|
||||
|| m.pool.roll(m.rngMeta, { maxTier: 6, filter: hasBond, extra });
|
||||
if (id) ps.acquireChess(id, { source: 'choice' });
|
||||
}
|
||||
handled = true;
|
||||
|
||||
@@ -544,20 +544,30 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
});
|
||||
return p ? view(p) : null;
|
||||
},
|
||||
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
|
||||
/**
|
||||
* Random chess id from the shared pool (copy-weighted) — and the player's own 自选 stock (player/diy.js
|
||||
* diyStockEntries, 0.2.0: 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」). opts: { maxTier, tier, bond, filter(id) };
|
||||
* bonds are read through the player's data view (a slotted slot: its operator's).
|
||||
*/
|
||||
rollChess: (opts = {}) => {
|
||||
const f = (id) => {
|
||||
if (opts.bond) { const c = gd.chess(id); if (!c || !Array.isArray(c.bonds) || !c.bonds.includes(opts.bond)) return false; }
|
||||
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
|
||||
};
|
||||
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f });
|
||||
const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null;
|
||||
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra });
|
||||
},
|
||||
rollItem: (opts = {}) => m.rollItemId(opts),
|
||||
/**
|
||||
* Roll a choices.json pool: equip pools → { kind: 'item', id }; chess pools (items / weighted / shopEligible with
|
||||
* tier, minTier, bond, golden) → { kind: 'chess', id, golden } (a pool chess needs a free copy). null when empty.
|
||||
*/
|
||||
rollPool: (poolId, opts = {}) => m.rollPool(poolId, { shopLevel: ps.shop.level, ...opts }),
|
||||
rollPool: (poolId, opts = {}) => m.rollPool(poolId, {
|
||||
shopLevel: ps.shop.level,
|
||||
// a shared-pool draw also takes the player's 自选 stock, its bonds read through the player's view (rollChess)
|
||||
extra: typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null, chessOf: (id) => gd.chess(id),
|
||||
...opts,
|
||||
}),
|
||||
/**
|
||||
* Run another owned chess's 特质 of `eventType` now (SERVER_GAIN / SERVER_PREP_START / SERVER_PREP_FIN /
|
||||
* SERVER_CHESS_SOLD / SERVER_REFRESH_SHOP). opts.asUid: run them as if they belonged to that piece. Returns the
|
||||
|
||||
@@ -128,6 +128,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
|
||||
const range = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
|
||||
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
|
||||
// an outside-bound summon (战术锚点, PlayerState.summonExcluded) outside it
|
||||
const out = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
|
||||
if (out && out.has(k)) fail(`${id}: ${p.id} on ${k}, inside its owner's attack range`);
|
||||
if (p.kind === 'chess') deployed++;
|
||||
}
|
||||
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
|
||||
|
||||
@@ -152,9 +152,11 @@ export class MatchSpDraft {
|
||||
* Roll a choices.json pool (ctx.rollPool). Equip pools → rollItemId. Chess pools: an `items` (uniform) or `weighted`
|
||||
* list — only chess with a free pool copy (or outside the pool) qualify — else a copy-weighted draw from the shared
|
||||
* pool filtered by `tier` / `minTier` / `maxTier` (number or 'shopLevel') / `bond`; `golden: true` yields the elite id.
|
||||
* `extra` (the drawing player's 自选 stock, player/diy.js diyStockEntries) joins that shared-pool draw, `chessOf` reads the
|
||||
* bonds (the player's data view: a slotted slot's operator) — 0.2.0 WE2.
|
||||
* @returns {{ kind: 'item'|'chess', id: string, golden?: boolean } | null}
|
||||
*/
|
||||
rollPool(poolId, { shopLevel = 6 } = {}) {
|
||||
rollPool(poolId, { shopLevel = 6, extra = null, chessOf = null } = {}) {
|
||||
const pools = this.gd.choices.pools && typeof this.gd.choices.pools === 'object' ? this.gd.choices.pools : {};
|
||||
const p = typeof poolId === 'string' && Object.hasOwn(pools, poolId) ? pools[poolId] : null;
|
||||
if (!p || typeof p !== 'object') return null;
|
||||
@@ -181,10 +183,12 @@ export class MatchSpDraft {
|
||||
const maxTier = p.maxTier === 'shopLevel' ? lvl : Number.isInteger(p.maxTier) ? p.maxTier : 6;
|
||||
const minTier = Number.isInteger(p.minTier) ? p.minTier : 1;
|
||||
const bond = typeof p.bond === 'string' ? p.bond : null;
|
||||
const recOf = typeof chessOf === 'function' ? chessOf : (cid) => this.gd.chess(cid);
|
||||
id = this.pool.roll(rng, {
|
||||
tier: Number.isInteger(p.tier) ? p.tier : null,
|
||||
maxTier,
|
||||
filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(this.gd.chess(cid)?.bonds) && this.gd.chess(cid).bonds.includes(bond))),
|
||||
filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(recOf(cid)?.bonds) && recOf(cid).bonds.includes(bond))),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
if (!id) return null;
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
// shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once
|
||||
// the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at
|
||||
// level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any
|
||||
// chess of its tier: 4). Effects that grant a random operator from the shared pool never draw it.
|
||||
// chess of its tier: 4).
|
||||
// * random grants (diyStockEntries, 0.2.0 WE2 #9): an effect, reward or 机变 card that grants this player a random
|
||||
// operator from the pool draws its stock too — 「自选干员放入后模拟中的补给池随机范围也将被相应扩大」 (bilibili), "调度中心
|
||||
// 随机资源的范围将被扩大" (PRTS 新手教程) — under the roll's own tier rules (the effect's tier / maxTier: no 调度中心 gate,
|
||||
// as for a preset chess) and filters (bonds read through the player's data view).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
@@ -95,8 +99,10 @@ export function diyGameData(gd, records) {
|
||||
token: { value: (id) => gd.token(id) || tokenOf(id) },
|
||||
/**
|
||||
* GameData.placeableTokens for a slotted slot: the summons its record lists (the pick's skill and talents) that are
|
||||
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count, the
|
||||
* active module's own when its variant has one (`byModule`: 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子).
|
||||
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count (PRTS
|
||||
* 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量)"), the active module's own when its variant has one (`byModule`:
|
||||
* 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子; SUM-Y stage 2+ 4 drones / summons). The data's deploy limit holds the
|
||||
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影.
|
||||
*/
|
||||
placeableTokens: {
|
||||
value: (chessId, loadout = null) => {
|
||||
@@ -202,6 +208,17 @@ export class PlayerDiy {
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stock entries a random grant of this player draws besides the shared pool (see the header): every slotted piece
|
||||
* with copies left, whatever the 调度中心 level (the roll's own tier rules apply), or null (none).
|
||||
* @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null}
|
||||
*/
|
||||
diyStockEntries() {
|
||||
if (!this.diyStock || !this.diyStock.entries.size) return null;
|
||||
const out = [...this.diyStock.entries].filter(([, e]) => e.left > 0);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 自选 pick a chess id (normal or elite) of this player fields — its slot's — or null (not a slotted slot).
|
||||
* @param {string} id
|
||||
|
||||
@@ -44,14 +44,18 @@ export class PlayerPlacement {
|
||||
|
||||
/**
|
||||
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
|
||||
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形), a tile of its owner's
|
||||
* attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a
|
||||
* summon swapped with its own owner).
|
||||
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
|
||||
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
|
||||
* (summonExcluded). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
|
||||
* with its own owner).
|
||||
*/
|
||||
_legal(piece, r, c, owner = null) {
|
||||
if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false;
|
||||
const k = tileKey(r, c);
|
||||
const range = this.summonRange(piece, owner);
|
||||
return !range || range.has(tileKey(r, c));
|
||||
if (range && !range.has(k)) return false;
|
||||
const out = this.summonExcluded(piece, owner);
|
||||
return !out || !out.has(k);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +70,24 @@ export class PlayerPlacement {
|
||||
*/
|
||||
summonRange(piece, owner = null) {
|
||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
return this._ownerRangeOf(piece, owner);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'r,c' keys an outside-bound summon may NOT stand on — its owner's attack range (tokens `ownerRangeOutside`: PRTS
|
||||
* 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位"; 0.2.0 WE2) — or null (not outside-bound, or the owner is off the
|
||||
* board). Same range as summonRange.
|
||||
* @param {any} piece
|
||||
* @param {{ key: string, piece: any, dir: string } | null} [owner]
|
||||
* @returns {Set<string> | null}
|
||||
*/
|
||||
summonExcluded(piece, owner = null) {
|
||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRangeOutside !== true) return null;
|
||||
return this._ownerRangeOf(piece, owner);
|
||||
}
|
||||
|
||||
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
|
||||
_ownerRangeOf(piece, owner = null) {
|
||||
let at = owner;
|
||||
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
|
||||
const chess = at && this.gd.chess(at.piece.id);
|
||||
@@ -86,17 +108,23 @@ export class PlayerPlacement {
|
||||
* battle. Returns the number taken off the board; a toast names them.
|
||||
*/
|
||||
_liftOutOfRange() {
|
||||
const back = [], gone = [];
|
||||
const back = [], gone = [], backOut = [], goneOut = [];
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token') continue;
|
||||
const range = this.summonRange(p);
|
||||
if (!range || range.has(k)) continue;
|
||||
const out = this.summonExcluded(p);
|
||||
const inside = !!(out && out.has(k));
|
||||
if (!inside && (!range || range.has(k))) continue;
|
||||
this.board.delete(k);
|
||||
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
|
||||
const ok = this._returnToken(p, null, { allowTemp: true });
|
||||
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
|
||||
}
|
||||
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
||||
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
|
||||
return back.length + gone.length;
|
||||
// an outside-bound summon (战术锚点) its owner's new range now covers
|
||||
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
|
||||
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
|
||||
return back.length + gone.length + backOut.length + goneOut.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +139,8 @@ export class PlayerPlacement {
|
||||
for (const [k, p] of this.board) {
|
||||
if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue;
|
||||
const range = this.summonRange(p, at);
|
||||
if (range && !range.has(k)) needSlot.add(p.id);
|
||||
const out = this.summonExcluded(p, at);
|
||||
if ((range && !range.has(k)) || (out && out.has(k))) needSlot.add(p.id);
|
||||
}
|
||||
return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
// Targets: bond_by_id / bond_self (own active bonds) / bond_actived_maxstack;
|
||||
// amounts: by_count / by_charcount_samerow / by_charlevel; conditions character_same_row /
|
||||
// character_same_col (≥ check_count incl. self). Gains go through support.gainLayers with
|
||||
// reason 'garrison', source = the trait's owner, cap = max_add_count_per_battle per (instance, bond).
|
||||
// reason 'garrison', source = the trait's owner, cap = max_add_count_per_battle per (instance, bond)
|
||||
// — per instance for bond_actived_maxstack (塑心: the highest bond may change, the cap does not).
|
||||
// act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front
|
||||
// of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile)
|
||||
// — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap).
|
||||
@@ -191,10 +192,20 @@ export function fireGain(battle, it) {
|
||||
if (!conditionMet(battle, it)) return 0;
|
||||
const bonds = targetBonds(battle, it);
|
||||
if (!bonds.length) return 0;
|
||||
const n = amountOf(battle, it);
|
||||
let n = amountOf(battle, it);
|
||||
// bond_actived_maxstack (塑心 garrison_90 "当前已激活且层数最多的盟约层数+1(每场作战至多10层)"): the cap is the instance's
|
||||
// own over the battle, whichever bond is the highest at each gain — per (instance, bond) a change of the highest bond
|
||||
// granted a fresh 10 (PR #178 review). The base amount counts toward it (魔王's extra does not, as in gainLayers).
|
||||
const whole = it.bbStr.bond_type === 'bond_actived_maxstack' && Number.isFinite(it.cap);
|
||||
if (whole) {
|
||||
n = Math.min(Math.floor(n), Math.max(0, Math.floor(it.cap) - (it.wholeUsed ?? 0)));
|
||||
if (!(n > 0)) return 0;
|
||||
}
|
||||
const added = S.gainLayers(battle, {
|
||||
playerId: it.unit.ownerId, bonds, n, requireActive: true, source: it.unit, reason: 'garrison', cap: it.cap, capKey: it.capKey,
|
||||
playerId: it.unit.ownerId, bonds, n, requireActive: true, source: it.unit, reason: 'garrison',
|
||||
cap: whole ? Infinity : it.cap, capKey: whole ? null : it.capKey,
|
||||
});
|
||||
if (whole && added > 0) it.wholeUsed = (it.wholeUsed ?? 0) + n;
|
||||
if (added > 0) S.fxOn(battle, 'garrison', it.unit, `gar:${it.gid}`, it.key, { n: added });
|
||||
return added;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ operators, the self-select 6★ operators, contributions from GitHub issue #136
|
||||
| `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits |
|
||||
| `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) |
|
||||
| `shared/summoner.js` | the 召唤师 summon deck the 自选 kits of 麦哲伦 / 令 / 电弧 share (`summonDeck`: the holding, a placed piece's return, recalls, the summons leaving with their owner; `holdBuff`, `tokenStat`) |
|
||||
| `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor |
|
||||
|
||||
`content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less
|
||||
id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). A 补位 stand-in's
|
||||
@@ -184,8 +183,10 @@ pick chooses any of the three, so write every skill under `skills: { [skillId]:
|
||||
both tiers, both forms and both module stages. A 自选 piece has no 特质 and its bonds come from its factions: neither is
|
||||
the kit's business. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnToken(unit, …)` resolve the variant of the
|
||||
pick. A **placeable** summon (data/backups.json `tokens[id].placeable`, made by the pick's skill or a talent — the
|
||||
variant's `sources`) is a hand piece like any operator's: when the 自选 piece is deployed in prep its player gets one
|
||||
stack of the variant's `deployLimit` — the picked module's own when its variant has one (望's TRP-X: 7 棋子) —
|
||||
variant's `sources` — and shown by its owner: a skill's own object such as 予愿安洁莉娜's “一会儿见!” is not) is a hand piece like any operator's: when the 自选 piece is deployed in prep its player gets one
|
||||
stack of the variant's `deployLimit` — the picked module's own when its variant has one (望's TRP-X: 7 棋子); it holds
|
||||
the token's own talent additions (max_deploy_count / max_deck_stack_cnt, tools/build-data.mjs `tokenTalentDeckBonus`:
|
||||
麦哲伦 / 令 3, SUM-Y stage 2+ 4, 白铁 2, 夜莺 3), so a deck reads `deployLimit` / `deckStack` as they are —
|
||||
(server/match/player/diy.js `placeableTokens`, PlayerState `grantTokensFor`), places
|
||||
it on a legal tile (the token's position / `ownerRange`), loses it with its owner (sold, merged, moved back), and the
|
||||
match hands every placed piece to the battle as a PlayerBattleInput token unit `{ kind: 'token', tokenId, ownerUid }` —
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
// abilities). Off her deployment tile, “一会儿见!” marks it (备注 "每次移动后若不位于初始位置且初始位置不存在标志物…部署一个").
|
||||
// When the skill ends she goes back there by a 【移动】 (the marker leaves) unless she is knocked out.
|
||||
// - “一会儿见!” (its kit below): 无敌, 孤立, 不可选中 by enemies, blocks nothing, no attack (PRTS 备注; token_aglna2_passive).
|
||||
// It is the skill's placeholder (skchr_aglna2_3 `_placeholderTokenKey`), not a player's summon: the data marks it
|
||||
// placeable (the prep hands a piece out with S3 — open question), so a placed piece is used as that marker and never
|
||||
// deploys by itself (`deferDeploy`; its tile stays reserved); without one the skill summons it.
|
||||
// It is the skill's placeholder (skchr_aglna2_3 `_placeholderTokenKey`), not a player's summon: no owner shows it, so the
|
||||
// data does not mark it placeable (tools/build-data.mjs summonRecord, 0.2.0 WE2 — until then the prep handed a piece out
|
||||
// with S3 and this kit used it as the marker) and the skill summons it each time.
|
||||
|
||||
import { num, talentBb, skillRec, batMod, up, installAura, toggleBuff } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
@@ -174,13 +174,11 @@ export default {
|
||||
}
|
||||
function placeMarker(battle, unit) {
|
||||
if (!st || (st.marker && st.marker.alive)) return;
|
||||
const piece = battle.allyUnits.find((t) => t.kind === 'token' && t.defId === MARKER && t.ownerUnit === unit && t.uid != null && !t.alive && !t.removed);
|
||||
if (piece && battle.redeploy(piece, { free: true, tile: [st.r0, st.c0] })) { st.marker = piece; return; }
|
||||
st.marker = battle.spawnToken(unit, MARKER, st.r0, st.c0, { kit: markerKit() });
|
||||
}
|
||||
function dropMarker(battle) {
|
||||
const m = st && st.marker;
|
||||
if (m && m.alive) battle.retreat(m, { reason: 'expired', permanent: m.uid == null });
|
||||
if (m && m.alive) battle.retreat(m, { reason: 'expired', permanent: true });
|
||||
if (st) st.marker = null;
|
||||
}
|
||||
/** Her S3 range for where she stands: the 3-9 ∪ x-4 at home; away, the x-4 here + the anchored 3-9 (extra keys). */
|
||||
@@ -297,13 +295,6 @@ export default {
|
||||
if (ctx.target !== unit || !RESIST_STATUSES.has(ctx.status) || (ctx.source && ctx.source.side === 'ally')) return;
|
||||
if (unit.findBuff(CHANT_KEY) || unit.findBuff(MOVE_KEY)) ctx.cancel = true;
|
||||
}, { owner: unit });
|
||||
// a placed “一会儿见!” piece is S3's marker: it never deploys by itself
|
||||
for (const t of battle.allyUnits) {
|
||||
if (t.kind !== 'token' || t.defId !== MARKER || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||||
if (t.kit) battle.offOwner(t);
|
||||
battle._setupUnit(t, markerKit());
|
||||
if (sid === S3) t.deferDeploy = true;
|
||||
}
|
||||
if (sid !== S3) return;
|
||||
const dr = num(b3.damage_resistance), slow = -num(b3.move_speed);
|
||||
const aspdB = num(b3['aglna2_s_3[blocked].attack_speed']), aspdU = num(b3['aglna2_s_3[unblocked].attack_speed']);
|
||||
|
||||
@@ -47,8 +47,10 @@
|
||||
// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path
|
||||
// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied
|
||||
// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too),
|
||||
// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's
|
||||
// ACTIVE_RANGE rule cannot see it (reported, not hand-coded).
|
||||
// to that unit's facing. Trigger: the data's DEFAULT, plus — the owner's ACTIVE_RANGE rule (2026-10-05: a MANUAL skill
|
||||
// whose running range strictly contains her own casts with an enemy inside that range), which no data range can carry
|
||||
// for an unbounded path that bends — the beam's path as a content trigger range (skill.addTriggerRange: a targetable enemy
|
||||
// on the path she would fire along now, air units too as the beam hits them; 0.2.0 WE2, follow-up #16, O1's report).
|
||||
// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械
|
||||
// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its
|
||||
// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the
|
||||
@@ -285,6 +287,8 @@ export default {
|
||||
battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
|
||||
battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
|
||||
battle.on('tick', () => refreshPath(battle, unit), { owner: unit });
|
||||
// S2: the beam's path is its trigger range too (the owner's larger-range rule, see the header)
|
||||
if (unit.skill?.id === S2) unit.skill.addTriggerRange(() => (up(unit) ? [aphrisPath(battle, unit, true).tiles.map(([r, c]) => r * COLS + c)] : []));
|
||||
// S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly
|
||||
// checks `disarm` before the cast only) — no target, no attack, no bullet spent
|
||||
battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit });
|
||||
|
||||
@@ -39,7 +39,10 @@
|
||||
// (PRTS "大幅度缩短(*0.2)": batMod's ratio reading), every attack attack@atk_scale × ATK; the energies released meanwhile
|
||||
// use that scale too (备注 "该技能的“每次攻击的攻击倍率”会实时应用在特性积攒的“攻击能量”抛射物上") × the energy scale.
|
||||
// `attack@cnt` (1) has no text [unused].
|
||||
// - S2 荒芜回响 (AUTO, instant, data DEFAULT — an AUTO skill whose effect acts on enemies keeps the data rule): spends every
|
||||
// - S2 荒芜回响 (AUTO, instant): cast at full SP as soon as one placeable tile of his attack range is free — no enemy needed
|
||||
// (PRTS 备注 "攻击范围内不存在可部署位时,技能不会被触发"; the skill prefab's `_trigger._minTileNum` 1 — O20's report; 0.2.0 WE2:
|
||||
// until then the data's DEFAULT waited for an enemy in his range; `trigger: 'NEVER'` + this kit's cast, the pattern of
|
||||
// 伺夜's conditional casts): spends every
|
||||
// stored energy (elite ones first: 备注 "可以消耗并优先消耗第一天赋储存的额外能量") and places energies + 1 旧日残影 on free
|
||||
// placeable tiles of his attack range (部署位置 全部位), in the 备注's order: tiles holding a selectable ground enemy (the
|
||||
// best of them first by his target order — 仇恨值 [ASSUMED: the engine's default operator order]), then the tiles nearest
|
||||
@@ -88,6 +91,15 @@ export const eliteOrLeader = (e) => !!e && (!!e.isBoss || e.def?.rank === 'ELITE
|
||||
/** His stored energies (per deployment): `n` normal, `e` elite, `q` the volleys in flight per target id. */
|
||||
const stateOf = (unit) => unit.trait.ebn ?? (unit.trait.ebn = { n: 0, e: 0, q: new Map() });
|
||||
|
||||
/** A tile 荒芜回响 may place a 旧日残影 on: inside the field, deployable (全部位) and free (Battle.isReservedTile). */
|
||||
const remnantTileOk = (battle, r, c) => battle.grid.inRect(r, c) && battle.grid.canStand(r, c, { ranged: true }) && !battle.isReservedTile(r, c);
|
||||
|
||||
/** Whether `unit`'s attack range holds one tile 荒芜回响 may use (its cast condition; no rng, unlike remnantTiles). */
|
||||
function hasRemnantTile(battle, unit) {
|
||||
for (const k of unit.rangeKeys || []) if (remnantTileOk(battle, Math.floor(k / COLS), k % COLS)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles 荒芜回响 may place 旧日残影 on (PRTS 备注 order): free deployable tiles of `unit`'s attack range — first those
|
||||
* holding a selectable ground enemy, by the best of them in his target order; then by the distance to the nearest
|
||||
@@ -97,8 +109,7 @@ function remnantTiles(battle, unit) {
|
||||
const tiles = [];
|
||||
for (const k of unit.rangeKeys || []) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
|
||||
tiles.push([r, c]);
|
||||
if (remnantTileOk(battle, r, c)) tiles.push([r, c]);
|
||||
}
|
||||
if (!tiles.length) return tiles;
|
||||
const foes = battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND));
|
||||
@@ -231,6 +242,7 @@ export default {
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'instant',
|
||||
trigger: 'NEVER', // the kit's cast (install): full SP and one free placeable tile in his range
|
||||
onStart({ battle, unit, skill }) {
|
||||
const tiles = remnantTiles(battle, unit);
|
||||
if (!tiles.length) { skill.addCharge(1); return; } // "攻击范围内不存在可部署位时,技能不会被触发"
|
||||
@@ -260,6 +272,14 @@ export default {
|
||||
},
|
||||
},
|
||||
install(battle, unit) {
|
||||
// S2 荒芜回响: cast at full SP with one free placeable tile in his range, no enemy needed (see the header)
|
||||
if (unit.skill?.id === S2) {
|
||||
battle.on('tick', () => {
|
||||
const sk = unit.skill;
|
||||
if (!sk || !sk.ready || sk.active || !up(unit) || !unit.canAct || unit.s.flags.silence) return;
|
||||
if (hasRemnantTile(battle, unit)) sk.activate('SP_FULL');
|
||||
}, { owner: unit });
|
||||
}
|
||||
// MSC-Y: ASPD while any energy is stored
|
||||
const aspd = num(hidden.attack_speed);
|
||||
if (aspd) toggleBuff(battle, unit, 'trait:ebnhlz:stored', () => { const v = stateOf(unit); return v.n + v.e > 0; }, { aspd });
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// 单位反弹伤害,伤害为法术普通伤害"), PRTS 卫戍协议/帮助 技能策略 (her S3 row "于技能持续时间过半后…关闭技能(进入其技能效果的
|
||||
// 临死模式)" added in 上半, REMOVED with 下半; "通常不会自动关闭技能").
|
||||
// - Trait (驭法铁卫) "技能开启时普通攻击会造成法术伤害": physical attacks while no skill runs (the kit's trait dmgType — the
|
||||
// data's 'arts' is the skill-on type) and arts while one runs (every skill's attack override); block 3, ground-only
|
||||
// data's 'phys' too since 0.2.0 WE2 — build-data classifyAttack) and arts while one runs (every skill's attack override); block 3, ground-only
|
||||
// melee on her 1-1. Module AST-X 无迹 adds "且攻击和受到攻击时对目标额外造成10%攻击力的法术伤害" (trait bb atk_scale): every
|
||||
// damage instance of her normal attacks (skill attacks included; not the shield, a counter or the module's own damage)
|
||||
// is followed by atk_scale × ATK arts on that enemy (附加伤害: no dodge), and every enemy attack she takes returns
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
// - S3 铁钳号·原型机 (MANUAL, DEFAULT): 30 s, ATK +atk, ASPD +attack_speed; +1 stock at the start.
|
||||
// [ASSUMED] the stock refilled by +cnt on each deployment, capped at cnt + the CRA-X +1; a RechargeToken beyond the cap is lost;
|
||||
// the crab charges only from the attacks that select it (an ally's area skill that picks enemies does not see it); summons
|
||||
// attack it too. Open (data, report): the hand count of his devices is the data's deployLimit 1 — tools/build-data.mjs takes
|
||||
// the attribute frame's maxDeployCount and leaves out the token's hidden talent max_deploy_count +1 (E2 "最多可部署2个").
|
||||
// attack it too. The hand count of his devices is the data's deployLimit 2 (the attribute frame's 1 + the token's hidden talent
|
||||
// max_deploy_count 1 — E2 "最多可部署2个"; tools/build-data.mjs tokenTalentDeckBonus, 0.2.0).
|
||||
|
||||
import { num, talentBb, talentGrid, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
// the move be refused, she comes back on her own tile [ASSUMED; officially she is knocked out].
|
||||
// At the skill's end any anchor of hers on the field is withdrawn and the stock emptied (token_ctrl). The flight's begin
|
||||
// clip and its 生命回复速度 ×0 are not modelled [ASSUMED].
|
||||
// - 战术锚点 (token_10068_kalts2_mtship, placed in prep — "仅可以部署在凯尔希·思衡托攻击范围外的远程位" is the match's
|
||||
// placement rule): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown.
|
||||
// - 战术锚点 (token_10068_kalts2_mtship, placed in prep on a ranged tile outside her attack range — "仅可以部署在凯尔希·思衡托
|
||||
// 攻击范围外的远程位": the data's `ownerRangeOutside` / `rangedTilesOnly`, enforced by the match, 0.2.0 WE2): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown.
|
||||
// A placed piece is docked by content/tokens.js (a skill's summon: deployed for free at the battle start, then only when
|
||||
// her S3 gives one and its 70 s redeploy time has passed, on its own tile).
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
// target her; her summons leave the field with her (shared/summoner.js).
|
||||
// - T1 挑灯问梦 "可以使用5个召唤物(最多同时部署3个),功能随技能选择而改变": the summon of her pick's skill (“清平” / “逍遥” /
|
||||
// “弦惊” — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js with charge =
|
||||
// cnt (5), cap = the summon deck (max_deck_stack_cnt 5, +3 with SUM-Y), at most 1 + max_deploy_count (2 ⇒ 3; SUM-Y stage 3:
|
||||
// 4) standing. Every summon holds 禁疗 (PRTS; [ASSUMED: given here — the data's `abnormal` lacks it]) and fights with its
|
||||
// cnt (5), cap = the summon record's deckStack (5: its hidden talent's max_deck_stack_cnt, tools/build-data.mjs
|
||||
// tokenTalentDeckBonus; +3 with SUM-Y), at most its deployLimit standing (3 = 1 + the talent's max_deploy_count 2; SUM-Y
|
||||
// stage 2+: 4) — the hand count too. Every summon holds 禁疗 (PRTS; [ASSUMED: given here — the data's `abnormal` lacks it]) and fights with its
|
||||
// own data: “清平” blocks 1 and strikes in melee (physical) on its 1-1, “逍遥” shoots arts on its 3-1 (air units too), “弦惊”
|
||||
// blocks 2 and strikes every enemy it blocks (physical; its trait "攻击阻挡的所有敌人").
|
||||
// - T2 随付笺咏醉屠苏 "召唤物被击倒/吸收/回收时令额外获得3点技力、攻击力+3%(攻击力加成最多叠加5层)": a summon knocked out,
|
||||
@@ -74,7 +75,6 @@ const TAG_S3 = 'ling:s3';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const skillOn = (u, id) => !!(u && u.skill && u.skill.active && u.skill.id === id);
|
||||
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
|
||||
const tileOf = (u) => u.tileR * COLS + u.tileC;
|
||||
|
||||
/** Her S1 / S3 acting on a summon (held from each skill start / end and each summon deployment). */
|
||||
@@ -217,16 +217,13 @@ export default {
|
||||
talents: [
|
||||
{ install(battle, unit) { // 挑灯问梦: the deck of her summon pieces and their kit
|
||||
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
|
||||
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
|
||||
const deckBase = num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, cnt);
|
||||
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
|
||||
const sp = num(t1.sp), atk = num(t1.atk), stacks = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
|
||||
summonDeck(battle, unit, {
|
||||
tokenIds: SOULS,
|
||||
charge: cnt,
|
||||
start: sumY,
|
||||
cap: deckBase + num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : 0, 0),
|
||||
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
|
||||
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt),
|
||||
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1),
|
||||
kit: (t) => soulKit(t, unit, opts),
|
||||
// 随付笺咏醉屠苏: knocked out / absorbed / recalled ⇒ +sp SP and a stack of ATK +atk (not when they leave with her)
|
||||
onLeave(t, kind) {
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
// PRTS "可对空"), blocks 1, ground enemies target her. Her drones leave the field with her (shared/summoner.js).
|
||||
// - T1 支援无人机·龙腾 "可以使用5个不阻挡敌人的无人机(最多同时部署3个),功能随技能选择而改变": the drone of her pick's skill
|
||||
// (龙腾.F / .L / .A — the skills' overrideTokenKey) is the hand piece the player places; the deck of ../shared/summoner.js
|
||||
// with charge = cnt (5) at each of her deployments, cap = the drone deck (its hidden talent's max_deck_stack_cnt 5, +the
|
||||
// SUM-Y deckStack 3), at most 1 + its max_deploy_count (2 ⇒ 3; SUM-Y stage 3: 3 ⇒ 4) standing. Every drone holds 禁疗
|
||||
// with charge = cnt (5) at each of her deployments, cap = the drone record's deckStack (5: its hidden talent's
|
||||
// max_deck_stack_cnt, tools/build-data.mjs tokenTalentDeckBonus; SUM-Y +3), at most its deployLimit standing (3 = 1 + the
|
||||
// talent's max_deploy_count 2; SUM-Y stage 2+: 4) — the hand count too. Every drone holds 禁疗
|
||||
// (PRTS; the data's `abnormal` lacks it [ASSUMED: given here]), blocks nothing, has no skill of its own here (its 回收 is
|
||||
// her skills' end, below). 龙腾.F (ATK 0) makes no attack (the engine's rule for a 0-ATK summon; a commented-out PRTS
|
||||
// note "缴械,普通攻击不造成伤害" agrees [ASSUMED]); 龙腾.L strikes one ground enemy on its own tile for arts damage; 龙腾.A
|
||||
@@ -135,15 +136,12 @@ export default {
|
||||
talents: [
|
||||
{ install(battle, unit) { // 支援无人机·龙腾: the deck of her drone pieces and their kit
|
||||
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
|
||||
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
|
||||
const deckBase = num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, cnt);
|
||||
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
|
||||
summonDeck(battle, unit, {
|
||||
tokenIds: DRONES,
|
||||
charge: cnt,
|
||||
start: sumY,
|
||||
cap: deckBase + num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : 0, 0),
|
||||
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
|
||||
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt),
|
||||
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1),
|
||||
kit: (t) => droneKit(t, unit, opts),
|
||||
});
|
||||
// her skills on the standing drones: at each start / end (a drone deployed meanwhile syncs itself)
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
// stage 3: the next attack@chain.extra_cnt jumps after that neither). Her heals pass 禁疗 ("治疗效果无视禁疗"); her selection
|
||||
// takes her own 重构体 through its 禁疗 (`healThrough`), no other 禁疗 unit [ASSUMED: the jumps too].
|
||||
// - Module XAH-X 记忆存档: the trait's 0.85 per jump (traitOverride); stage 3 changes T1 (below); ATK / DEF from the attributes.
|
||||
// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon). "可以在攻击范围内的地面使用" — a
|
||||
// ground tile of her attack range — is not in the token data (`ownerRange` false), so the match does not hold the piece to
|
||||
// it and the battle takes it where it stands [open question]. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt
|
||||
// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon) on a ground tile of her attack
|
||||
// range — "可以在攻击范围内的地面使用": the data's `ownerRange` from that talent text (tools/build-data.mjs summonRecord,
|
||||
// 0.2.0 WE2) and its MELEE position, enforced by the match. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt
|
||||
// −1, loses 8 HP every 0.1 s from 1 s after it lands (流失, Battle.loseHp — PRTS "每0.1s流失8生命值"; trait damage_per_second
|
||||
// 80); every ally of its
|
||||
// 3×3 but itself (no 孤立 one) ATK +atk (0.15; XAH-X stage 3 0.25; 同名效果取最高). Gone (destroyed or withdrawn) ⇒ back on its
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
// - Modules SO-A 电弧特勤证章 / SO-B 新起点: their trait and talent parts act "在【岁的界园志异】中" only (the client gates them to
|
||||
// the 集成战略 theme rogue_yan / validInGameTag roguelike): N/A here — the module talents the composed record carries
|
||||
// (SO-A's hidden respawn_time / prob / sp, SO-B's attack@max_target and x-5 recall) are ignored; their attributes are in the
|
||||
// stats. (DIY_EXCLUDED_MODULE_TYPE refuses ISW-* only, so a roster may carry them — see the report.)
|
||||
// stats. A player's roster never carries them (shared/diy.js DIY_EXCLUDED_MODULE_TYPE, 0.2.0 WE2); the kit fields them for
|
||||
// its tests.
|
||||
|
||||
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
|
||||
import { summonDeck, holdBuff, tokenStat } from '../shared/summoner.js';
|
||||
@@ -231,8 +232,6 @@ export default {
|
||||
talents: [
|
||||
{ install(battle, unit) { // 卡带里的灵感: the deck of her summon pieces and their kit
|
||||
const cnt = Math.max(0, Math.floor(num(t0.cnt)));
|
||||
const tdef = tokenId ? battle.tokenDef(tokenId, unit) : null;
|
||||
const extra = num(tokTalent(tdef, 'max_deploy_count')?.bb?.max_deploy_count, 0);
|
||||
const ratios = { atk: num(t1.atk), def: num(t1.def), hp: num(t1.max_hp) };
|
||||
const inspireOne = (s) => {
|
||||
if (!up(unit)) return;
|
||||
@@ -250,8 +249,8 @@ export default {
|
||||
summonDeck(battle, unit, {
|
||||
tokenIds: TOWERS,
|
||||
charge: cnt,
|
||||
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt) + num(tokTalent(tdef, 'max_deck_stack_cnt')?.bb?.max_deck_stack_cnt, 0),
|
||||
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1) + extra,
|
||||
cap: num(tokenId ? tokenStat(battle, unit, tokenId, 'deckStack') : cnt, cnt),
|
||||
maxDeployed: num(tokenId ? tokenStat(battle, unit, tokenId, 'deployLimit') : 1, 1),
|
||||
kit: (t) => towerKit(t, unit, opts),
|
||||
});
|
||||
// her skills on the standing summons at each start / end (a summon deployed meanwhile syncs itself); 鼓舞 again
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
// a tile at a Manhattan distance ≥ max_distance from it, it is put back on that tile's centre (unblocked, its route
|
||||
// re-planned from there; waypoints already passed stay passed), stealth or 无敌 notwithstanding; a 消失 (hidden) enemy loses
|
||||
// its mark and every mark ends with the skill (the client attaches it to shu_s_3). No enemy of the mode holds
|
||||
// 免疫传送 (data otherImmunities). Trigger: the data rule is TRY_SEARCH_ALLY_SKILL (her own row, PRTS "技能范围内存在可治疗的
|
||||
// 我方单位时释放技能"), which the engine does not know (an unknown rule acts as DEFAULT): this kit maps it onto the engine's
|
||||
// SKILL_RANGE + `allies` on x-2 (an injured, healable allied unit of the skill range, herself included; 古米 S1's option).
|
||||
// 免疫传送 (data otherImmunities). Trigger: the data's — her own row TRY_SEARCH_ALLY_SKILL (PRTS "技能范围内存在可治疗的我方
|
||||
// 单位时释放技能"), which tools/build-data.mjs (TRIGGER_ALLY_RULES) writes as the engine's SKILL_RANGE + `allies` on the
|
||||
// skill range x-2 (an injured, healable allied unit there, herself included; 古米 S1's option).
|
||||
|
||||
import { num, traitBb, skillRec, batMod, up, giveSp } from '../shared/tier1.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
@@ -164,7 +164,6 @@ export default {
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
trigger: { rule: 'SKILL_RANGE', grid: g3, allies: true },
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeGrid: g3 },
|
||||
onStart({ unit }) { unit.mem.shuMarks = new Map(); },
|
||||
|
||||
@@ -54,8 +54,10 @@
|
||||
// 备注 "每次部署薇薇安娜时,重新计算该技能的使用次数" / "首次技能结束后,本技能的技能范围永久扩大至3-2"; vvana_s_3[check]: a
|
||||
// skill triggered before ⇒ the enhance mark and duration_plus = enhance_duration, vvana_s_3: mode 4 instead of 3): from her
|
||||
// second cast of a deployment the skill runs enhance_duration s with S3_LATER_HITS hits on the 3-2 range (her own grid while
|
||||
// it runs: the card shows it). The trigger stays the data's DEFAULT on her own range for every cast — the later casts'
|
||||
// larger running range is no data range (reported, not changed: kits/README.md checklist 5).
|
||||
// it runs: the card shows it). Trigger: the data's DEFAULT on her own range for the first cast of a deployment; once it
|
||||
// has ended the owner's ACTIVE_RANGE rule (2026-10-05: a MANUAL skill whose running range strictly contains her own casts
|
||||
// with an enemy inside that range) on the 3-2 — no data range carries it, so the kit sets it (skill.setTrigger, 0.2.0 WE2,
|
||||
// follow-up #16; O11's report) — and DEFAULT again at her next deployment.
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js';
|
||||
import { isElite, elementDmg, enemiesIn } from '../shared/tier6.js';
|
||||
@@ -168,9 +170,11 @@ export default {
|
||||
skill.timeLeft = Math.max(0.01, num(b3.enhance_duration, skill.duration));
|
||||
setGrid(battle, unit, RANGE_3_2);
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
onEnd({ battle, unit, skill }) {
|
||||
if (unit.mem.vvanaS3Later) setGrid(battle, unit, unit.def?.rangeGrid);
|
||||
unit.mem.vvanaS3Later = false;
|
||||
// its later casts run on the 3-2: from now the owner's ACTIVE_RANGE rule there (see the header)
|
||||
if ((unit.mem.vvanaS3Casts ?? 0) >= 1) skill.setTrigger('ACTIVE_RANGE', RANGE_3_2);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,6 +262,7 @@ export default {
|
||||
unit.mem.vvanaS3Casts = 0;
|
||||
unit.mem.vvanaS3Later = false;
|
||||
setGrid(battle, unit, own);
|
||||
if (unit.skill?.id === S3) unit.skill.setTrigger('DEFAULT', null); // the first cast of a deployment: the data's rule
|
||||
}, { owner: unit });
|
||||
}
|
||||
// AFT-D: 元素伤害 at the output of every damage of hers on an enemy in its 灼燃 爆发冷却 (not on this rider's own)
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// Sources: the client's battle data (charpack char_248_mgllan / char_2023_ling / char_4195_radian: Talents/1
|
||||
// charge_token[born] — RechargeToken `cnt` at every deployment of the owner; CommonAbilities die_to_kill_token — KillTokens
|
||||
// when the owner leaves; the SUM-Y equips mgllan_equip_2_* / ling_equip_1_*: trigger_charge_token — RechargeToken the
|
||||
// trait's `cnt` once, on the undeployed card (`_attachPassiveBuffsOnDummy`); the token decks: max_deck_stack_cnt /
|
||||
// max_deploy_count of the tokens' hidden talent, deckStack / deployLimit of the token records); PRTS 分支特性信息 召唤师
|
||||
// trait's `cnt` once, on the undeployed card (`_attachPassiveBuffsOnDummy`); the token decks: deckStack / deployLimit of
|
||||
// the token records, which carry the tokens' hidden talent max_deck_stack_cnt / max_deploy_count — tools/build-data.mjs
|
||||
// tokenTalentDeckBonus); PRTS 分支特性信息 召唤师
|
||||
// ("干员离场后,附属的召唤物随之消失"); PRTS 卫戍协议/帮助 §作战阶段 (placed summons: "所有手动部署的召唤物,无视所属干员的持有
|
||||
// 状态,不消耗持有数量,作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个").
|
||||
//
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// server/sim/content/kits/tier1.js — re-export shim (0.2.0 refactor, R4): the tier-1 kits live one per file in ops/,
|
||||
// registered by index.js; their shared helpers and notes are in shared/tier1.js (README.md). This module keeps the
|
||||
// old imports working until the shims are removed after the 0.2.0 refactor wave.
|
||||
import { TIER_KITS } from './index.js';
|
||||
|
||||
export * from './shared/tier1.js';
|
||||
export { tinmanKit } from './ops/chess_char_1_16-tinman.js';
|
||||
export default TIER_KITS[0];
|
||||
@@ -1,6 +0,0 @@
|
||||
// server/sim/content/kits/tier2.js — re-export shim (0.2.0 refactor, R4): the tier-2 kits live one per file in ops/,
|
||||
// registered by index.js; their shared helpers and notes are in shared/tier2.js (README.md). This module keeps the
|
||||
// old imports working until the shims are removed after the 0.2.0 refactor wave.
|
||||
import { TIER_KITS } from './index.js';
|
||||
|
||||
export default TIER_KITS[1];
|
||||
@@ -1,6 +0,0 @@
|
||||
// server/sim/content/kits/tier3.js — re-export shim (0.2.0 refactor, R4): the tier-3 kits live one per file in ops/,
|
||||
// registered by index.js; their shared helpers and notes are in shared/tier3.js (README.md). This module keeps the
|
||||
// old imports working until the shims are removed after the 0.2.0 refactor wave.
|
||||
import { TIER_KITS } from './index.js';
|
||||
|
||||
export default TIER_KITS[2];
|
||||
@@ -1,6 +0,0 @@
|
||||
// server/sim/content/kits/tier4.js — re-export shim (0.2.0 refactor, R4): the tier-4 kits live one per file in ops/,
|
||||
// registered by index.js; their shared helpers and notes are in shared/tier4.js (README.md). This module keeps the
|
||||
// old imports working until the shims are removed after the 0.2.0 refactor wave.
|
||||
import { TIER_KITS } from './index.js';
|
||||
|
||||
export default Object.freeze({ ...TIER_KITS[3] });
|
||||
@@ -1,6 +0,0 @@
|
||||
// server/sim/content/kits/tier5.js — re-export shim (0.2.0 refactor, R4): the tier-5 kits live one per file in ops/,
|
||||
// registered by index.js; their shared helpers and notes are in shared/tier5.js (README.md). This module keeps the
|
||||
// old imports working until the shims are removed after the 0.2.0 refactor wave.
|
||||
import { TIER_KITS } from './index.js';
|
||||
|
||||
export default TIER_KITS[4];
|
||||
@@ -1,7 +0,0 @@
|
||||
// server/sim/content/kits/tier6.js — re-export shim (0.2.0 refactor, R4): the tier-6 kits live one per file in ops/,
|
||||
// registered by index.js; their shared helpers and notes are in shared/tier6.js (README.md). This module keeps the
|
||||
// old imports working until the shims are removed after the 0.2.0 refactor wave.
|
||||
import { TIER_KITS } from './index.js';
|
||||
|
||||
export { PITHST_ELEMENTS } from './ops/chess_char_1_15-pithst.js';
|
||||
export default TIER_KITS[5];
|
||||
@@ -136,7 +136,8 @@ export function normalizeSkill(rec) {
|
||||
initSp: Math.max(0, num(s.initSp, 0)),
|
||||
maxCharges: Math.max(1, num(s.maxChargeTime ?? s.maxCharges ?? s.charges, 1)),
|
||||
rangeGrid: toArrayOfPairs(s.rangeGrid),
|
||||
trigger: { rule, grid: trigGrid },
|
||||
// `allies` (data: an ally row, tools/build-data.mjs TRIGGER_ALLY_RULES — 黍 S3) — skills.js `trigger.allies`
|
||||
trigger: typeof trig === 'object' && trig.allies === true ? { rule, grid: trigGrid, allies: true } : { rule, grid: trigGrid },
|
||||
kind: s.kind ?? null,
|
||||
bb,
|
||||
description: s.description ?? s.desc ?? '',
|
||||
|
||||
@@ -120,6 +120,19 @@ export class SkillRuntime {
|
||||
this.noSkill = !spec; // unit without any skill spec
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the trigger rule and grid mid-battle — a kit whose skill's running range changes with its own use (薇薇安娜 S3:
|
||||
* "首次技能结束后,本技能的技能范围永久扩大至3-2" — ACTIVE_RANGE on 3-2 from then, DEFAULT again at her next deployment).
|
||||
* `grid`: a facing-RIGHT [dRow, dCol] grid (ACTIVE_RANGE / SKILL_RANGE / CUSTOM_RANGE), or null. (0.2.0 WE2, additive.)
|
||||
* @param {string} rule @param {number[][]|null} [grid]
|
||||
*/
|
||||
setTrigger(rule, grid = null) {
|
||||
this.rule = String(rule ?? 'DEFAULT').toUpperCase();
|
||||
this.triggerGrid = Array.isArray(grid) && grid.length ? grid : null;
|
||||
this._trigKeys = null;
|
||||
this._trigSet = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra DEFAULT-trigger range (海嗣 "攻击范围视为自身攻击范围的延伸", 流形): `fn(battle, unit)` returns a list whose
|
||||
* entries are ally units (their current `rangeKeys` count while they are on the field) or arrays of absolute tile
|
||||
|
||||
Reference in new issue
Block a user