feedback5 K3: the Touch stand-in kit (standin-acmedc.js) — S1 慨赠, S2 宛如天启, module PHY-X; S3 恳切福音 and 攫升 / 超脱 run the 外勤医疗 strategy's Touch code, now exported from content/tokens.js (touchGospel, mapCharTalents; the strategy unchanged, golden identical)
This commit is contained in:
1 parent
65ac4afbd8
commit
55f53fdcb0
5 files changed
+341
-11
No files matched your search
@@ -126,6 +126,9 @@ stand-in's, `def.standInFor` the replaced operator's charId.
|
||||
stand-ins is `tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS` (预备干员-重装, Mechanist: every skill `DEFAULT`,
|
||||
`rawRule` `TAKE_DAMAGE`).
|
||||
- **Summons**: no stand-in has one (DATA.md §18).
|
||||
- **One character, one implementation**: a stand-in the mode also fields elsewhere reuses that code through a named
|
||||
export, never through another registry: Touch (`standin-acmedc.js`) runs the 外勤医疗 strategy's Touch S3 and talents,
|
||||
`content/tokens.js` `touchGospel` / `mapCharTalents`.
|
||||
- `node --test test/content/kits_layout.test.js` checks the file name, the key (a charId of data/backups.json `units`)
|
||||
and the registry entry.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const KIT_FILES = Object.freeze([
|
||||
* A stand-in without a file fights with the generic kit (content/generic.js) plus its unconditional stat talents
|
||||
* (genericTalents) — the eight 预备干员 need no file.
|
||||
*/
|
||||
export const STANDIN_KIT_FILES = Object.freeze([]);
|
||||
export const STANDIN_KIT_FILES = Object.freeze(['standin-acmedc.js']);
|
||||
|
||||
async function loadKitFile(file) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// server/sim/content/kits/ops/standin-acmedc.js — Touch (char_613_acmedc) 补位 stand-in kit: 5 塞雷娅, 6 塑心 / 纯烬艾雅法拉
|
||||
// (all S3 恳切福音 + module PHY-X on the elite). Stand-in contract and rules: ../README.md "Stand-in kits".
|
||||
|
||||
import { num, traitBb, skillRec } from '../shared/tier1.js';
|
||||
import { mapCharTalents, touchGospel } from '../../tokens.js';
|
||||
|
||||
const S1 = 'skchr_acmedc_1', S2 = 'skchr_acmedc_2', S3 = 'skchr_acmedc_3';
|
||||
/** S2 宛如天启's "所有医疗干员攻击力+X%" on every medic operator of hers while the skill runs (one per target: 同名 buff). */
|
||||
const APOCALYPSE_KEY = 'acmedc:apocalypse';
|
||||
|
||||
const isMedicOp = (a) => a.kind === 'op' && a.alive && a.deployed && !a.hidden && a.def?.profession === 'MEDIC';
|
||||
|
||||
export default {
|
||||
// Touch (医师) — skills from data/backups.json (normal E2 Lv1 skill rank 4, elite E2 Lv60 rank 7; skill_table and PRTS
|
||||
// Touch(卫戍协议) agree):
|
||||
// S1 慨赠 (20 s): ATK +atk, each heal has an `attack@prob` chance of one extra heal [ASSUMED: of the same target, the
|
||||
// same amount — the text names no other target].
|
||||
// S2 宛如天启 (30 s): her range becomes the 3-10 grid (PRTS 修正: "攻击距离+1" means 攻击范围扩大), she heals
|
||||
// `attack@max_target` targets, every 医疗 operator of hers ATK +`attack@atk` herself included [ASSUMED: her own
|
||||
// player's operators, as 余's "全场所有干员"; summons not].
|
||||
// S3 恳切福音 (40 s): content/tokens.js touchGospel — the one implementation the 外勤医疗 strategy's Touch runs too
|
||||
// (5-2 grid, ATK +atk, 2 targets, an extra heal of 30 % of the main one on the target or an orthogonal neighbour,
|
||||
// ×heal_scale on allies at ≤ hp_ratio HP).
|
||||
// Talents 攫升 / 超脱: content/tokens.js mapCharTalents (the strategy's Touch too); the module's 超脱 upgrade (8 SP at
|
||||
// module Lv3) comes with the record's talents.
|
||||
// Module PHY-X (elite, trait addition): "治疗生命值低于50%的友方单位时治疗量提升15%" — every heal of hers on an ally
|
||||
// below `hp_ratio` HP (strictly: "低于") ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it
|
||||
// multiplies (×1.61 at Lv7): both are `heal_scale` blackboards, the 治疗倍率, and PRTS 游戏数据基础 §倍率 says "同种
|
||||
// 倍率间叠乘" — no Touch-specific note or buff template exists. It runs before S3's hook, so S3's 30 % extra heal is
|
||||
// a share of the main heal with both bonuses and, like S3's ×1.35 / ×1.40 in touchGospel, never multiplies that
|
||||
// share again [ASSUMED].
|
||||
// Triggers (data, nothing set here): S1 DEFAULT (an injured ally in her range); S2 / S3 ACTIVE_RANGE — the owner's
|
||||
// rule of 2026-10-05 read for a heal skill: an injured ally inside the running 3-10 / 5-2 range casts.
|
||||
char_613_acmedc: (bb, chess, def) => {
|
||||
const tb = traitBb(chess);
|
||||
const phyScale = num(tb.heal_scale, 1), phyRatio = num(tb.hp_ratio, 0);
|
||||
const r1 = skillRec(chess, S1), r2 = skillRec(chess, S2), r3 = skillRec(chess, S3);
|
||||
const gospel = r3 ? touchGospel(r3.bb ?? {}, r3) : null;
|
||||
const skills = {};
|
||||
if (r1) {
|
||||
const b1 = r1.bb ?? {};
|
||||
const p = num(b1['attack@prob'], 0);
|
||||
skills[S1] = {
|
||||
kind: 'duration', heal: true,
|
||||
mods: { atkPct: num(b1.atk) },
|
||||
onHit({ battle, unit, target, heal }) {
|
||||
if (!(p > 0) || !target || !(heal > 0) || !battle.rng.chance(p)) return;
|
||||
battle.heal(unit, target, heal);
|
||||
battle.fx('heal', { x: target.x, y: target.y, id: unit.id });
|
||||
},
|
||||
};
|
||||
}
|
||||
if (r2) {
|
||||
const b2 = r2.bb ?? {};
|
||||
const v = num(b2['attack@atk'], 0);
|
||||
const grant = (battle, unit) => {
|
||||
for (const a of battle.alliesFor(unit, unit.ownerId)) {
|
||||
if (!isMedicOp(a)) continue;
|
||||
const cur = a.findBuff(APOCALYPSE_KEY);
|
||||
if (cur && (cur.source === unit || (cur.data?.v ?? 0) >= v)) continue; // another Touch's equal or stronger one
|
||||
battle.addBuff(a, { key: APOCALYPSE_KEY, mods: { atkPct: v }, source: unit, data: { v }, tags: ['skill'] });
|
||||
}
|
||||
};
|
||||
skills[S2] = {
|
||||
kind: 'duration', heal: true,
|
||||
targeting: { rangeGrid: r2.rangeGrid, maxTargets: Math.max(1, Math.floor(num(b2['attack@max_target'], 2))) },
|
||||
onStart({ battle, unit }) { if (v) grant(battle, unit); },
|
||||
// medics deployed (or redeployed) while it runs get it too
|
||||
onTick({ battle, unit }) { if (v) grant(battle, unit); },
|
||||
onEnd({ battle, unit }) {
|
||||
for (const a of battle.allyUnits) if (a.findBuff(APOCALYPSE_KEY)?.source === unit) battle.removeBuff(a, APOCALYPSE_KEY);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (gospel) skills[S3] = gospel.skill;
|
||||
return {
|
||||
skills,
|
||||
talents: mapCharTalents(def),
|
||||
install(battle, unit) {
|
||||
if (phyScale !== 1 && phyRatio > 0) {
|
||||
battle.on('heal', (ctx) => {
|
||||
// (S3's extra heal is a share of the main heal, which already carries it: touchGospel's `touchExtra`)
|
||||
if (ctx.source !== unit || !ctx.target || ctx.opts?.regen || unit.mem.touchExtra) return;
|
||||
if (ctx.target.hpRatio < phyRatio - 1e-9) ctx.amount *= phyScale;
|
||||
}, { owner: unit, priority: 10 });
|
||||
}
|
||||
if (gospel && def?.skill?.id === S3) gospel.install(battle, unit);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -74,7 +74,8 @@
|
||||
// makes the token (Battle.spawnToken also refuses summons the owner's loadout does not produce).
|
||||
//
|
||||
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows,
|
||||
// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS.
|
||||
// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS; mapCharTalents and touchGospel (Touch's 攫升 /
|
||||
// 超脱 and 恳切福音, shared with the Touch 补位 stand-in kit, kits/ops/standin-acmedc.js).
|
||||
|
||||
import { COLS, ROWS, MOVE_SCALE } from '../constants.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
|
||||
@@ -1228,12 +1229,13 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
|
||||
// band map characters (预备干员-医疗 / Touch)
|
||||
|
||||
/**
|
||||
* Talents of the band map characters (data talents of the character record):
|
||||
* Talents of the band map characters (data talents of the character record) — and of the Touch 补位 stand-in, whose kit
|
||||
* (kits/ops/standin-acmedc.js) uses this one implementation, the module's 超脱 upgrade (8 SP) coming with the record:
|
||||
* plain stat talent "攻击力+4%" (预备干员-医疗 攻击提升) → persistent ATK/DEF/HP/ASPD buff
|
||||
* 攫升 "治疗目标时使其获得3点技力" (Touch) → every heal by the character gives the healed unit `sp` SP
|
||||
* 超脱 "攻击范围内的友方干员被击倒时获得5点技力" (Touch) → an allied operator knocked out on a tile of its range: +`sp` SP
|
||||
*/
|
||||
function mapCharTalents(def) {
|
||||
export function mapCharTalents(def) {
|
||||
const out = [];
|
||||
for (const t of def?.talents ?? []) {
|
||||
const bb = t?.bb ?? {};
|
||||
@@ -1278,18 +1280,21 @@ function reserveMedicKit(bb, raw, def) {
|
||||
return { ...k, talents: [...(k.talents ?? []), ...mapCharTalents(def)] };
|
||||
}
|
||||
|
||||
/** Touch 恳切福音: +ATK, 2 heal targets in the skill range, ×heal_scale on allies ≤ hp_ratio, +addition heal. */
|
||||
function touchKit(bb, raw, def) {
|
||||
const sk = def?.skill;
|
||||
if (!sk) return { skill: null, talents: mapCharTalents(def) };
|
||||
/**
|
||||
* Touch 恳切福音 (skchr_acmedc_3): +ATK, 2 heal targets in the skill range, ×heal_scale on allies ≤ hp_ratio, +addition
|
||||
* heal — one implementation for the 外勤医疗 map character (touchKit) and the Touch 补位 stand-in (kits/ops/
|
||||
* standin-acmedc.js). `skill` = the skill record (`rangeGrid`, `duration`) of blackboard `bb`. Returns `{ skill:
|
||||
* SkillSpec, install(battle, unit) }`: `install` adds the heal boost, which acts while the unit's skill runs — install it
|
||||
* on a unit whose skill is this one only.
|
||||
*/
|
||||
export function touchGospel(bb, skill) {
|
||||
const hpRatio = num(bb.hp_ratio, 0), boost = num(bb.heal_scale, 1), extra = num(bb['attack@addition_heal_scale'], 0);
|
||||
const targeting = {};
|
||||
if (num(bb['attack@max_target'], 0) > 1) targeting.maxTargets = Math.floor(num(bb['attack@max_target'], 1));
|
||||
if (sk.rangeGrid && sk.rangeGrid.length) targeting.rangeGrid = sk.rangeGrid;
|
||||
if (skill.rangeGrid && skill.rangeGrid.length) targeting.rangeGrid = skill.rangeGrid;
|
||||
return {
|
||||
talents: mapCharTalents(def),
|
||||
skill: {
|
||||
kind: sk.duration > 0 ? 'duration' : 'instant', heal: true,
|
||||
kind: skill.duration > 0 ? 'duration' : 'instant', heal: true,
|
||||
mods: num(bb.atk, 0) ? { atkPct: num(bb.atk, 0) } : undefined,
|
||||
targeting,
|
||||
onHit({ battle, unit, target, heal }) {
|
||||
@@ -1321,6 +1326,14 @@ function touchKit(bb, raw, def) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Touch (外勤医疗 map character): 恳切福音 (touchGospel) + 攫升 / 超脱 (mapCharTalents). */
|
||||
function touchKit(bb, raw, def) {
|
||||
const sk = def?.skill;
|
||||
if (!sk) return { skill: null, talents: mapCharTalents(def) };
|
||||
const g = touchGospel(bb, sk);
|
||||
return { talents: mapCharTalents(def), skill: g.skill, install: g.install };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a band map character (char_605_cmedic 预备干员-医疗 / char_613_acmedc Touch) at its stage position on the
|
||||
* player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). Returns the unit or null.
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// test/content/standin_acmedc.test.js — the Touch (char_613_acmedc) 补位 stand-in kit (kits/ops/standin-acmedc.js) on every
|
||||
// chess she replaces: 5 塞雷娅, 6 塑心 / 纯烬艾雅法拉 (all S3, module PHY-X on the elite); S1 / S2 through the harness
|
||||
// `standIn: { skillIndex }`. S3 and the talents are content/tokens.js touchGospel / mapCharTalents, the implementation the
|
||||
// 外勤医疗 strategy's Touch runs (test/content/tokens_devices.test.js). Triggers: S1 DEFAULT, S2 / S3 ACTIVE_RANGE (the
|
||||
// owner's rule of 2026-10-05, a heal skill: an injured ally inside the running range).
|
||||
// Run: node --test test/content/standin_acmedc.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { unitForm } from '../../shared/standIn.js';
|
||||
import { touchGospel } from '../../server/sim/content/tokens.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const CHESS = load('chess');
|
||||
const BACKUPS = load('backups');
|
||||
const CHAR = 'char_613_acmedc';
|
||||
const S1 = 'skchr_acmedc_1', S2 = 'skchr_acmedc_2', S3 = 'skchr_acmedc_3';
|
||||
const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
|
||||
const DEFS = {
|
||||
chess: {
|
||||
test_tank_a: chessRec({ id: 'test_tank_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }),
|
||||
test_tank2_a: chessRec({ id: 'test_tank2_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }),
|
||||
// SP only from 攫升: an attack-SP skill and no enemy to attack
|
||||
test_sp_a: chessRec({ id: 'test_sp_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 1000, initSp: 0 } }),
|
||||
test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', stats: { atk: 300 }, skill: null, rangeGrid: [[0, 0]] }),
|
||||
},
|
||||
};
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
|
||||
const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status);
|
||||
const skillOf = (id, skillId) => formOf(id).skills.find((s) => s.skillId === skillId);
|
||||
const moduleOf = (id) => (CHESS[id].backup.uniEquipId && CHESS[id].status.equipLevel > 0 ? formOf(id).modules.find((m) => m.uniEquipId === CHESS[id].backup.uniEquipId) : null);
|
||||
|
||||
/**
|
||||
* Touch (unit 1) on (10,3) facing right — her 3-3 covers columns 3–6, S2's 3-10 columns 3–7, S3's 5-2 columns 3–8 — and
|
||||
* `allies` [[chessId, row, col, hpRatio]] (injured at once). h.heals: { t, tgt, uid, amount (final, before the HP cap), ratio }.
|
||||
*/
|
||||
function battle(touch, allies, opts = {}) {
|
||||
const heals = [];
|
||||
const h = makeBattle({
|
||||
defs: DEFS, timeLimit: 300, autoFinish: false, seed: opts.seed ?? 5, flags: { dpPerSec: 0 },
|
||||
units: [{ chessId: touch.id, row: 10, col: 3, standIn: touch.standIn ?? true, ...(touch.carry ? { carryState: touch.carry } : null) },
|
||||
...allies.map(([chessId, row, col]) => ({ chessId, row, col }))],
|
||||
setup(b) {
|
||||
b.on('heal', (c) => { if (c.source?.uid === 1) heals.push({ t: b.time, tgt: c.target.id, uid: c.target.uid, amount: c.amount, ratio: c.target.hpRatio }); }, { priority: -999 });
|
||||
},
|
||||
});
|
||||
h.heals = heals;
|
||||
h.step();
|
||||
allies.forEach(([, , , ratio], i) => { const a = h.unit(i + 2); if (ratio < 1) h.b.loseHp(a, a.hp * (1 - ratio)); });
|
||||
return h;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('Touch fields on all 6 records she replaces: her body (+ PHY-X attributes on the elites), S3 恳切福音 everywhere, her own kit, ACTIVE_RANGE on the 5-2', () => {
|
||||
assert.equal(RECORDS.length, 6);
|
||||
for (const id of RECORDS) {
|
||||
const c = CHESS[id];
|
||||
const form = formOf(id);
|
||||
const mod = moduleOf(id);
|
||||
const h = battle({ id }, []);
|
||||
const u = h.unit(1);
|
||||
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, c.backup.skillIndex], [CHAR, c.charId, S3, 2], id);
|
||||
assert.ok(!u.kit.generic && u.kit.skillSource === 'skills', `${id}: her kit's own spec`);
|
||||
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
|
||||
assert.equal(!!mod, c.isGolden, `${id}: PHY-X on every elite`);
|
||||
assert.deepEqual([u.base.atk, u.base.maxHp], [form.stats.atk + (mod?.attr.atk ?? 0), form.stats.maxHp + (mod?.attr.maxHp ?? 0)], `${id}: stats`);
|
||||
assert.equal(u.skill.rule, 'ACTIVE_RANGE', `${id}: trigger`);
|
||||
assert.equal(u.skill.triggerGrid.length, 18, `${id}: the 5-2 running range`);
|
||||
assert.equal(u.profile.dmgType, 'heal');
|
||||
// S3 is the strategy's touchGospel spec
|
||||
const g = touchGospel(skillOf(id, S3).bb, skillOf(id, S3));
|
||||
const k = u.kit.skill;
|
||||
assert.deepEqual([k.kind, k.heal, k.mods, k.targeting], [g.skill.kind, g.skill.heal, g.skill.mods, g.skill.targeting], `${id}: touchGospel`);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 恳切福音: cast with an injured ally inside the 5-2 only (no heal before); 40 s on the 5-2, 2 targets, ×1.35 / ×1.40 below half HP, +30 % extra heal', () => {
|
||||
for (const [id, atk, boost] of [['chess_char_5_11_a', 0.2, 1.35], ['chess_char_6_20_a', 0.2, 1.35], ['chess_char_6_09_b', 0.3, 1.4]]) {
|
||||
const phy = moduleOf(id) ? 1.15 : 1;
|
||||
const sk = skillOf(id, S3);
|
||||
assert.deepEqual([sk.bb.atk, sk.bb.heal_scale, sk.bb['attack@addition_heal_scale'], sk.duration], [atk, boost, 0.3, 40]);
|
||||
// (10,8): outside her 3-3 and S2's 3-10, inside the 5-2; (10,5) full HP
|
||||
const h = battle({ id }, [['test_tank_a', 10, 8, 0.3], ['test_tank2_a', 9, 5, 1]]);
|
||||
const u = h.unit(1), near = h.unit(3);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.equal(u.rangeKeys.length, 12);
|
||||
assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`);
|
||||
close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01);
|
||||
assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: no heal before the cast — it was out of her range`);
|
||||
assert.equal(u.rangeKeys.length, 18, `${id}: the 5-2`);
|
||||
close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
|
||||
h.run(0.5);
|
||||
const first = h.heals.filter((x) => x.t === h.heals[0].t);
|
||||
assert.ok(first[0].t >= cast, `${id}: the first heal comes with the cast`);
|
||||
assert.deepEqual(first.map((x) => x.uid), [2, 2], `${id}: the main heal on the far ally, then the extra on it (its neighbours are full)`);
|
||||
close(first[0].amount, u.s.atk * phy * boost, `${id}: main heal ×${boost}${phy > 1 ? ' ×1.15 PHY-X' : ''}`);
|
||||
close(first[1].amount, first[0].amount * 0.3, `${id}: extra heal = 30 % of the main one`);
|
||||
// two injured allies: both healed per action
|
||||
h.b.loseHp(near, near.hp * 0.2);
|
||||
const n0 = h.heals.length;
|
||||
h.runUntil(() => h.heals.length > n0, 5);
|
||||
h.step();
|
||||
const action = h.heals.slice(n0);
|
||||
assert.deepEqual(new Set(action.map((x) => x.uid)), new Set([2, 3]), `${id}: 2 targets`);
|
||||
close(action.find((x) => x.uid === 3).amount, u.s.atk * 1, `${id}: no bonus above half HP`);
|
||||
h.runUntil(() => !u.skill.active, 45);
|
||||
assert.equal(u.rangeKeys.length, 12, `${id}: back to the 3-3`);
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
}
|
||||
// none: no cast however long, and none for an injured ally beyond the 5-2
|
||||
for (const allies of [[['test_tank_a', 10, 8, 1]], [['test_tank_a', 10, 9, 0.3]]]) {
|
||||
const h = battle({ id: 'chess_char_5_11_a', carry: { sp: 999 } }, allies);
|
||||
h.run(5);
|
||||
assert.equal(h.unit(1).skill.activations, 0, JSON.stringify(allies));
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 慨赠: cast on an injured ally in her range; 20 s of ATK +40 % / +50 %, each heal a 40 % / 50 % chance of a second heal of the same ally', () => {
|
||||
for (const [id, atk, p] of [['chess_char_5_11_a', 0.4, 0.4], ['chess_char_5_11_b', 0.5, 0.5]]) {
|
||||
assert.deepEqual([skillOf(id, S1).bb.atk, skillOf(id, S1).bb['attack@prob'], skillOf(id, S1).duration], [atk, p, 20]);
|
||||
let actions = 0, doubles = 0;
|
||||
for (let seed = 1; seed <= 6; seed++) {
|
||||
// the ally stays above half HP (no PHY-X bonus), inside her 3-3
|
||||
const h = battle({ id, standIn: { skillIndex: 0 }, carry: { sp: 999 } }, [['test_tank_a', 10, 5, 0.7]], { seed });
|
||||
const u = h.unit(1);
|
||||
assert.equal(u.skill.id, S1);
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.ok(h.runUntil(() => cast != null, 5));
|
||||
h.step();
|
||||
close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
close(h.b.time - cast, 20, `${id}: 20 s`, 0.01);
|
||||
const during = h.heals.filter((x) => x.t >= cast && x.t < cast + 20);
|
||||
const byT = new Map();
|
||||
for (const x of during) byT.set(x.t, [...(byT.get(x.t) ?? []), x]);
|
||||
for (const list of byT.values()) {
|
||||
actions++;
|
||||
assert.ok(list.length <= 2 && list.every((x) => x.uid === 2), `${id}: at most one extra heal, on the same ally`);
|
||||
if (list.length === 2) { doubles++; close(list[1].amount, list[0].amount, `${id}: the same amount`); }
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
assert.ok(actions >= 30, `${id}: ${actions} heals`);
|
||||
assert.ok(Math.abs(doubles / actions - p) < 0.2, `${id}: ${doubles} / ${actions} extra heals for p = ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 宛如天启: cast with an injured ally inside the 3-10 only; 30 s on the 3-10, 2 targets, every 医疗 operator of hers ATK +21 % / +30 % (herself too)', () => {
|
||||
for (const [id, v] of [['chess_char_6_09_a', 0.21], ['chess_char_6_09_b', 0.3]]) {
|
||||
const sk = skillOf(id, S2);
|
||||
assert.deepEqual([sk.bb['attack@atk'], sk.bb['attack@max_target'], sk.duration, sk.trigger.rule], [v, 2, 30, 'ACTIVE_RANGE']);
|
||||
const h = battle({ id, standIn: { skillIndex: 1 } }, [['test_tank_a', 10, 7, 0.6], ['test_medic_a', 12, 2, 1], ['test_tank2_a', 9, 4, 1]]);
|
||||
const u = h.unit(1), tank = h.unit(2), medic = h.unit(3), tank2 = h.unit(4);
|
||||
assert.equal(u.skill.id, S2);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`);
|
||||
close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01);
|
||||
assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: (10,7) is outside her 3-3`);
|
||||
h.step();
|
||||
assert.equal(u.rangeKeys.length, 15, `${id}: the 3-10`);
|
||||
close(u.s.atk, u.base.atk * (1 + v), `${id}: her ATK`);
|
||||
close(medic.s.atk, medic.base.atk * (1 + v), `${id}: another medic's ATK`);
|
||||
close(tank.s.atk, tank.base.atk, `${id}: not a non-medic`);
|
||||
h.b.loseHp(tank2, tank2.hp * 0.3);
|
||||
const n0 = h.heals.length;
|
||||
h.runUntil(() => h.heals.length > n0, 5);
|
||||
assert.deepEqual(new Set(h.heals.slice(n0).map((x) => x.uid)), new Set([2, 4]), `${id}: 2 targets`);
|
||||
h.runUntil(() => !u.skill.active, 35);
|
||||
h.step();
|
||||
assert.equal(u.rangeKeys.length, 12);
|
||||
close(medic.s.atk, medic.base.atk, `${id}: the aura ends with the skill`);
|
||||
close(u.s.atk, u.base.atk, `${id}: hers too`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
// an injured ally on (10,8) is beyond the 3-10: no cast
|
||||
const h = battle({ id: 'chess_char_6_09_a', standIn: { skillIndex: 1 }, carry: { sp: 999 } }, [['test_tank_a', 10, 8, 0.3]]);
|
||||
h.run(5);
|
||||
assert.equal(h.unit(1).skill.activations, 0);
|
||||
});
|
||||
|
||||
test('攫升 / 超脱 (the strategy\'s mapCharTalents): a healed unit +3 SP; an operator knocked out in her range +5 SP (+8 with PHY-X Lv3)', () => {
|
||||
for (const [id, sp] of [['chess_char_5_11_a', 5], ['chess_char_5_11_b', 5], ['chess_char_6_20_b', 8]]) {
|
||||
const h = battle({ id }, [['test_sp_a', 10, 5, 0.6], ['test_tank_a', 9, 4, 1], ['test_tank2_a', 9, 10, 1]]);
|
||||
const u = h.unit(1), ally = h.unit(2);
|
||||
const gains = [];
|
||||
h.b.on('spGain', (c) => { if (c.unit === ally) gains.push([c.reason, c.amount]); });
|
||||
h.runUntil(() => h.heals.length > 0, 5);
|
||||
h.step();
|
||||
assert.deepEqual(gains, [['talent', 3]], `${id}: 攫升`);
|
||||
assert.equal(u.def.talents[1].bb.sp, sp);
|
||||
const before = u.skill.sp;
|
||||
h.b.kill(h.unit(3)); // (9,4): in her range
|
||||
close(u.skill.sp, before + sp, `${id}: 超脱`);
|
||||
const mid = u.skill.sp;
|
||||
h.b.kill(h.unit(4)); // (9,10): out of it
|
||||
close(u.skill.sp, mid, `${id}: not out of range`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('module PHY-X (elites): every heal of hers on an ally below 50 % HP ×1.15 — none at or above it, none on the normal chess', () => {
|
||||
for (const id of ['chess_char_5_11_a', 'chess_char_5_11_b', 'chess_char_6_09_b']) {
|
||||
const mod = moduleOf(id);
|
||||
if (mod) assert.deepEqual([mod.traitOverride.bb.heal_scale, mod.traitOverride.bb.hp_ratio], [1.15, 0.5]);
|
||||
for (const [ratio, mul] of [[0.3, mod ? 1.15 : 1], [0.5, 1], [0.8, 1]]) {
|
||||
const h = battle({ id }, [['test_tank_a', 10, 5, ratio]]);
|
||||
const u = h.unit(1);
|
||||
h.runUntil(() => h.heals.length >= 1, 10);
|
||||
assert.equal(u.skill.active, false, `${id}: a normal heal`);
|
||||
close(h.heals[0].amount, u.s.atk * mul, `${id}: at ${ratio} HP`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user