Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-clemnt.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

233 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 克莱门莎 (char_4231_clemnt), 本源近卫. Official CN tables 2026-10-08 (client 2.7.81),
// PRTS 克莱门莎, 2026-10-09: all skills at ranks 4/7 and both potential-sensitive talents.
// https://prts.wiki/w/克莱门莎 . No module was released with her.
// PRTS still marks several launch-day details unknown: [ASSUMED] carriage's unresolved 放逐 combination =
// cannot walk/attack/be blocked, but remains damageable;
// its boundary clearance 0.4, carriage updates once per sim tick, vortex ends with the skill;
// S3 first bombardment at 0.5 s, then twice at 1 s intervals, launched shots survive skill end.
// These pending confirmations are listed in DESIGN §28.1; keep each assumption local to this kit.
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys, hitRect } from '../../../body.js';
import { dirVec } from '../../../dir.js';
import { COLS } from '../../../constants.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skchr_clemnt_1', S2 = 'skchr_clemnt_2', S3 = 'skchr_clemnt_3';
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
const AIR = Object.freeze({ canHitFly: true });
const SPEED = 2, DISTANCE = 3, CATCH_HALF = 0.55, VORTEX_RADIUS = 1.5;
const SPLASH_RADIUS = 1.1; // PRTS 2026-10-10: collision test; four secondary targets take 100% ATK.
const FIRST_BOMB = 0.5; // [ASSUMED] Neither PRTS nor the edited official demo settles the first landing offset.
const CAST = 'clemnt:cast', CARRY = 'clemnt:carry:';
const skillBb = (raw, id) => skillRec(raw, id)?.bb ?? {};
const tile = (u) => [Math.round(u.y), Math.round(u.x)];
const immunity = (e) => e.def?.raw?.stats?.otherImmunities ?? [];
// No current mode enemy has the three named teleport immunities. Also honour explicit normalized flags for custom data.
const cannotCarry = (e) => e.s.flags.selfBound || e.def?.immune?.has('exile') || e.def?.immune?.has('teleport')
|| immunity(e).some((s) => /teleport|transmit|exile/i.test(String(s)));
/** Cabin's 1.1 × 1.1 collision square; huge bodies use the engine hit rectangle. */
function touches(e, x, y) {
const r = hitRect(e);
return r ? r.x1 >= x - CATCH_HALF && r.x0 <= x + CATCH_HALF && r.y1 >= y - CATCH_HALF && r.y0 <= y + CATCH_HALF
: Math.abs(e.x - x) <= CATCH_HALF + 1e-9 && Math.abs(e.y - y) <= CATCH_HALF + 1e-9;
}
export default {
char_4231_clemnt: (bb, raw) => {
const t0 = talentBb(raw, 0), t1 = talentBb(raw, 1);
const b1 = skillBb(raw, S1), b2 = skillBb(raw, S2), b3 = skillBb(raw, S3);
const rider = ({ battle, unit, target, dealt }, ratio) => {
if (dealt > 0 && hasHp(target)) battle.dealDamage(unit, target, {
amount: dealt * ratio, type: 'element', element: 'erosion', isSkill: true, tags: ['skill', 'clemnt:rider'],
});
};
const freePassengers = (battle, unit) => {
const c = unit.mem.clemntCabin;
if (c) for (const e of c.passengers) battle.removeBuff(e, CARRY + unit.id);
battle.removeBuff(unit, CAST);
};
const finishCabin = (battle, unit, c) => {
if (c.stopped) return;
c.stopped = true;
freePassengers(battle, unit);
// PRTS: vortex at the cabin's actual stopping position, radius 1.5, can hit air.
c.tick = 0;
battle.fx('tide', { x: c.x, y: c.y, r: VORTEX_RADIUS, dur: unit.skill.timeLeft, id: unit.id });
};
const startCabin = (battle, unit) => {
const [dr, dc] = dirVec(unit.dir), [r, col] = tile(unit);
let distance = DISTANCE;
for (let d = 1; d <= DISTANCE + 1; d++) {
const rr = r + dr * d, cc = col + dc * d, t = battle.grid.tile(rr, cc);
if (!battle.grid.groundPassable(rr, cc) || t.height === 'HIGH' || t.key === 'tile_hole') {
// [ASSUMED] PRTS's provisional rule: stop 0.4 tiles before the impassable edge.
distance = Math.min(distance, Math.max(0, d - 0.5 - 0.4)); break;
}
}
const c = unit.mem.clemntCabin = { x: col, y: r, ox: col, oy: r, dr, dc, distance, moved: 0,
mass: num(b2['attack@max_passenger_mass'], 8), passengers: [], stopped: false, tick: 0 };
battle.addBuff(unit, { key: CAST, duration: 10, flags: { disarm: true }, tags: ['skill'] });
battle.fx('anchor', { x: col + dc * distance, y: r + dr * distance, fromX: col, fromY: r, id: unit.id, dur: distance / SPEED });
if (distance <= 1e-9) finishCabin(battle, unit, c);
};
const tickCabin = (battle, unit, dt) => {
const c = unit.mem.clemntCabin;
if (!c || !up(unit)) return;
if (!c.stopped) {
c.moved = Math.min(c.distance, c.moved + SPEED * dt);
c.x = c.ox + c.dc * c.moved; c.y = c.oy + c.dr * c.moved;
// Release before either boarding limit is checked: invalid riders occupy neither a slot
// nor weight. Return their current weight, including modifiers applied during the ride.
for (let i = c.passengers.length - 1; i >= 0; i--) {
const e = c.passengers[i], [er, ec] = tile(e);
if (!e.alive || e.hidden || cannotCarry(e)
|| !battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) {
battle.removeBuff(e, CARRY + unit.id);
c.passengers.splice(i, 1);
c.mass += e.weight;
}
}
const cap = num(b2['attack@max_passenger_cnt'], 15);
for (const e of battle.enemies) {
if (c.passengers.length >= cap) break;
if (!e.alive || !e.deployed || e.hidden || e.isFlying || cannotCarry(e) || c.passengers.includes(e)
|| e.weight > c.mass + 1e-9 || !touches(e, c.x, c.y)) continue;
const [er, ec] = tile(e);
if (!battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) continue;
c.mass -= e.weight;
c.passengers.push(e);
battle._unblock(e);
// [ASSUMED] PRTS has not yet resolved every status in 放逐; no invulnerability or hide is invented.
battle.addBuff(e, { key: CARRY + unit.id, flags: { noMove: true, disarm: true, unblockable: true }, source: unit, tags: ['skill'] });
}
for (const e of c.passengers) {
if (!e.findBuff(CARRY + unit.id)) continue;
const fromX = e.x, fromY = e.y;
e.x = c.x; e.y = c.y;
if (e.route) e.route.pts = null;
battle.fx('teleport', { x: e.x, y: e.y, fromX, fromY, id: e.id, src: unit.id });
}
if (c.moved >= c.distance - 1e-9) finishCabin(battle, unit, c);
return;
}
const foes = battle.enemiesInRadius(c.x, c.y, VORTEX_RADIUS).filter((e) => canTargetEnemy(unit, e, AIR));
for (const e of foes) battle.addBuff(e, { key: `clemnt:vortex:${unit.id}`, duration: 0.1,
mods: { moveMul: 1 + num(b2['attack@move_speed'], -0.7) }, source: unit, tags: ['skill'] });
c.tick += dt;
if (c.tick < 1 - 1e-9) return;
c.tick -= 1;
for (const e of foes) {
battle.dealDamage(unit, e, { amount: num(b2['attack@water_element'], 200), type: 'element',
element: 'erosion', isSkill: true, canDodge: false, tags: ['skill', 'dot', 'clemnt:vortex:erosion'] });
if (hasHp(e)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2['attack@physical_atk_scale']),
type: 'phys', canDodge: false, isSkill: true, tags: ['skill', 'dot', 'clemnt:vortex'] });
}
};
const bombard = (battle, unit, e, m, skill) => {
const [r, c] = tile(e), key = r * COLS + c;
if ((m.tiles.get(key) ?? -1) > battle.time) return false;
m.tiles.set(key, battle.time + num(b3.projectile_life_time, 5));
const keys = new Set([[r, c], [r + 1, c], [r - 1, c], [r, c + 1], [r, c - 1]].map(([rr, cc]) => rr * COLS + cc));
const atk = unit.s.atk;
battle.fx('lock', { x: c, y: r, id: e.id, src: unit.id, dur: FIRST_BOMB });
for (let n = 0; n < 3; n++) battle.after(FIRST_BOMB + n, () => {
battle.fx('blast', { x: c, y: r, r: 1.5, id: unit.id });
for (const t of battle.enemies) {
if (!canTargetEnemy(unit, t, GROUND) || !bodyInKeys(t, keys)) continue;
battle.dealDamage(unit, t, { amount: atk * num(b3.s3_atk_scale), type: 'phys', canDodge: false,
isSkill: true, tags: ['skill', 'clemnt:bomb'] });
if (hasHp(t)) battle.dealDamage(unit, t, { amount: atk * num(b3.ep_damage_scale), type: 'elemental', element: 'erosion',
canDodge: false, isSkill: true, tags: ['skill', 'clemnt:bomb'] });
}
}, { owner: unit, holdsBattle: true });
skill.ammoLeft--;
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
return true;
};
return {
skills: {
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1),
onHit(c) { rider(c, num(b1.ep_damage_ratio)); } } },
[S2]: {
kind: 'duration', mods: { aspd: num(b2.attack_speed) },
targeting: { rangeGrid: skillRec(raw, S2)?.rangeGrid },
attack: { atkScale: num(b2['attack@aoe_atk_scale'], 1), groundOnly: true,
onEachHit({ battle, unit, target, kind, attackId }) {
if (kind !== 'main' || !target) return;
// Main victim plus at most four neighbours, never every unit in the radius.
// [ASSUMED] Ties among splash victims follow the engine's stable spawn order.
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS)
.filter((e) => e !== target && canTargetEnemy(unit, e, GROUND))
.slice(0, Math.max(0, num(b2['attack@max_target'], 5) - 1));
for (const e of near) battle.dealDamage(unit, e, {
amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys',
isAttack: true, isSplash: true, isSkill: true, attackId, tags: ['skill', 'clemnt:splash'],
});
} },
onStart({ battle, unit }) { startCabin(battle, unit); },
onTick({ battle, unit, dt }) { tickCabin(battle, unit, dt); },
onEnd({ battle, unit }) { freePassengers(battle, unit); unit.mem.clemntCabin = null; },
},
[S3]: {
kind: 'ammo', ammo: num(b3.trigger_time, 10),
targeting: { rangeGrid: skillRec(raw, S3)?.rangeGrid, maxTargets: num(b3['attack@max_target'], 3) },
attack: { atkScale: num(b3['attack@atk_scale'], 1), onHit(c) { rider(c, num(b3['attack@ep_damage_ratio'])); } },
onAttack(c) { c.noAmmo = true; },
onStart({ unit }) { unit.mem.clemntBombs = { marks: new Map(), tiles: new Map() }; },
onTick({ battle, unit, skill }) {
const m = unit.mem.clemntBombs;
if (!m || !unit.canAct) return;
const choices = [];
for (const [e, until] of m.marks) {
if (until < battle.time - 1e-9 || !e.alive || e.hidden) { m.marks.delete(e); continue; }
if (canTargetEnemy(unit, e, GROUND)) choices.push(e);
}
const dist2 = (e) => { const dx = e.x - unit.x, dy = e.y - unit.y; return dx * dx + dy * dy; };
choices.sort((a, b) => dist2(a) - dist2(b) || a.spawnSeq - b.spawnSeq);
for (const e of choices) if (bombard(battle, unit, e, m, skill)) { m.marks.delete(e); break; }
if (skill.ammoLeft <= 0) skill.end('ammo');
},
onEnd({ unit }) { unit.mem.clemntBombs = null; },
},
},
talents: [
{ install(battle, unit) { // 生死定夺: ATK scale BEFORE DEF, per physical damage instance.
battle.on('hit', ({ source, target, dmg }) => {
if (source !== unit || dmg.type !== 'phys' || !target || target.side !== 'enemy') return;
const chance = target.s.def < target.base.def ? num(t0.prob_special) : num(t0.prob_normal);
if (battle.rng() < chance) dmg.amount *= num(t0.t1_atk_scale, 1);
}, { owner: unit });
} },
{ install(battle, unit) { // 习得性防御: source tile in forward half-plane, plus every enemy she blocks.
battle.on('hit', ({ source, target, dmg }) => {
if (target !== unit || !source || (dmg.type !== 'phys' && dmg.type !== 'arts')) return;
const [dr, dc] = dirVec(unit.dir), [r, c] = tile(source);
if (source.blockedBy !== unit && (r - unit.tileR) * dr + (c - unit.tileC) * dc < 0) return;
const sea = source.def?.tags?.includes('seamonster');
dmg.mul *= 1 - num(sea ? t1.damage_resistance_seamonster : t1.damage_resistance_normal);
}, { owner: unit });
} },
],
install(battle, unit) {
if (unit.skill?.id === S2) {
// Maintainer 2026-10-10 (#467): follow Chen S3 automation. The vortex hits air while normal attacks do not.
// [ASSUMED] No mode footage establishes a separate auto-cast shape for this new operator; use the expanded
// skill attack grid with permanent extensions, retaining shared operation cooldown/control checks.
unit.skill.addTriggerRange(() => [{
keys: absoluteRangeKeys(skillRec(raw, S2)?.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
profile: AIR,
}]);
}
battle.on('elementBurst', ({ target, element }) => {
if (element !== 'erosion' || target?.side !== 'enemy' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|| !canTargetEnemy(unit, target, GROUND)) return;
unit.mem.clemntBombs?.marks.set(target, battle.time + num(b3.ep_break_duration, 1));
}, { owner: unit });
},
};
},
};