0.2.5-dev: merge claude/036-touch

# Conflicts:
#	CHANGELOG.md
#	docs/history/0.2.5.md
This commit is contained in:
sganggs committed 2026-10-12 15:00:10 +08:00
commit a8ff5e7c1d
22 files changed
+294 -59

No files matched your search

+85
View File
@@ -711,6 +711,91 @@ test('Touch 外勤医疗: every player of a match with the band gets 预备干
cover('band_amedic');
});
test('外勤医疗: 预备干员-医疗 and Touch keep preplaced targeting order against a flying 守墓石像', () => {
const defs = {
harold: chessRec({ id: 'harold', profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite1: chessRec({ id: 'elite1', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite2: chessRec({ id: 'elite2', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
};
for (const [eliteCount, mapCharId] of [[1, 'char_605_cmedic'], [2, 'char_613_acmedc']]) {
const h = makeBattle({
stageId: 'act2autochess_m01', bandId: 'band_amedic', autoFinish: false, timeLimit: 100,
defs: { chess: defs }, units: [
{ chessId: 'harold', row: 11, col: 3 },
{ chessId: 'elite1', row: 12, col: 8 },
...(eliteCount === 2 ? [{ chessId: 'elite2', row: 9, col: 8 }] : []),
],
});
h.step();
const medic = h.unit(mapCharId), harold = h.unit('harold');
const ops = h.allies().filter((u) => u.kind === 'op');
assert.ok(medic?.alive && harold?.alive, mapCharId);
assert.ok(ops.every((u) => medic.deploySeq > u.deploySeq), 'battleStart creates the map character after initial deployment');
assert.ok(ops.every((u) => medic.aggroSeq < u.aggroSeq), 'its targeting order precedes every initial operator');
assert.equal(medic.s.taunt, harold.s.taunt, 'the two targets have equal taunt');
const gargoyle = h.spawn('enemy_1172_dugago', { pos: [10, 3], routeIndex: 0, mods: { speedMul: 0 } });
assert.ok(gargoyle);
h.b.kill(gargoyle, null); // first knock-out changes 守墓石像 into its flying form
assert.ok(gargoyle.alive);
const attacks = [];
h.b.on('enemyAttackStart', ({ enemy, targets }) => {
if (enemy === gargoyle && gargoyle.motion === 'FLY') attacks.push({ target: targets[0], x: enemy.x, y: enemy.y });
});
assert.ok(h.runUntil(() => attacks.length > 0, 20), 'flying gargoyle starts an attack');
const { target, x, y } = attacks[0];
const reach = gargoyle.base.rangeRadius + 0.25;
for (const u of [harold, medic]) assert.ok((u.x - x) ** 2 + (u.y - y) ** 2 <= reach ** 2, `${u.defId} is in range at target lock`);
assert.equal(gargoyle.blockedBy, null);
assert.equal(target, harold, `${mapCharId}: the later ordinary operator is selected`);
h.b.addBuff(medic, { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
assert.ok(h.runUntil(() => attacks.length > 1, 20), 'gargoyle starts another attack');
assert.equal(attacks[1].target, medic, 'higher taunt still takes precedence over deployment order');
checkInvariants(h.b);
}
});
test('外勤医疗: 联防右侧 Touch retains its prepared targeting order', () => {
const defs = {
harold: chessRec({ id: 'harold', profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite1: chessRec({ id: 'elite1', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite2: chessRec({ id: 'elite2', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
};
const players = [
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, bandId: 'band_amedic', bonds: {}, playerEffects: [], units: [] },
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, bandId: 'band_amedic', bonds: {}, playerEffects: [], units: [
{ uid: 1, kind: 'chess', chessId: 'harold', row: 11, col: 3 },
{ uid: 2, kind: 'chess', chessId: 'elite1', row: 12, col: 8 },
{ uid: 3, kind: 'chess', chessId: 'elite2', row: 9, col: 8 },
] },
];
const h = makeBattle({ kind: 'unite', stageId: 'act2autochess_m01', players, defs: { chess: defs }, autoFinish: false });
h.step();
const leftMedic = h.allies().find((u) => u.ownerId === 'L' && u.defId === 'char_605_cmedic');
const rightTouch = h.allies().find((u) => u.ownerId === 'R' && u.defId === 'char_613_acmedc');
const harold = h.allies().find((u) => u.ownerId === 'R' && u.defId === 'harold');
assert.ok(leftMedic && rightTouch && harold);
assert.deepEqual([leftMedic.tileR, leftMedic.tileC], [10, 2]);
assert.deepEqual([rightTouch.tileR, rightTouch.tileC], [10, 10]);
assert.ok(leftMedic.aggroSeq < rightTouch.aggroSeq && rightTouch.aggroSeq < harold.aggroSeq);
assert.ok(rightTouch.deploySeq > harold.deploySeq);
const gargoyle = h.spawn('enemy_1172_dugago', { pos: [10, 11], routeIndex: 0, mods: { speedMul: 0 } });
assert.ok(gargoyle);
h.b.kill(gargoyle, null);
const attacks = [];
h.b.on('enemyAttackStart', ({ enemy, targets }) => {
if (enemy === gargoyle && enemy.motion === 'FLY') attacks.push({ target: targets[0], x: enemy.x, y: enemy.y });
});
assert.ok(h.runUntil(() => attacks.length > 0, 20));
const { target, x, y } = attacks[0];
const reach = gargoyle.base.rangeRadius + 0.25;
for (const u of [harold, rightTouch]) assert.ok((u.x - x) ** 2 + (u.y - y) ** 2 <= reach ** 2);
assert.equal(gargoyle.blockedBy, null);
assert.equal(target, harold);
checkInvariants(h.b);
});
test('coverage: every band is exercised by a test above', () => {
cover('band_dusk'); // 夕: 画卷 grant (fixed-round test) + 墨色真颜 (battle test)
const missing = Object.keys(DATA.bands).filter((id) => !COVER.has(id)).sort();
+2
View File
@@ -511,6 +511,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
assert.ok(med);
assert.deepEqual([med.tileR, med.tileC], [10, 2]);
const gu = h.unit('test_guard');
assert.ok(med.aggroSeq > gu.aggroSeq, 'an ad-hoc map character keeps ordinary summon priority');
gu.hp = 5000;
h.run(4);
assert.ok(gu.hp > 5000, 'healed');
@@ -518,6 +519,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
h.step();
const touch = spawnMapChar(h.b, 'p1', TOKEN_IDS.touch);
assert.ok(touch);
assert.ok(touch.aggroSeq > gu.aggroSeq, 'an ad-hoc Touch also keeps ordinary summon priority');
touch.skill.activate('test', { free: true });
gu.hp = 3000;
h.run(4);
+7 -7
View File
@@ -1575,17 +1575,17 @@
{
"round": 11,
"h_0": [1,28,12,0,0,5,8,"b5da5048",139,"arcaneShip 0/8, deputShip 0/6, emptyShip 1/6, kazimierzShip 0/4, preciShip 1/42, raidShip 1/31, skillfulShip 0/2, soloShip 1/6, steadShip 1/20, swiftShip 1/34, yanShip 0/1",25,25,0,98],
"ai_0": [1,23,12,0,0,5,8,"083c8c02",149,"arcaneShip 1/19, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/5, preciShip 2/24, raidShip 1/64, sargonShip 0/2, suntShip 1/8, swiftShip 1/29",25,25,0,99]
"ai_0": [1,23,12,0,0,5,8,"083c8c02",161,"arcaneShip 1/19, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/11, preciShip 2/30, raidShip 1/64, sargonShip 0/2, suntShip 1/8, swiftShip 1/29",25,25,0,99]
},
{
"round": 12,
"h_0": [1,28,12,0,0,5,8,"093d3c03",157,"arcaneShip 0/8, deputShip 0/6, egirShip 1/3, emptyShip 0/6, kazimierzShip 0/4, preciShip 1/49, raidShip 1/36, skillfulShip 0/2, soloShip 1/6, steadShip 1/24, suntShip 1/0, swiftShip 1/39, yanShip 0/1",23,31,8,109],
"ai_0": [1,23,12,0,0,6,8,"7258fcd5",191,"arcaneShip 1/21, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/11, preciShip 2/37, raidShip 1/80, sargonShip 0/2, suntShip 1/8, swiftShip 1/34",31,31,0,112]
"ai_0": [1,23,12,0,0,6,8,"7258fcd5",206,"arcaneShip 1/24, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/17, preciShip 2/43, raidShip 1/80, sargonShip 0/2, suntShip 1/8, swiftShip 1/34",31,31,0,112]
},
{
"round": 13,
"h_0": [1,18,12,0,0,6,8,"ba279997",190,"arcaneShip 0/8, deputShip 0/6, egirShip 1/11, emptyShip 0/6, kazimierzShip 0/4, preciShip 1/54, raidShip 1/44, skillfulShip 0/2, soloShip 1/6, steadShip 1/31, suntShip 1/0, swiftShip 1/44, yanShip 0/1",21,57,36,120],
"ai_0": [1,13,12,0,0,6,8,"284baa2f",234,"arcaneShip 0/21, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/17, preciShip 2/48, raidShip 1/112, sargonShip 0/2, siracusaShip 0/6, soloShip 1/0, steadShip 1/4, suntShip 1/8, swiftShip 1/45",31,57,26,122]
"h_0": [1,18,12,0,0,6,8,"ba279997",190,"arcaneShip 0/8, deputShip 0/6, egirShip 1/11, emptyShip 0/6, kazimierzShip 0/4, preciShip 1/54, raidShip 1/44, skillfulShip 0/2, soloShip 1/6, steadShip 1/31, suntShip 1/0, swiftShip 1/44, yanShip 0/1",22,57,35,120],
"ai_0": [1,13,12,0,0,6,8,"284baa2f",246,"arcaneShip 0/24, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/23, preciShip 2/54, raidShip 1/112, sargonShip 0/2, siracusaShip 0/6, soloShip 1/0, steadShip 1/4, suntShip 1/8, swiftShip 1/45",31,57,26,122]
}
],
"players": {
@@ -1599,7 +1599,7 @@
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":172,"bossDamage":193091,"dmgDealt":2564165,"gold":131,"itemsEquipped":16,"kills":272,"leaks":44,"lpLost":10,"merges":4,"perfectRounds":11,"refreshes":13},
"stats": {"activatedLayers":172,"bossDamage":189876,"dmgDealt":2565610,"gold":131,"itemsEquipped":16,"kills":273,"leaks":43,"lpLost":10,"merges":4,"perfectRounds":11,"refreshes":13},
"lineup": ["4_14_a@12,3[4_07_e_b+4_03_e_a]","3_10_b@11,3","6_04_a@11,4","3_09_a@11,8[3_08_e_a]","1_17_b@10,3[1_01_e_a+6_05_e_a]","5_05_a@10,4[3_04_e_a+4_02_e_a]","3_05_b@10,8[5_02_e_b+3_03_e_a]","2_17_a@9,4"],
"bonds": ["arcaneShip 1/8","deputShip 0/6","egirShip 1/11","emptyShip 0/6","kazimierzShip 0/8","preciShip 1/59","raidShip 0/44","skillfulShip 0/2","soloShip 1/6","steadShip 1/35","suntShip 1/0","swiftShip 1/53","yanShip 0/1"]
},
@@ -1613,9 +1613,9 @@
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":267,"bossDamage":586909,"dmgDealt":3838927,"gold":134,"itemsEquipped":19,"kills":276,"leaks":26,"lpLost":10,"merges":4,"perfectRounds":12,"refreshes":18},
"stats": {"activatedLayers":282,"bossDamage":590124,"dmgDealt":3843408,"gold":134,"itemsEquipped":19,"kills":276,"leaks":26,"lpLost":10,"merges":4,"perfectRounds":12,"refreshes":18},
"lineup": ["4_10_a@12,3","4_01_a@12,8[6_02_e_a+2_01_e_a]","6_12_a@11,3[3_05_e_a+3_11_e_a]","4_14_a@11,8","1_01_b@10,3[1_05_e_b+3_01_e_b]","5_07_a@10,7[3_07_e_a+3_05_e_b]","1_18_b@10,8[4_11_e_a+4_05_e_a]","2_01_b@9,8[3_02_e_a+3_06_e_a]"],
"bonds": ["arcaneShip 1/27","deputShip 0/8","kazimierzShip 0/8","lateranoShip 1/17","preciShip 2/53","raidShip 1/112","sargonShip 0/2","siracusaShip 0/6","steadShip 0/4","suntShip 1/8","swiftShip 1/50"]
"bonds": ["arcaneShip 1/30","deputShip 0/8","kazimierzShip 0/8","lateranoShip 1/23","preciShip 2/59","raidShip 1/112","sargonShip 0/2","siracusaShip 0/6","steadShip 0/4","suntShip 1/8","swiftShip 1/50"]
}
},
"standIns": {
+26 -8
View File
@@ -96,7 +96,7 @@ describe('render engine in headless Chrome', { skip }, () => {
} finally { await page.close(); }
});
test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => {
test('外勤医疗 reserve medic opens its detail from normal and right boss preparation without being draggable', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
const first = await page.evaluate(() => {
const v = window.__demo.view;
@@ -108,8 +108,14 @@ describe('render engine in headless Chrome', { skip }, () => {
v.setPrep(st, { editable: true });
const medic = v.debug.views.get('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
let clicked = null;
const off = v.on('pieceClick', (e) => { clicked = e; });
v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 61, clientX: p.x, clientY: p.y }));
off();
const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y,
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) };
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top),
clicked: clicked ? { uid: clicked.uid, id: clicked.unit?.defId, row: clicked.unit?.y, col: clicked.unit?.x,
prepMapChar: clicked.prepMapChar } : null };
return out;
});
await wait(500);
@@ -118,7 +124,13 @@ describe('render engine in headless Chrome', { skip }, () => {
const v = window.__demo.view;
v.setCamera('bossPrep', { side: 'R', instant: true });
const mirrored = v.debug.views.get('m:-1');
return [mirrored?.x, mirrored?.y, mirrored?.dir];
const p = v.tileScreen(10, 2);
let clicked = null;
const off = v.on('pieceClick', (e) => { clicked = e; });
v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 62, clientX: p.x, clientY: p.y }));
off();
return { view: [mirrored?.x, mirrored?.y, mirrored?.dir],
clicked: clicked ? [clicked.unit?.y, clicked.unit?.x, clicked.unit?.dir, clicked.prepMapChar] : null };
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') });
@@ -127,7 +139,8 @@ describe('render engine in headless Chrome', { skip }, () => {
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [];
v.setPrep(st, { editable: true });
return !v.debug.views.has('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
return !v.debug.views.has('m:-1') && !v.debug.pick.mapCharAt(p.x - rect.left, p.y - rect.top);
});
const fallback = await page.evaluate(async () => {
const { createFallbackView } = await import('/js/ui/fallbackField.js');
@@ -138,18 +151,23 @@ describe('render engine in headless Chrome', { skip }, () => {
ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const el = host.querySelector('.ff-piece--map-char');
let clicked = null;
const off = ff.on('pieceClick', (e) => { clicked = e; });
el?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 63 }));
off();
const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null,
movableId: el?.dataset.uid ?? null };
movableId: el?.dataset.uid ?? null, clicked: clicked ? [clicked.unit?.defId, clicked.prepMapChar] : null };
ff.destroy(); host.remove();
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
draggable: false });
assert.deepEqual(boss, [18, 3, 'LEFT']);
draggable: false, clicked: { uid: -1, id: 'char_605_cmedic', row: 10, col: 2, prepMapChar: true } });
assert.deepEqual(boss, { view: [18, 3, 'LEFT'], clicked: [10, 2, 'RIGHT', true] });
assert.equal(removed, true);
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null });
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'auto', movableId: null,
clicked: ['char_605_cmedic', true] });
});
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
+12
View File
@@ -44,6 +44,18 @@ test('user Given strategy medics When inspecting them Then their healing tiles r
}
});
test('user Given a prepared map medic When its card opens Then its board-space range is visible before battle', () => {
for (const id of ['char_605_cmedic', 'char_613_acmedc']) {
const unit = medic(id, { id: -1, uid: -1, area: 'board', x: 2, y: 10, dir: 'RIGHT' });
const r = inspectRange({ ...args(unit), target: { kind: 'unit', unit, prepMapChar: true }, showPrep: true,
field: { prep: false }, snapshot: [99, 18, 3, 1202, 1202] });
assert.deepEqual([r.row, r.col, r.dir], [10, 2, 'RIGHT'], 'use the original board coordinates even on the mirrored boss half');
assert.equal(rangeTiles(r.grid, r.row, r.col, r.dir).length, 12);
assert.equal(inspectRange({ ...args(unit), target: { kind: 'unit', unit }, showPrep: true }), null,
'other unit cards do not become prep-range previews');
}
});
test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => {
const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } }));
assert.equal(r.grid.length, 18);
+13 -9
View File
@@ -134,15 +134,19 @@ test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and sh
['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2</>', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']],
['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%</>', '攻击提升', '攻击力+4%', '恢复友方单位生命']],
]) {
// a battle click resolves the unit as its token record
const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map());
assert.equal(detail.type, 'token');
const text = allText(TokenDetail(detail));
assert.match(text, /干员/);
assert.match(text, /医疗/);
assert.doesNotMatch(text, /召唤物/);
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
for (const unit of [
{ id: 91, side: 'ally', defId: id }, // battle click
{ id: -1, uid: -1, kind: 'token', side: 'ally', defId: id, area: 'board', x: 2, y: 10 }, // preparation preview click
]) {
const detail = resolveDetail({ kind: 'unit', unit, prepMapChar: unit.id === -1 }, new Map());
assert.equal(detail.type, 'token');
const text = allText(TokenDetail(detail));
assert.match(text, /干员/);
assert.match(text, /医疗/);
assert.doesNotMatch(text, /召唤物/);
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
}
}
// the record keeps the official sentence; the line under it is what touchGospel does (strictly below hp_ratio)
assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/);