feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout
Ported by hand: - #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃 chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝 chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines are identical to #109's tier1/2/3/4/6 diffs. - #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the skill's own mods), and W5D's 冲锋号令 DP start effect is kept. - #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty name (the Chinese text is the PR's), plus its English line in public/i18n/en.json. - data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/ file changed. - docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are always on"), SIM §7.4 (both generic passive cases). - Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js / snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js / plan.mjs, #74 plan.mjs / docs. Where the PRs met feedback5 work: - 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so. - Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the generic:passive buff). Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed and fields the unit that explains it. - #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low, bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low, bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1, 琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype). - #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退): roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high, bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun. - #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3, coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted. - #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low, bond-visiShip-high. - #66 + #160: roster-008, roster-027. - #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06, coop2-HARD-4, coop2-NORMAL-14-standins. - all three: roster-010, roster-012, roster-031. 69 scenarios. Every other digest is unchanged.
This commit is contained in:
commit
a0a7660287
55 files changed
+2140
-736
No files matched your search
@@ -78,7 +78,7 @@ test('耀骑士临光 S2: withdrawn at the skill end, she lies down on her tile
|
||||
const h = field([{ chessId: NEARL2, row: 10, col: 4, skillIndex: idx }]);
|
||||
const u = h.unit(NEARL2);
|
||||
h.step();
|
||||
assert.ok(u.alive && u.findBuff('nearl2:night'), 'S2 on deploy');
|
||||
assert.ok(u.alive && u.skill.active, 'S2 on deploy');
|
||||
assert.ok(h.runUntil(() => !u.alive, 40), 'withdraws when the skill ends');
|
||||
assert.equal(u.removeReason, 'retreat');
|
||||
const d = downOf(h, u);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Regression checks for 突袭 landing terrain, using the shared server/browser simulation.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, flatStage, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { inRange } from '../../server/sim/content/support/index.js';
|
||||
|
||||
const HOME = [12, 3];
|
||||
const ENEMY = [10, 8];
|
||||
const BONDS = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
|
||||
|
||||
function scenario(ready, noLanding = false) {
|
||||
const stage = flatStage();
|
||||
const put = (r, c, glyph) => { stage.rows[r] = stage.rows[r].slice(0, c) + glyph + stage.rows[r].slice(c + 1); };
|
||||
// Every tile in the landing search area is high ground or water except the one dry shore tile.
|
||||
for (let r = 9; r <= 12; r++) for (let c = 6; c <= 10; c++) put(r, c, 'h');
|
||||
put(...ENEMY, 'd');
|
||||
put(10, 9, 'r'); // A shooting platform: low ground in the legend, but unavailable to melee.
|
||||
if (!noLanding) put(9, 8, 'r');
|
||||
const h = makeBattle({
|
||||
stage, bonds: BONDS,
|
||||
defs: {
|
||||
chess: { raider: chessRec({
|
||||
id: 'raider', profession: 'WARRIOR', position: 'MELEE', bonds: ['raidShip'],
|
||||
rangeGrid: [[0, 0], [0, 1], [1, 0], [0, -1], [-1, 0]],
|
||||
skill: ready ? { spCost: 10, initSp: 10 } : null,
|
||||
}) },
|
||||
enemies: { enemy_wader: enemyRec({ key: 'enemy_wader', hp: 1e7, speed: 0 }) },
|
||||
},
|
||||
units: [{ chessId: 'raider', row: HOME[0], col: HOME[1] }],
|
||||
enemies: [{ key: 'enemy_wader', pos: ENEMY }],
|
||||
setup(b) { b.setObstacle(10, 9, true); },
|
||||
});
|
||||
h.step();
|
||||
assert.equal(h.unit('raider').alive, true);
|
||||
assert.equal(h.b.grid.canStand(10, 7), false, 'high ground is not a melee landing tile');
|
||||
assert.equal(h.b.grid.canStand(...ENEMY), false, 'water is not a landing tile');
|
||||
assert.equal(h.b.grid.canStand(10, 9), false, 'shooting platform is not a melee landing tile');
|
||||
return h;
|
||||
}
|
||||
|
||||
for (const ready of [false, true]) {
|
||||
const trigger = ready ? 'skill ready' : 'idle for 10 seconds';
|
||||
test(`突袭 (${trigger}): skips high ground, water and shooting platforms, landing on dry ground`, () => {
|
||||
const h = scenario(ready);
|
||||
const u = h.unit('raider');
|
||||
h.run(ready ? 0.6 : 11);
|
||||
assert.deepEqual([u.tileR, u.tileC], [9, 8], 'the only legal shore tile');
|
||||
assert.ok(u.alive && u.deployed && u.ground);
|
||||
assert.ok(inRange(u, h.enemy('enemy_wader')), 'the enemy is in range after the jump');
|
||||
const landings = h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial);
|
||||
assert.equal(landings.length, 1, 'one successful raid redeployment');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test(`突袭 (${trigger}): stays deployed when only high ground, water and platforms surround the enemy`, () => {
|
||||
const h = scenario(ready, true);
|
||||
const u = h.unit('raider');
|
||||
h.run(12);
|
||||
assert.deepEqual([u.tileR, u.tileC], HOME);
|
||||
assert.ok(u.alive && u.deployed, 'no withdrawal without a valid landing');
|
||||
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 0);
|
||||
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length, 0);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
}
|
||||
@@ -4,15 +4,160 @@ import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { genericSkillSpec, genericKind } from '../../server/sim/content/generic.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { UF } from '../../shared/constants.js';
|
||||
|
||||
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
|
||||
const RANGE3 = [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]];
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
|
||||
|
||||
test('deployment duration: synchronous start, one charge, countdown, carry and fresh redeployment', () => {
|
||||
for (const carryState of [undefined, { hpPct: 0.5, sp: 0, skillActive: true }]) {
|
||||
const order = [];
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: sniperWith({ spCost: 0 }, { id: 't_deploy' }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4, carryState }], timeLimit: 120,
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 4, spCost: 0, spType: 'none', trigger: 'NEVER', mods: { atkPct: 1 },
|
||||
} }) },
|
||||
setup(b) {
|
||||
for (const name of ['skillStart', 'deploy']) b.on(name, (c) => order.push([name, c.reason, c.unit.skill.timeLeft]));
|
||||
},
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_deploy'), sk = u.skill;
|
||||
assert.deepEqual(order, [['skillStart', 'deploy', 4], ['deploy', undefined, 4]]);
|
||||
assert.equal(sk.active, true);
|
||||
assert.equal(sk.activations, 1);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.ready, false);
|
||||
approx(u.s.atk, 200);
|
||||
const tuple = () => h.snapshot().units.find((t) => t[0] === u.id);
|
||||
assert.ok(tuple()[7] & UF.SKILL);
|
||||
assert.deepEqual(tuple().slice(5, 7), [4, 4]);
|
||||
h.run(2);
|
||||
approx(sk.timeLeft, 2);
|
||||
assert.deepEqual(tuple().slice(5, 7), [2, 4]);
|
||||
h.run(2.1);
|
||||
assert.equal(sk.active, false);
|
||||
assert.equal(sk.ready, false);
|
||||
assert.equal(sk.charges, 0);
|
||||
assert.equal(sk.sp, 0);
|
||||
approx(u.s.atk, 100);
|
||||
assert.ok(!(tuple()[7] & UF.SKILL));
|
||||
assert.deepEqual(tuple().slice(5, 7), [0, 0]);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['duration']);
|
||||
assert.equal(sk.gainSp(100, 'talent'), 0);
|
||||
h.run(5);
|
||||
assert.equal(sk.activations, 1);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1, 'expired skill does not end again on retreat');
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(sk.activations, 2);
|
||||
approx(sk.timeLeft, 4);
|
||||
assert.equal(sk.charges, 0);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('deployment duration: early retreat/death cleans effects and leaves no stale end on redeployment', () => {
|
||||
for (const remove of ['retreat', 'kill']) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_deploy: sniperWith({ spCost: 0 }, { id: 't_deploy' }) } },
|
||||
units: [{ chessId: 't_deploy', row: 10, col: 4 }],
|
||||
kits: { t_deploy: () => ({ skill: {
|
||||
kind: 'duration', activateOnDeploy: true, duration: 4, spCost: 0, spType: 'none', trigger: 'NEVER', mods: { aspd: 50 },
|
||||
} }) },
|
||||
});
|
||||
const u = h.unit('t_deploy');
|
||||
h.run(1);
|
||||
h.b[remove](u);
|
||||
assert.equal(u.skill.active, false);
|
||||
approx(u.s.aspd, u.base.aspd);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['death']);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.run(3.1);
|
||||
assert.equal(u.skill.active, true, 'old deployment deadline does not end the new window');
|
||||
assert.equal(h.hooksOf('skillEnd').length, 1);
|
||||
h.run(1);
|
||||
assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['death', 'duration']);
|
||||
assert.equal(u.skill.ready, false);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
function sniperWith(skill, o = {}) {
|
||||
return chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100, bat: 1, ...(o.stats || {}) }, rangeGrid: RANGE3, skill, ...o });
|
||||
}
|
||||
|
||||
test('generic timed passive: deployment window owns stats and arts hits without reinterpreting passive proc parameters; permanent passive stays on', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: {
|
||||
t_sn: sniperWith({ skillType: 'PASSIVE', duration: 0, spCost: 0,
|
||||
description: '部署后在4秒内攻击力+100%,攻击造成法术伤害',
|
||||
bb: { atk: 1, duration: 4, max_target: 2, 'attack@cold': 1, ability_range_forward_extend: 1 } }),
|
||||
t_p: sniperWith({ skillType: 'PASSIVE', duration: 0, spCost: 0, description: '攻击力+100%', bb: { atk: 1, duration: 4 } }, { id: 't_p' }),
|
||||
}, enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: 't_sn', row: 10, col: 4 }, { chessId: 't_p', row: 12, col: 4 }],
|
||||
content: 'generic', captureNoisy: true,
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit('t_sn'), p = h.unit('t_p');
|
||||
assert.equal(u.skill.kind, 'duration');
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.ready, false);
|
||||
assert.deepEqual(u.rangeKeys, u.baseRangeKeys, 'passive parameters do not extend the attack range');
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(2);
|
||||
approx(u.s.atk, 200);
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
assert.ok(hits.length && hits.every((c) => c.type === 'arts'));
|
||||
assert.equal(new Set(hits.map((c) => c.target)).size, 1, 'passive max_target does not turn normal attacks into multi-target attacks');
|
||||
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [2, 4]);
|
||||
h.run(2.1);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(u.s.atk, 100);
|
||||
assert.deepEqual(u.rangeKeys, u.baseRangeKeys);
|
||||
h.run(2);
|
||||
const late = h.hooksOf('damaged').filter((c) => c.source === u && c.t > 4.5);
|
||||
assert.ok(late.length && late.every((c) => c.type === 'phys'));
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'cold').length, 0,
|
||||
'passive proc parameters do not become attack statuses');
|
||||
assert.equal(p.skill.kind, 'passive');
|
||||
assert.equal(p.skill.active, true);
|
||||
approx(p.s.atk, 200);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('generic 宴 S2: real normal/elite descriptions convert damage to arts only during the deployment window', () => {
|
||||
const ds = getDefaultSource();
|
||||
for (const id of ['chess_char_1_18_a', 'chess_char_1_18_b']) {
|
||||
const index = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_utage_2').index;
|
||||
const h = makeBattle({
|
||||
content: 'generic', timeLimit: 40, captureNoisy: true,
|
||||
units: [{ chessId: id, skillIndex: index, row: 10, col: 4 }],
|
||||
defs: { enemies: { enemy_dummy: dummy({ atk: 0 }) } },
|
||||
});
|
||||
h.b.start();
|
||||
const u = h.unit(id);
|
||||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
|
||||
h.run(2);
|
||||
assert.ok(hits().length > 0 && hits().every((c) => c.type === 'arts'), `${id}: active S2 deals arts damage`);
|
||||
h.run(u.skill.timeLeft + h.TICK);
|
||||
assert.equal(u.skill.active, false);
|
||||
assert.equal(u.skill.ready, false);
|
||||
const endedAt = hits().length;
|
||||
h.run(3);
|
||||
const late = hits().slice(endedAt);
|
||||
assert.ok(late.length > 0 && late.every((c) => c.type === 'phys'), `${id}: normal physical attacks after expiry`);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('SP: time regen, starts at initSp, capped at cost; DEFAULT waits for an enemy in the initial range', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_sn: sniperWith({ spCost: 5, initSp: 2, duration: 3, bb: { atk: 1 } }) }, enemies: { enemy_dummy: dummy() } },
|
||||
@@ -288,3 +433,33 @@ test('spCostMul changes (绝技-style) keep SP within bounds and convert overflo
|
||||
assert.equal(u.skill.charges, 1);
|
||||
assert.ok(u.skill.sp <= u.skill.spCost);
|
||||
});
|
||||
|
||||
test('a deploy-time passive fires the skill animation window; a deploy-timed skill runs its duration (PR #109)', () => {
|
||||
// 琳琅诗怀雅 S1 仗义疏财 / S2 “见面礼”: kind 'passive', no duration — the sim used to start them silently, so the
|
||||
// client got no 'skill' event and never played the clip the manifest carries (player report follow-up). The window is
|
||||
// the same one an instant cast uses: on at the deployment, off 0.5 s later (SKILL_ANIM_WINDOW), the passive itself
|
||||
// staying active (it never ends before the unit does).
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_3_04_a', row: 10, col: 6 }], autoFinish: false, timeLimit: 30 });
|
||||
h.run(0.2);
|
||||
const u = h.unit('chess_char_3_04_a');
|
||||
assert.equal(u.skill.kind, 'passive');
|
||||
assert.equal(u.skill.active, true, 'the passive is active from the deployment');
|
||||
assert.deepEqual(h.eventsOf('skill').map((e) => e[2]), [1], 'the window opens at the deployment');
|
||||
assert.ok(u.skillAnimUntil > h.b.time, 'skillAnimUntil drives ANIM.SKILL on the client');
|
||||
h.run(0.6);
|
||||
assert.deepEqual(h.eventsOf('skill').map((e) => e[2]), [1, 0], 'and closes 0.5 s later');
|
||||
assert.equal(u.skill.active, true, 'the passive itself never ends');
|
||||
|
||||
// a deploy-timed skill (缄默德克萨斯 S2 阵雨连绵, 8 s) is a real duration skill since PR #109: on at the deployment
|
||||
// (its own skill event, no 0.5 s window), off when the duration runs out, not ready again in that deployment
|
||||
const t = makeBattle({ units: [{ chessId: 'chess_char_4_16_a', row: 10, col: 6, skillIndex: 1 }], autoFinish: false, timeLimit: 30 });
|
||||
t.run(0.6);
|
||||
const tx = t.unit('chess_char_4_16_a');
|
||||
assert.equal(tx.skill.kind, 'duration');
|
||||
assert.equal(tx.skill.active, true);
|
||||
assert.deepEqual(t.eventsOf('skill').map((e) => e[2]), [1], 'on at the deployment');
|
||||
t.run(12);
|
||||
assert.equal(tx.skill.active, false, 'the duration ran out');
|
||||
assert.deepEqual(t.eventsOf('skill').map((e) => e[2]), [1, 0], 'and the skill ended');
|
||||
assert.equal(tx.skill.ready, false, 'one charge per deployment');
|
||||
});
|
||||
Reference in new issue
Block a user