feedback3: merge W3A — 联防 carries HP ratio and SP only (GitHub #82); down operators take part in the 联防 devour (players' reports, owner's decision); 不屈 on 撤退 / 替身 switches; SP recovers during stun (洛洛 S2)
This commit is contained in:
commit
dcc44712ad
23 files changed
+629
-67
No files matched your search
@@ -0,0 +1,108 @@
|
||||
// test/content/feedback3-egir-down.test.js — 阿戈尔 in 联防 (community report #3, GitHub #33 item 3; per players' reports,
|
||||
// owner's decision 2026-10-04): an operator down at the end of its own combat still takes part in the battle-start devour
|
||||
// as if it stood (marking order, the unit in front, base ATK / block gains, its own marks' 物理流失) and is then forced
|
||||
// out — it used to be forced out first, which broke the chain at its tile. Nothing lands on it (it is never knocked out
|
||||
// again, revived or devoured).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { FORCED_EXIT } from '../../server/sim/constants.js';
|
||||
|
||||
const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`);
|
||||
const bondOn = (count, layers = 0, tier = null, thresholds = [3, 6, 9]) => ({
|
||||
count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, layers,
|
||||
});
|
||||
const buffOf = (u, key) => u.buffs.find((b) => b.key === key) ?? null;
|
||||
const devours = (h) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes('bond:egir:devour'));
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 阿戈尔 devour in 联防 with an operator down since its own combat
|
||||
|
||||
const ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', GLADY = 'chess_char_4_12_a', FODDER = 'chess_char_1_01_a';
|
||||
|
||||
function chain(ghostDown, order = [ULPIA, GHOST, GLADY, FODDER]) {
|
||||
const h = makeBattle({
|
||||
kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'],
|
||||
units: order.map((chessId, i) => ({ chessId, row: 10, col: 3 + i, ...(chessId === GHOST && ghostDown ? { carryState: { down: true } } : {}) })),
|
||||
bonds: { egirShip: bondOn(3, 0, null, [3, 5]) },
|
||||
});
|
||||
h.step();
|
||||
return h;
|
||||
}
|
||||
|
||||
test('联防 阿戈尔 chain 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 → fodder: 乌尔比安 gains as if 幽灵鲨 stood, then 幽灵鲨 is forced out', () => {
|
||||
const up = chain(false);
|
||||
const down = chain(true);
|
||||
const [ulpia, ghost, glady, fodder] = [ULPIA, GHOST, GLADY, FODDER].map((id) => down.unit(id));
|
||||
const ref = buffOf(up.unit(ULPIA), 'bond:egir:devour');
|
||||
const got = buffOf(ulpia, 'bond:egir:devour');
|
||||
assert.ok(ref && got, 'both 乌尔比安 devour');
|
||||
const base = (id) => down.unit(id).base;
|
||||
close(got.mods.atkFlat, base(GHOST).atk + base(GLADY).atk + base(FODDER).atk, 1e-6, 'the base ATK of 幽灵鲨, 歌蕾蒂娅 and the fodder');
|
||||
assert.equal(got.mods.blockCnt, base(GHOST).blockCnt + base(GLADY).blockCnt + base(FODDER).blockCnt);
|
||||
assert.deepEqual(got.mods, ref.mods, 'exactly the gains of the chain with 幽灵鲨 standing');
|
||||
close(ulpia.s.atk, up.unit(ULPIA).s.atk, 1e-6, '乌尔比安 ATK');
|
||||
// then she is out: forced out (not knocked out again), never devoured, never revived
|
||||
assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT && down.b.isDown(ghost), '幽灵鲨 lies on her tile, forced out');
|
||||
assert.equal(down.hooksOf('death').filter((c) => c.unit === ghost && c.reason === 'killed').length, 0, 'no knock-out');
|
||||
assert.equal(down.hooksOf('deploy').filter((c) => c.unit === ghost && !c.initial).length, 0, 'no revive');
|
||||
assert.ok(!devours(down).some((c) => c.target === ghost), 'no 物理流失 on her');
|
||||
// standing, 乌尔比安's first mark knocks her out, so her own marks give nothing; down, her marks resolve but find
|
||||
// 歌蕾蒂娅 and the fodder already knocked out by 乌尔比安's (cancelled) — the rest resolves alike
|
||||
const pairs = (h) => devours(h).map((c) => [c.source.defId, c.target.defId]);
|
||||
assert.deepEqual(pairs(up), [[ULPIA, GHOST], [ULPIA, GLADY], [ULPIA, FODDER]]);
|
||||
assert.deepEqual(pairs(down), [[ULPIA, GLADY], [ULPIA, FODDER]]);
|
||||
assert.equal(glady.alive, up.unit(GLADY).alive);
|
||||
assert.equal(fodder.alive, up.unit(FODDER).alive);
|
||||
// her own marks (歌蕾蒂娅, the fodder) still give her their base ATK / block for when she is back
|
||||
const own = buffOf(ghost, 'bond:egir:devour');
|
||||
close(own ? own.mods.atkFlat : 0, base(GLADY).atk + base(FODDER).atk, 1e-6, '幽灵鲨 keeps her marking gains (a persistent buff)');
|
||||
checkInvariants(down.b);
|
||||
assert.equal(down.result().perPlayer.p1.deaths, up.result().perPlayer.p1.deaths - 1, 'no knock-out counted for her (standing, the devour knocks her out)');
|
||||
});
|
||||
|
||||
/** 幽灵鲨 (down or standing) → 歌蕾蒂娅 → fodder on row 10; 乌尔比安 (+ 深巡, 海霓 for 5 members) on row 12 facing empty tiles. */
|
||||
function head(ghostDown, five) {
|
||||
const units = [
|
||||
{ chessId: GHOST, row: 10, col: 3, ...(ghostDown ? { carryState: { down: true } } : {}) }, { chessId: GLADY, row: 10, col: 4 },
|
||||
{ chessId: FODDER, row: 10, col: 5 }, { chessId: ULPIA, row: 12, col: 3 },
|
||||
...(five ? [{ chessId: 'chess_char_1_04_a', row: 12, col: 5 }, { chessId: 'chess_char_3_09_a', row: 12, col: 7 }] : []),
|
||||
];
|
||||
const h = makeBattle({
|
||||
kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'],
|
||||
units, bonds: { egirShip: bondOn(five ? 5 : 3, 0, null, [3, 5]) },
|
||||
});
|
||||
h.step();
|
||||
return h;
|
||||
}
|
||||
|
||||
test('联防 阿戈尔: a down member at the head of the chain devours as if it stood — its 物理流失 lands (credited to it), then it stays out', () => {
|
||||
for (const five of [false, true]) {
|
||||
const tag = five ? '5 members' : '3 members';
|
||||
const up = head(false, five);
|
||||
const down = head(true, five);
|
||||
const [ghost, glady, fodder] = [GHOST, GLADY, FODDER].map((id) => down.unit(id));
|
||||
const g = buffOf(ghost, 'bond:egir:devour');
|
||||
close(g.mods.atkFlat, glady.base.atk + fodder.base.atk, 1e-6, `${tag}: marks 歌蕾蒂娅 and, through her, the fodder`);
|
||||
const pairs = (h) => devours(h).map((c) => [c.source.defId, c.target.defId]);
|
||||
assert.deepEqual(pairs(down), pairs(up), `${tag}: the marks resolve as with 幽灵鲨 standing`);
|
||||
assert.deepEqual(pairs(down), [[GHOST, GLADY], [GHOST, FODDER]], `${tag}: her two marks; 歌蕾蒂娅's on the fodder is cancelled (knocked out first)`);
|
||||
assert.ok(!fodder.alive, `${tag}: the fodder falls`);
|
||||
assert.equal(ghost.stats.kills, up.unit(GHOST).stats.kills, `${tag}: the kills are hers, as standing`);
|
||||
assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT && down.b.isDown(ghost), `${tag}: 幽灵鲨 stays forced out`);
|
||||
const revived = (h, u) => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length;
|
||||
if (five) {
|
||||
// the 5-tier revive: 歌蕾蒂娅's first knock-out (by the down 幽灵鲨's mark) spends a charge, as standing
|
||||
assert.equal(revived(down, glady), 1, '歌蕾蒂娅 revived once');
|
||||
assert.equal(revived(up, up.unit(GLADY)), 1);
|
||||
assert.ok(glady.alive, '歌蕾蒂娅 stands again');
|
||||
close(glady.hp, glady.s.maxHp, 1e-6, 'at full HP: no further mark on her');
|
||||
assert.equal(revived(down, ghost), 0, 'the down 幽灵鲨 spends nothing');
|
||||
} else {
|
||||
assert.ok(!glady.alive, '歌蕾蒂娅 falls to 幽灵鲨\'s mark (3 members: no revive)');
|
||||
}
|
||||
assert.equal(down.result().perPlayer.p1.deaths, up.result().perPlayer.p1.deaths, `${tag}: the same knock-outs`);
|
||||
checkInvariants(down.b);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// test/content/feedback3-indom-exits.test.js — 不屈 (owner's decision 2026-10-04, PRTS 卫戍协议:盟约 下半/PRTS盟约记录 不屈
|
||||
// 修正: "被击倒、撤退、切换<替身>与<本体>时,有(18+0.4×层数)%概率立刻重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次
|
||||
// 部署的再部署时间和费用归零"): retreats and 傀儡师 switches roll too, not only knock-outs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { FORCED_EXIT } from '../../server/sim/constants.js';
|
||||
|
||||
const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`);
|
||||
const bondOn = (count, layers = 0, tier = null, thresholds = [3, 6, 9]) => ({
|
||||
count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, layers,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 不屈 on 撤退 and on 替身 switches
|
||||
|
||||
const groundOp = (id, extra = {}) => chessRec({ id, profession: 'WARRIOR', skill: null, bonds: ['indomShip'], stats: { atk: 100, maxHp: 5000, def: 0, respawnTime: 70 }, ...extra });
|
||||
const spOp = (id) => groundOp(id, { skill: { duration: 5, spCost: 50, initSp: 0, bb: { atk: 1 } } });
|
||||
const INDOM = (layers, count = 2) => ({ indomShip: bondOn(count, layers, null, [2, 3]) }); // p = min(1, 0.18 + 0.004 × layers)
|
||||
const GH2 = 'chess_char_5_13_a'; // 归溟幽灵鲨: 傀儡师, a member of 阿戈尔 and 不屈
|
||||
|
||||
test('不屈: a 撤退 rolls like a knock-out — at p = 1 it is back at once, free, on its tile (行商\'s withdrawal too); tier 2\'s +5 SP stays a knock-out effect', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: groundOp('t_a'), t_b: spOp('t_b'), t_c: groundOp('t_c') } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 }, { chessId: 't_c', row: 10, col: 8 }],
|
||||
bonds: INDOM(300, 3), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'],
|
||||
});
|
||||
h.step();
|
||||
const [a, b] = [h.unit('t_a'), h.unit('t_b')];
|
||||
const dp0 = h.b.getPlayer('p1').dp;
|
||||
const sp0 = b.skill.sp;
|
||||
h.b.retreat(a, { reason: 'retreat' });
|
||||
assert.ok(a.alive && a.deployed, 'back at once');
|
||||
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === a && !c.initial).length, 1);
|
||||
assert.deepEqual([a.tileR, a.tileC], [a.homeR, a.homeC], 'on its tile');
|
||||
close(h.b.getPlayer('p1').dp, dp0, 1e-9, 'free');
|
||||
close(b.skill.sp, sp0, 1e-9, 'no +5 SP for a retreat');
|
||||
h.step(); // (不屈 fires once per unit and instant)
|
||||
h.b.retreat(a, { reason: 'merchant' });
|
||||
assert.ok(a.alive, '行商\'s automatic withdrawal is a 撤退 too');
|
||||
h.step();
|
||||
const sp1 = b.skill.sp;
|
||||
h.b.dealDamage(null, a, { amount: 1e9, type: 'true' });
|
||||
assert.ok(a.alive, 'a knock-out: back too');
|
||||
close(b.skill.sp, sp1 + 5, 1e-9, 'a knock-out gives the tier-2 +5 SP');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('不屈: no roll on the 突袭 retreat (it redeploys at once anyway) nor on the 联防 forced exit; a missed roll leaves the withdrawn operator down', () => {
|
||||
const h = makeBattle({
|
||||
kind: 'unite', defs: { chess: { t_a: groundOp('t_a'), t_b: groundOp('t_b') } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 4, carryState: { down: true } }, { chessId: 't_b', row: 10, col: 6 }], bonds: INDOM(300),
|
||||
autoFinish: false, timeLimit: 60,
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit('t_a');
|
||||
assert.ok(!a.alive && a.removeReason === FORCED_EXIT && h.b.isDown(a), 'the 联防 forced exit is no 撤退: it stays down with its timer');
|
||||
const b = h.unit('t_b');
|
||||
h.b.retreat(b, { reason: 'raid' });
|
||||
assert.ok(!b.alive, 'the 突袭 retreat: 不屈 leaves it to the raid\'s own redeploy');
|
||||
// p = 0.18: a seed whose roll misses leaves it down where it stood (§22.15)
|
||||
let missed = false;
|
||||
for (let seed = 1; seed < 40 && !missed; seed++) {
|
||||
const m = makeBattle({
|
||||
seed, defs: { chess: { t_a: groundOp('t_a'), t_b: groundOp('t_b') } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 }], bonds: INDOM(0), autoFinish: false, timeLimit: 60,
|
||||
});
|
||||
m.step();
|
||||
const x = m.unit('t_a');
|
||||
m.b.retreat(x, { reason: 'retreat' });
|
||||
if (!x.alive) { missed = true; assert.ok(m.b.isDown(x), 'down on its tile with its timer'); }
|
||||
}
|
||||
assert.ok(missed, 'a missed roll within 39 seeds at p = 0.18');
|
||||
});
|
||||
|
||||
test('不屈: a 傀儡师 switch to the 替身 rolls — she stays on the field, and a hit makes her next deployment immediate and free', () => {
|
||||
const h = makeBattle({ units: [{ chessId: GH2, row: 10, col: 4 }], bonds: INDOM(300), autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['deploy'] });
|
||||
h.step();
|
||||
const u = h.unit(GH2);
|
||||
assert.ok(u.ground, 'a ground operator');
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(u.alive && u.trait.doll, 'switched to her 替身, still on the field');
|
||||
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length, 0, 'no redeploy for the switch itself');
|
||||
assert.equal(u.mem.indomFreeDeploy, true, 'the roll hit: her next deployment is zeroed');
|
||||
h.run(2);
|
||||
const dp0 = h.b.getPlayer('p1').dp;
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); // a lethal hit on the 替身 knocks her out
|
||||
assert.ok(u.alive && u.deployed && !u.trait.doll, 'back at once as her 本体');
|
||||
close(h.b.getPlayer('p1').dp, dp0, 1e-9, 'free');
|
||||
assert.equal(u.mem.indomFreeDeploy, false, 'spent by that deployment');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('不屈: the switch back to the 本体 rolls too; a zeroed deployment brings her back even when the knock-out roll misses', () => {
|
||||
const h = makeBattle({ units: [{ chessId: GH2, row: 10, col: 4 }], bonds: INDOM(300), autoFinish: false, timeLimit: 120, captureNoisy: true });
|
||||
h.step();
|
||||
const u = h.unit(GH2);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
u.mem.indomFreeDeploy = false; // forget the first switch's roll: the switch back must roll on its own
|
||||
h.runUntil(() => !u.trait.doll, 30);
|
||||
assert.ok(u.alive && !u.trait.doll, 'back to her 本体');
|
||||
assert.equal(u.mem.indomFreeDeploy, true, 'the switch back rolled (p = 1)');
|
||||
// p = 0.18 (no layers): with a zeroed deployment banked she comes back after any knock-out, rolled or not
|
||||
for (let seed = 1; seed <= 5; seed++) {
|
||||
const m = makeBattle({ seed, defs: { chess: { t_a: groundOp('t_a') } }, units: [{ chessId: 't_a', row: 10, col: 4 }], bonds: INDOM(0), autoFinish: false, timeLimit: 60 });
|
||||
m.step();
|
||||
const x = m.unit('t_a');
|
||||
x.mem.indomFreeDeploy = true;
|
||||
m.b.dealDamage(null, x, { amount: 1e9, type: 'true' });
|
||||
assert.ok(x.alive && !x.mem.indomFreeDeploy, `seed ${seed}: back at once`);
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -339,7 +339,7 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses,
|
||||
assert.match(SIM, /taken over by another operator in contact with room, else they walk on/);
|
||||
// #2 联防: carryState { down: true } → FORCED_EXIT (code = §5.1 / §5.5 / §6.1 / §18.3)
|
||||
assert.equal(FORCED_EXIT, 'forcedExit');
|
||||
assert.match(DESIGN, /carryState\?: \{ hpPct, sp, skillActive \} \| \{ down: true \}/);
|
||||
assert.match(DESIGN, /carryState\?: \{ hpPct, sp \} \| \{ sp \} \| \{ down: true \}/);
|
||||
assert.match(DESIGN, /or `FORCED_EXIT`, entering 联防 knocked out/);
|
||||
assert.match(sec(6), /knocked out at the end of its own combat enters down/);
|
||||
assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/);
|
||||
|
||||
@@ -348,7 +348,7 @@ test('联防 under client-side combat: helpers\' reported end state (HP%, SP) ca
|
||||
assert.equal(f.authority, 'p_1');
|
||||
const carried = f.spec.players[0].units.find((u) => u.uid === unit.uid);
|
||||
const reported = m.lastResults.get('p_1').unitsEnd.find((u) => u.uid === unit.uid);
|
||||
assert.deepEqual(carried.carryState, { hpPct: reported.hpPct, sp: reported.sp, skillActive: reported.skillActive });
|
||||
assert.deepEqual(carried.carryState, { hpPct: reported.hpPct, sp: reported.sp });
|
||||
const got = h.sent.filter(([, x]) => x.t === 'b.start' && x.fieldId === 'u').map(([pid, x]) => `${pid}:${x.authoritative ? 'A' : x.watch ? 'w' : 'r'}`).sort();
|
||||
assert.deepEqual(got, ['p_0:w', 'p_1:A', 'p_2:r']);
|
||||
h.drive(() => m.phase === PHASE.SETTLE);
|
||||
|
||||
@@ -148,7 +148,7 @@ test('联防: trigger, helpers (≤ 2, highest LP then seat), carry state, sourc
|
||||
// unite.js (research 08 §5): the first-ranked helper "率先迎敌" on the right-hand field (colOffset +8)
|
||||
assert.deepEqual(u.opts.players.map((p) => [p.playerId, p.colOffset]), [['p_1', 8], ['p_2', 0]]);
|
||||
const carried = u.opts.players[0].units.find((x) => x.uid === unit.uid);
|
||||
assert.deepEqual(carried.carryState, { hpPct: 0.5, sp: 3, skillActive: false }, 'HP%/SP from the end of the own combat');
|
||||
assert.deepEqual(carried.carryState, { hpPct: 0.5, sp: 3 }, 'HP%/SP from the end of the own combat');
|
||||
assert.equal(u.opts.spawns.length, 18, 'union of every counted leak');
|
||||
assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_0').length, 6);
|
||||
assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_3').length, 12);
|
||||
@@ -198,7 +198,7 @@ test('联防: an operator knocked out at the end of the helper\'s own combat is
|
||||
// timer (user playtest #5 item 2: it used to be left out and vanished); its summons are fielded as the board has them
|
||||
assert.deepEqual(units.find((x) => x.uid === hemo.uid)?.carryState, { down: true }, 'the knocked-out operator enters 联防 down');
|
||||
assert.ok(units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'its summons are fielded');
|
||||
assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
|
||||
assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3 });
|
||||
assert.ok(helper.board.has(`${t0[0]},${t0[1]}`) && helper.board.has(placed), 'the board itself is untouched (next round as usual)');
|
||||
// during 联防 an 'n:<pid>' id names no field: refused, and the viewer keeps streaming the 联防 field
|
||||
assert.equal(m.watchers.get('p_0'), 'u');
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// test/match/feedback3-unitecarry.test.js — community report #34 / GitHub #82: 联防 carried a skill that was running at
|
||||
// the end of a helper's own combat and restarted it for free (圣聆初雪's S2 on at the 联防 start with SP 0, an empty bar).
|
||||
// Official (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,
|
||||
// 上一阶段为退场状态的干员强制退场)"; the official setup message carries `charBattleStatusList: [{ instId, hp, tech }]` only
|
||||
// (research 09 §3 HelpBattleInfo). So: an operator's HP ratio and 技力, a summon's 技力, nothing of the skill state.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { uniteBattleOpts } from '../../server/match/unite.js';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`);
|
||||
|
||||
let gdCache = null;
|
||||
/** A real GameData (unite templates for buildUniteWave). */
|
||||
function gd() {
|
||||
if (!gdCache) gdCache = makeMatch({ mode: 'coop', humans: 3, seed: 1, fake: true }).start().m.gd;
|
||||
return gdCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* unite.js's carry for one helper, from the given end-of-combat report: board = [[uid, 'chess'|'token', chessId?]],
|
||||
* the helper's battle input lists those pieces (board row 10, from col 3) with the carry unite.js attached.
|
||||
*/
|
||||
function uniteInput(board, unitsEnd) {
|
||||
const ps = {
|
||||
playerId: 'p1', seat: 0,
|
||||
board: new Map(board.map(([uid, kind], i) => [`10,${3 + i}`, { uid, kind }])),
|
||||
battleInput: ({ side, colOffset, carry }) => ({
|
||||
playerId: 'p1', seat: 0, side, colOffset, bonds: {}, bandId: null, playerEffects: [],
|
||||
units: board.map(([uid, kind, id, extra], i) => ({
|
||||
uid, kind, ...(kind === 'token' ? { tokenId: id } : { chessId: id }), row: 10, col: 3 + i, dir: 'RIGHT', items: [],
|
||||
...(extra || {}), ...(carry.has(uid) ? { carryState: carry.get(uid) } : {}),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
const m = { gd: gd(), round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} };
|
||||
const { players } = uniteBattleOpts(m, { helpers: [ps], leaked: [] }, 60);
|
||||
return players[0];
|
||||
}
|
||||
|
||||
test('unite.js carry: HP ratio + SP for an operator (never skillActive), SP only for a summon on the field, down for a knocked-out operator', () => {
|
||||
const input = uniteInput(
|
||||
[[1, 'chess', 't_run'], [2, 'token', 'tok'], [3, 'chess', 't_down'], [4, 'token', 'tok2']],
|
||||
[
|
||||
{ uid: 1, hpPct: 0.4, sp: 0, skillActive: true, alive: true },
|
||||
{ uid: 2, hpPct: 0.3, sp: 40, skillActive: false, alive: true },
|
||||
{ uid: 3, hpPct: 0, sp: 7, skillActive: false, alive: false },
|
||||
{ uid: 4, hpPct: 0, sp: 12, skillActive: false, alive: false },
|
||||
],
|
||||
);
|
||||
const cs = (uid) => input.units.find((u) => u.uid === uid).carryState;
|
||||
assert.deepEqual(cs(1), { hpPct: 0.4, sp: 0 }, 'the running skill is not carried');
|
||||
assert.deepEqual(cs(2), { sp: 40 }, '召唤物仅修改技力');
|
||||
assert.deepEqual(cs(3), { down: true });
|
||||
assert.equal(cs(4), undefined, 'a summon off the field at the end enters fresh [ASSUMED]');
|
||||
});
|
||||
|
||||
test('PlayerState.battleInput hands a summon piece its carryState', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 5, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_1');
|
||||
const tok = ps.newPiece('token', 'token_10030_mlyss_wtrman', { ownerUid: 999 });
|
||||
ps.board.set('10,4', tok);
|
||||
const input = ps.battleInput({ carry: new Map([[tok.uid, { sp: 40 }]]) });
|
||||
assert.deepEqual(input.units.find((u) => u.uid === tok.uid).carryState, { sp: 40 });
|
||||
});
|
||||
|
||||
test('an operator whose skill runs at the end of its own combat enters 联防 with the skill off and the carried SP; stored charges come back', () => {
|
||||
const defs = {
|
||||
chess: {
|
||||
// a 30 s skill (cost 10, ready at once) cast on the dummy in range: running when the own combat ends
|
||||
t_run: chessRec({ id: 't_run', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100 },
|
||||
skill: { duration: 30, spCost: 10, initSp: 10, bb: { atk: 1 } } }),
|
||||
// 2 charges of a cost-5 skill, nothing in range: both charges stored at the end (技力 10 = 2 × 5)
|
||||
t_chg: chessRec({ id: 't_chg', profession: 'SNIPER', rangeGrid: [[0, 0]], stats: { atk: 100 },
|
||||
skill: { duration: 5, spCost: 5, initSp: 0, maxChargeTime: 2, bb: { atk: 1 } } }),
|
||||
},
|
||||
enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 }) },
|
||||
};
|
||||
const own = makeBattle({
|
||||
defs, content: 'generic', autoFinish: false, timeLimit: 60,
|
||||
units: [{ uid: 1, chessId: 't_run', row: 10, col: 3 }, { uid: 2, chessId: 't_chg', row: 12, col: 3 }],
|
||||
enemies: [{ key: 'e_dummy', pos: [10, 5] }],
|
||||
});
|
||||
own.runUntil(() => own.unit('t_run').skill.active, 10);
|
||||
own.run(12);
|
||||
assert.ok(own.unit('t_run').skill.active, 'still running at the end');
|
||||
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
|
||||
const end = (uid) => unitsEnd.find((u) => u.uid === uid);
|
||||
assert.equal(end(1).skillActive, true);
|
||||
assert.equal(end(1).sp, 0, 'spent at the activation (PRTS 技能 "触发技能后…消耗相应的技力")');
|
||||
assert.equal(own.unit('t_chg').skill.charges, 2);
|
||||
assert.equal(end(2).sp, 10, 'the official 技力: both charges (PRTS 技能 "当前技力上限等于该技能技力需求的X倍")');
|
||||
|
||||
const input = uniteInput([[1, 'chess', 't_run'], [2, 'chess', 't_chg']], unitsEnd);
|
||||
input.units[1].row = 12; // as on the own field
|
||||
const u = makeBattle({ kind: 'unite', defs, content: 'generic', autoFinish: false, players: [input] });
|
||||
u.step();
|
||||
const run = u.unit('t_run'), chg = u.unit('t_chg');
|
||||
close(run.hpRatio, end(1).hpPct, 1e-6, 'HP ratio');
|
||||
assert.equal(run.skill.active, false, 'the skill enters 联防 switched off (it used to restart for free)');
|
||||
assert.equal(run.skill.activations, 0);
|
||||
assert.ok(run.skill.sp < 0.1, `SP from 0, got ${run.skill.sp}`);
|
||||
close(run.s.atk, 100, 1e-6, 'no skill ATK');
|
||||
assert.equal(chg.skill.charges, 2, 'the stored charges are back');
|
||||
});
|
||||
|
||||
test('a summon piece enters 联防 with its carried SP only (流形: SP set, HP full)', () => {
|
||||
// 缪尔赛思 (chess_char_6_11_a) and her board 流形 (token_10030_mlyss_wtrman: cost 100, initial SP 95)
|
||||
const players = [{
|
||||
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null, playerEffects: [],
|
||||
units: [
|
||||
{ uid: 1, kind: 'chess', chessId: 'chess_char_6_11_a', row: 10, col: 3, dir: 'RIGHT', items: [], carryState: { hpPct: 0.5, sp: 3 } },
|
||||
{ uid: 2, kind: 'token', tokenId: 'token_10030_mlyss_wtrman', ownerUid: 1, row: 10, col: 4, dir: 'RIGHT', carryState: { sp: 40, hpPct: 0.3 } },
|
||||
],
|
||||
}];
|
||||
const h = makeBattle({ kind: 'unite', players, autoFinish: false });
|
||||
h.step();
|
||||
const tok = h.b.allyUnits.find((x) => x.uid === 2);
|
||||
assert.ok(tok && tok.alive && tok.deployed, 'the 流形 is on the field');
|
||||
assert.ok(tok.skill.sp >= 40 && tok.skill.sp < 41, `its SP is the carried 40 (fresh it would be 95), got ${tok.skill.sp}`);
|
||||
close(tok.hp, tok.s.maxHp, 1e-6, 'a summon keeps no HP ratio (召唤物仅修改技力)');
|
||||
});
|
||||
|
||||
test('联防: the carried SP is set after the deployment ("部署完成后…技力修改") — 独行 / 黄沙罗盘 deploy-time SP does not come on top; a later redeploy keeps it', () => {
|
||||
const caster = (id, bonds = []) => chessRec({ id, bonds, profession: 'CASTER', stats: { maxHp: 1000, atk: 100 }, skill: { spCost: 40, initSp: 10, duration: 10 } });
|
||||
const cases = [
|
||||
// 独行 (bonds.json soloShip bb.sp 15: "初始技力+15") on its lone member
|
||||
{ name: '独行', unit: { chessId: 't_solo' }, defs: { t_solo: caster('t_solo', ['soloShip']) }, bonds: { soloShip: { count: 1, active: true, tier: 1, layers: 0 } }, gift: 15 },
|
||||
// 黄沙罗盘 (chess_item_6_07_e "初始技力+30")
|
||||
{ name: '黄沙罗盘', unit: { chessId: 't_comp', items: ['chess_item_6_07_e_a'] }, defs: { t_comp: caster('t_comp') }, bonds: {}, gift: 30 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
for (const sp of [0, 30]) {
|
||||
const h = makeBattle({
|
||||
kind: 'unite', defs: { chess: c.defs }, bonds: c.bonds, autoFinish: false, timeLimit: 60,
|
||||
units: [{ ...c.unit, row: 10, col: 4, carryState: { hpPct: 0.5, sp } }],
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit(c.unit.chessId);
|
||||
close(u.skill.sp, sp, 1e-9, `${c.name}, carried ${sp}: the bar comes back as it ended`);
|
||||
assert.equal(u.skill.ready, false, `${c.name}, carried ${sp}: not ready`);
|
||||
// a later redeploy in the same 联防 battle: initial SP 10 + the gift, as usual
|
||||
h.step();
|
||||
h.b.retreat(u, { reason: 'retreat' });
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
close(u.skill.sp, 10 + c.gift, 1e-9, `${c.name}: a later redeploy keeps the deploy-time SP`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -71,8 +71,8 @@ for (const clientCombat of [true, false]) {
|
||||
const inp = (pid, uid) => players.find((p) => p.playerId === pid).units.find((x) => x.uid === uid);
|
||||
assert.deepEqual(inp('p_1', down1.uid)?.carryState, { down: true }, 'the right-hand helper\'s knocked-out operator is in the 联防 battle');
|
||||
assert.deepEqual(inp('p_2', down2.uid)?.carryState, { down: true }, 'the left-hand helper\'s too');
|
||||
assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
|
||||
assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
|
||||
assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3 });
|
||||
assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3 });
|
||||
|
||||
b.step();
|
||||
const unitOf = (uid, pid) => b.allyUnits.find((u) => u.uid === uid && u.ownerId === pid);
|
||||
|
||||
@@ -33,7 +33,7 @@ test('stun stops enemy movement and attacks; immunity honoured; statusApplied fi
|
||||
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 0));
|
||||
});
|
||||
|
||||
test('stunned operator neither attacks nor gains SP; silence blocks skill activation', () => {
|
||||
test('stunned operator neither attacks nor casts but keeps its natural SP recovery; silence blocks skill activation', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard({ skill: { spCost: 5, initSp: 0, duration: 3, bb: { atk: 0.5 } } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
|
||||
@@ -42,9 +42,12 @@ test('stunned operator neither attacks nor gains SP; silence blocks skill activa
|
||||
const u = h.unit('t_guard');
|
||||
h.b.applyStatus(u, 'stun', { duration: 2 });
|
||||
const atk0 = u.stats.attacks;
|
||||
const sp0 = u.skill.sp;
|
||||
h.run(1.9);
|
||||
assert.equal(u.stats.attacks, atk0);
|
||||
assert.ok(u.skill.sp < 0.2);
|
||||
// PRTS 技能: only 阻回 pauses the SP cooldown ("在阻回状态或技力条已满时…计时暂停"); 晕眩 (PRTS 异常效果 STUNNED) does not
|
||||
approx(u.skill.sp - sp0, 1.9, 0.02);
|
||||
assert.equal(u.skill.activations, 0, 'no cast while stunned');
|
||||
h.b.applyStatus(u, 'silence', { duration: 20 });
|
||||
h.run(8);
|
||||
assert.ok(u.stats.attacks > atk0, 'attacks after stun');
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// test/sim/feedback3-stunsp.test.js — community report #18: 洛洛's S2 self-stun lasted the right time, but her SP did not
|
||||
// recover while she was stunned (skills.js returned before the natural recovery when the unit could not act).
|
||||
// Official: PRTS 技能 "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停" — only 阻回 pauses natural recovery; PRTS 异常效果
|
||||
// STUNNED "无法攻击、释放技能、阻挡敌人类单位" (gamedata_const ba.stun "无法移动、阻挡、攻击及使用技能") names no SP effect.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
|
||||
const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`);
|
||||
const dummy = enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 });
|
||||
|
||||
test('洛洛 S2 自负此轭: the data matches skill_table (lv 4 / 7), the stun lasts the overload, and her SP recovers while she is stunned', () => {
|
||||
const ds = getDefaultSource();
|
||||
// skill_table skchr_rockr_2: level 4 duration 40, spCost 45, initSp 15, atk 0.4, attack_speed 45, scale 1.6; level 7
|
||||
// (the elite piece) 40 / 40 / 20, 0.5 / 60 / 1.7 — the chess status skillLevel 4 / 7
|
||||
for (const [id, lv, cost, init, atk, aspd, scale] of [['chess_char_2_10_a', 4, 45, 15, 0.4, 45, 1.6], ['chess_char_2_10_b', 7, 40, 20, 0.5, 60, 1.7]]) {
|
||||
const sk = ds.rawChess(id).skill;
|
||||
assert.equal(sk.skillId, 'skchr_rockr_2', id);
|
||||
assert.equal(sk.level, lv, id);
|
||||
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.attack_speed, sk.bb.scale], [40, cost, init, atk, aspd, scale], id);
|
||||
}
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { e_dummy: dummy } }, units: [{ chessId: 'chess_char_2_10_a', row: 10, col: 4, carryState: { sp: 999 } }],
|
||||
enemies: [{ key: 'e_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 200, hooks: ['statusApplied', 'skillStart'],
|
||||
});
|
||||
const u = h.unit('chess_char_2_10_a');
|
||||
h.step();
|
||||
assert.ok(u.skill.active, 'cast at once');
|
||||
h.runUntil(() => !u.skill.active, 45);
|
||||
h.step();
|
||||
const stun = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === u);
|
||||
close(stun.duration, 20, 0.05, 'stunned as long as the overload (the second half of the 40 s)');
|
||||
assert.ok(!u.canAct);
|
||||
const sp0 = u.skill.sp;
|
||||
assert.ok(sp0 < 0.2, `the cast spent the SP, got ${sp0}`);
|
||||
h.run(10);
|
||||
assert.ok(!u.canAct, 'still stunned');
|
||||
close(u.skill.sp - sp0, 10, 0.1, 'SP +1/s while stunned (it stayed at 0 before)');
|
||||
h.run(10.5);
|
||||
assert.ok(u.canAct, 'the stun is over after 20 s');
|
||||
close(u.skill.sp, 20.5, 0.2);
|
||||
// ready 45 s after the skill end (not 45 s after the stun): cast again then
|
||||
const casts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
|
||||
assert.equal(casts(), 1);
|
||||
h.run(25);
|
||||
assert.equal(casts(), 2, 'recast 45 s after the end of the skill');
|
||||
});
|
||||
|
||||
test('frozen or stunned operators keep their natural SP recovery; 阻回 (noSp) still stops it; no cast while stunned', () => {
|
||||
const op = (id) => chessRec({ id, profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100 }, skill: { duration: 5, spCost: 30, initSp: 0, bb: { atk: 1 } } });
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { e_dummy: dummy } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 3 }, { chessId: 't_b', row: 10, col: 3 }, { chessId: 't_c', row: 11, col: 3, carryState: { sp: 25 } }],
|
||||
enemies: [{ key: 'e_dummy', pos: [11, 4] }], content: 'generic', autoFinish: false, timeLimit: 60,
|
||||
});
|
||||
h.step();
|
||||
const [a, b, c] = ['t_a', 't_b', 't_c'].map((id) => h.unit(id));
|
||||
h.b.applyStatus(a, 'cold', { duration: 10 });
|
||||
h.b.applyStatus(a, 'cold', { duration: 10 }); // cold on cold → 冻结 (3 s)
|
||||
assert.ok(a.findBuff('freeze'), 'frozen');
|
||||
h.b.addBuff(b, { key: 't:noSp', duration: 3, flags: { noSp: true } });
|
||||
h.b.applyStatus(c, 'stun', { duration: 8 });
|
||||
const [a0, b0] = [a.skill.sp, b.skill.sp];
|
||||
h.run(2.9);
|
||||
close(a.skill.sp - a0, 2.9, 0.05, 'frozen: +1/s');
|
||||
close(b.skill.sp - b0, 0, 1e-9, '阻回: nothing');
|
||||
// t_c fills its 30 SP while stunned (25 + 5 s) but casts only once the stun is over (no cast while stunned)
|
||||
h.run(4.5);
|
||||
assert.ok(!c.canAct && c.skill.ready && c.skill.activations === 0, 'ready, stunned, no cast');
|
||||
h.run(1);
|
||||
assert.ok(c.canAct);
|
||||
h.run(1);
|
||||
assert.equal(c.skill.activations, 1, 'cast once it can act again');
|
||||
});
|
||||
+18
-13
@@ -42,23 +42,28 @@ test('attack-SP ammo skill: no SP from any ammo shot, including the last one', (
|
||||
assert.equal(u.skill.sp, 1);
|
||||
});
|
||||
|
||||
test('unite carryState {sp: 0, skillActive: true} (what unitsEnd reports) resumes the timed skill without a charge', () => {
|
||||
test('unite carryState: a skill running at the end of the own combat is not carried — unitsEnd reports sp 0 and the skill enters 联防 off', () => {
|
||||
// community report #34 / GitHub #82: the carried skillActive used to restart the skill for free (bar empty, skill on)
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }) } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4, carryState: { hpPct: 0.6, sp: 0, skillActive: true } }], content: 'generic',
|
||||
defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 10, bb: { atk: 1 } }) }, enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 }) } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'e_dummy', pos: [10, 6] }], content: 'generic', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('t_sn');
|
||||
approx(u.hpRatio, 0.6, 1e-9);
|
||||
assert.equal(u.skill.active, true);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
assert.ok(u.skill.sp < 0.01);
|
||||
approx(u.s.atk, 200);
|
||||
// and a real end-of-battle report round-trips: unitsEnd of a running skill carries sp 0 + skillActive
|
||||
h.runUntil(() => h.unit('t_sn').skill.active, 10);
|
||||
h.run(1);
|
||||
const end = h.result().perPlayer.p1.unitsEnd[0];
|
||||
assert.equal(end.skillActive, true);
|
||||
assert.equal(end.sp, 0);
|
||||
assert.equal(end.skillActive, true, 'reported');
|
||||
assert.equal(end.sp, 0, 'the SP was spent at the activation (PRTS 技能 "触发技能后…消耗相应的技力")');
|
||||
const u2 = makeBattle({
|
||||
defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }) } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4, carryState: { hpPct: 0.6, sp: end.sp, skillActive: end.skillActive } }], content: 'generic',
|
||||
});
|
||||
u2.step();
|
||||
const u = u2.unit('t_sn');
|
||||
approx(u.hpRatio, 0.6, 1e-9);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
assert.ok(u.skill.sp < 0.2);
|
||||
approx(u.s.atk, 100);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -195,7 +195,7 @@ test('SkillSpec from a kit: mods, targeting override, attack override, onStart/o
|
||||
assert.equal(u.s.aspd, 100);
|
||||
});
|
||||
|
||||
test('passive and toggle kinds; carryState restores hp/sp/skill', () => {
|
||||
test('passive and toggle kinds; carryState restores hp/sp, never a running skill', () => {
|
||||
const h = makeBattle({
|
||||
defs: {
|
||||
chess: {
|
||||
@@ -224,7 +224,10 @@ test('passive and toggle kinds; carryState restores hp/sp/skill', () => {
|
||||
h2.step();
|
||||
const c2 = h2.unit('t_c');
|
||||
approx(c2.hpRatio, 0.5, 1e-9);
|
||||
assert.equal(c2.skill.active, true);
|
||||
// PRTS 卫戍协议/帮助 §联防阶段: only the HP ratio and the SP are set — the (old) skillActive flag starts nothing
|
||||
assert.equal(c2.skill.active, false);
|
||||
assert.equal(c2.skill.ready, true, 'the carried 10 SP fill the bar');
|
||||
assert.equal(c2.skill.activations, 0);
|
||||
});
|
||||
|
||||
test('generic kit maps blackboards of every real chess to a sane SkillSpec', () => {
|
||||
|
||||
Reference in new issue
Block a user