sotc-foundry-suite/sotc-damage-wizard/module/DamageEngine.js

798 lines
32 KiB
JavaScript
Raw Permalink 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.

/**
* sotc-damage-wizard | DamageEngine.js
*
* Moteur de résolution des clashes selon les règles officielles SotC.
*
* ──────────────────────────────────────────────────────────────────────
* RÈGLES DE CLASH (séquence linéaire)
* ──────────────────────────────────────────────────────────────────────
*
* Les dés clashent dans l'ordre : dé[0] vs dé[0], dé[1] vs dé[1], etc.
* Les dés restants (sans opposant) sont "sans opposition" (Unopposed).
* Après un clash, les DEUX dés sont détruits sauf exception.
*
* Offensive (Slash/Pierce/Blunt) :
* WIN ou UNOPPOSED → HP damage = Power, Stagger damage = Power
* TIE → Rien
* LOSE → Rien
*
* Défensive – Block :
* WIN → Stagger = Power_block - Power_opposing
* TIE → Rien
* LOSE vs Off → Réduit HP damage et Stagger pris = Power_block
* UNOPPOSED → Saved Defense Die (état 2)
*
* Défensive – Evade :
* WIN vs Off → Recycle ce dé (réutilisé), aucun autre effet WIN
* WIN vs Def → Regain Stagger Resist = Power_evade - Power_opposing
* TIE → Rien
* LOSE vs Off → Réduit Stagger pris = Power_evade
* UNOPPOSED → Saved Defense Die (état 2), sauf si recyclé
*
* Counter Dice (counter-slash/pierce/blunt/block/evade) :
* - Ne se déclenchent PAS si Unopposed
* - Si WIN → réutilisé contre le prochain dé adverse
* - Détruits à la fin de la compétence adverse
* → Donnent état 2 (Saved Defense Die) sur le Dé de Vitesse
*
* PE (Points d'Émotion) :
* Chaque clash (entre deux dés, peu importe le résultat) donne +1 PE
* aux DEUX combatants.
*
* ──────────────────────────────────────────────────────────────────────
*/
/* ------------------------------------------------------------------ */
/* Constantes */
/* ------------------------------------------------------------------ */
export const OFFENSIVE_TYPES = new Set([
"slash", "pierce", "blunt"
]);
export const COUNTER_OFFENSIVE_TYPES = new Set([
"counter-slash", "counter-pierce", "counter-blunt"
]);
export const BLOCK_TYPES = new Set([
"block"
]);
export const COUNTER_BLOCK_TYPES = new Set([
"counter-block"
]);
export const EVADE_TYPES = new Set([
"evade"
]);
export const COUNTER_EVADE_TYPES = new Set([
"counter-evade"
]);
// Tous les types offensifs (normaux + counter)
export const ALL_OFFENSIVE = new Set([
...OFFENSIVE_TYPES, ...COUNTER_OFFENSIVE_TYPES
]);
// Tous les types défensifs (normaux + counter)
export const ALL_DEFENSIVE = new Set([
...BLOCK_TYPES, ...COUNTER_BLOCK_TYPES,
...EVADE_TYPES, ...COUNTER_EVADE_TYPES
]);
// Tous les types Counter
export const ALL_COUNTER = new Set([
...COUNTER_OFFENSIVE_TYPES, ...COUNTER_BLOCK_TYPES, ...COUNTER_EVADE_TYPES
]);
/* ------------------------------------------------------------------ */
/* Helpers de typage */
/* ------------------------------------------------------------------ */
export function isOffensive(type) { return ALL_OFFENSIVE.has(type); }
export function isDefensive(type) { return ALL_DEFENSIVE.has(type); }
export function isCounter(type) { return ALL_COUNTER.has(type); }
export function isBlock(type) { return BLOCK_TYPES.has(type) || COUNTER_BLOCK_TYPES.has(type); }
export function isEvade(type) { return EVADE_TYPES.has(type) || COUNTER_EVADE_TYPES.has(type); }
/* ------------------------------------------------------------------ */
/* Récupération de la compétence assignée */
/* ------------------------------------------------------------------ */
export function getAssignedSkill(combatant) {
const skillId = combatant?.getFlag("sotc-battle-show-ability", "assignedSkillId") ?? null;
if (!skillId) return null;
return combatant.actor?.items.get(skillId) ?? null;
}
export function getSkillDice(skill) {
const raw = skill.system.dice?.die ?? [];
const arr = Array.isArray(raw) ? raw : Object.values(raw);
return arr.map(d => ({
type: d.type ?? "slash",
formula: d.formula ?? "1d6",
mods: Array.isArray(d.mods)
? d.mods.filter(Boolean)
: Object.values(d.mods ?? {}).filter(Boolean)
}));
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé (roll + modificateurs de statuts) */
/* ------------------------------------------------------------------ */
export async function resolveDie(die, actor, extraMod = 0) {
const mods = actor.system.modifiers ?? {};
const match = die.formula.match(/^\s*(\d+)\s*d\s*(\d+)((?:\s*[+-]\s*\d+)*)\s*$/);
if (!match) {
const r = await new Roll("0").roll({ async: true });
return { roll: r, total: 0, formulaDisplay: `Formule invalide: ${die.formula}` };
}
const numDice = parseInt(match[1]);
const dieSize = parseInt(match[2]);
const modStr = (match[3] || "").replace(/\s+/g, "");
const baseMod = modStr.split(/(?=[+-])/).filter(s => s.length)
.reduce((sum, s) => sum + parseInt(s), 0);
let statusMod = mods.all_mod ?? 0;
if (isOffensive(die.type)) statusMod += mods.off_mod ?? 0;
if (isDefensive(die.type)) statusMod += mods.def_mod ?? 0;
switch (die.type) {
case "slash": case "counter-slash": statusMod += mods.slash_mod ?? 0; break;
case "pierce": case "counter-pierce": statusMod += mods.pierce_mod ?? 0; break;
case "blunt": case "counter-blunt": statusMod += mods.blunt_mod ?? 0; break;
case "block": case "counter-block": statusMod += mods.block_mod ?? 0; break;
case "evade": case "counter-evade": statusMod += mods.evade_mod ?? 0; break;
}
const totalMod = baseMod + extraMod + statusMod;
const formula = `${numDice}d${dieSize} + ${totalMod}`;
const roll = await new Roll(formula).roll({ async: true });
let display = `${numDice}d${dieSize}`;
if (baseMod !== 0) display += baseMod > 0 ? ` + ${baseMod}` : ` - ${Math.abs(baseMod)}`;
if (extraMod !== 0) display += extraMod > 0 ? ` + ${extraMod}` : ` - ${Math.abs(extraMod)}`;
if (statusMod !== 0) display += statusMod > 0 ? ` + ${statusMod}` : ` - ${Math.abs(statusMod)}`;
display += ` = ${roll.total}`;
return { roll, total: roll.total, formulaDisplay: display };
}
/* ------------------------------------------------------------------ */
/* Résolution des clashes (séquence linéaire) */
/* ------------------------------------------------------------------ */
/**
* Résout la séquence de clashes entre deux ensembles de dés rollés.
*
* Retourne un tableau de matchups, un résumé des effets, et les compteurs
* de PE gagnés.
*
* @param {Array} atkRolls [{die, total, roll, formulaDisplay}]
* @param {Array} defRolls [] si attaque unilatérale
* @param {Actor} defenderActor
* @param {Actor} attackerActor
* @returns {ClashResult}
*
* ClashResult :
* {
* matchups : DieMatchup[] — dé par dé
* atkHpDamage : number — HP infligés au défenseur
* atkStaggerDamage : number — Stagger infligé au défenseur
* defHpDamage : number — HP infligés à l'attaquant (clash)
* defStaggerDamage : number — Stagger infligé à l'attaquant (clash)
* defStaggerRegain : number — Stagger récupéré par le défenseur (evade win)
* atkStaggerRegain : number — Stagger récupéré par l'attaquant (evade win)
* atkPE : number — PE gagnés par l'attaquant
* defPE : number — PE gagnés par le défenseur
* hasCounter : boolean — true si des counter-dice sont présents
* atkSavedDice : boolean — l'attaquant a des dés à sauvegarder (état 2)
* defSavedDice : boolean — le défenseur a des dés à sauvegarder (état 2)
* }
*
* DieMatchup :
* {
* side : "atk" | "def"
* dieType : string
* power : number
* formulaDisplay : string
* oppType : string|null
* oppPower : number|null
* clashType : "clash" | "unopposed"
* verdict : string — voir constantes VERDICT_* ci-dessous
* effects : string[] — descriptions texte des effets
* }
*/
export function resolveClash(atkRolls, defRolls, defenderActor, attackerActor) {
// Affinités des acteurs
const defAff = _getAffinities(defenderActor);
const atkAff = attackerActor ? _getAffinities(attackerActor) : null;
const matchups = [];
// ── Compteurs d'effets ──────────────────────────────────────────────
let atkHpDamage = 0;
let atkStaggerDamage = 0;
let defHpDamage = 0;
let defStaggerDamage = 0;
let defStaggerRegain = 0;
let atkStaggerRegain = 0;
let atkPE = 0;
let defPE = 0;
let atkSavedDice = false;
let defSavedDice = false;
// ── Séquence linéaire côté attaquant ───────────────────────────────
// On travaille sur des copies mutables pour gérer les counter-dice recyclés
const defQueue = [...defRolls]; // dés du défenseur disponibles pour clash
// Compteur de clashes par dé défenseur (counter-dice : max 1 clash par dé adverse)
// Clé : index dans defRolls d'origine → nombre de fois utilisé
const defUsageCount = new Array(defRolls.length).fill(0);
// Index courant dans defQueue pour la séquence linéaire
let defIdx = 0;
for (let atkIdx = 0; atkIdx < atkRolls.length; atkIdx++) {
const atk = atkRolls[atkIdx];
const atkType = atk.die.type;
// Trouver l'opposant du défenseur à cet index (séquence linéaire)
const opp = defQueue[defIdx] ?? null;
const hasOpp = opp !== null;
let matchup;
if (!hasOpp) {
// ── UNOPPOSED côté attaquant ──
matchup = _resolveAtkUnopposed(atk, defAff, atkIdx);
atkHpDamage += matchup._hpDmg ?? 0;
atkStaggerDamage += matchup._staggerDmg ?? 0;
if (matchup._saved) atkSavedDice = true;
} else {
// ── CLASH ──
const oppType = opp.die.type;
// PE : chaque clash donne +1 aux deux
atkPE++;
defPE++;
matchup = _resolveAtkClash(atk, opp, defAff, atkAff);
atkHpDamage += matchup._hpDmg ?? 0;
atkStaggerDamage += matchup._staggerDmg ?? 0;
// Effets défensifs du dé opposant sur l'attaquant (block lose / evade lose)
defHpDamage += matchup._reverseHpDmg ?? 0;
defStaggerDamage += matchup._reverseStaggerDmg ?? 0;
defStaggerRegain += matchup._defStaggerRegain ?? 0;
atkStaggerRegain += matchup._atkStaggerRegain ?? 0;
// Avancer dans la file défenseur (sauf si counter recyclé)
if (!matchup._oppRecycled) {
defIdx++;
}
// Si le dé défenseur est un counter et perd → il est quand même consommé
if (matchup._oppConsumed) {
defIdx++;
}
}
matchups.push(matchup);
}
// ── Dés du défenseur restants (défenseur a plus de dés que l'attaquant) ──
for (let i = defIdx; i < defQueue.length; i++) {
const def = defQueue[i];
const matchup = _resolveDefUnopposed(def, atkAff, i);
defHpDamage += matchup._hpDmg ?? 0;
defStaggerDamage += matchup._staggerDmg ?? 0;
defStaggerRegain += matchup._defStaggerRegain ?? 0;
if (matchup._saved) defSavedDice = true;
matchups.push(matchup);
}
// Présence de counter-dice
const hasCounter = [...atkRolls, ...defRolls].some(r => isCounter(r.die.type));
return {
matchups,
atkHpDamage,
atkStaggerDamage,
defHpDamage,
defStaggerDamage,
defStaggerRegain,
atkStaggerRegain,
atkPE,
defPE,
hasCounter,
atkSavedDice,
defSavedDice,
};
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé attaquant sans opposition */
/* ------------------------------------------------------------------ */
function _resolveAtkUnopposed(atk, defAff, idx) {
const type = atk.die.type;
const power = atk.total;
const effects = [];
let _hpDmg = 0, _staggerDmg = 0, _saved = false;
if (isOffensive(type)) {
// Offensive Unopposed → deal damage = power
const aff = defAff[type.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, power - aff.damage);
_staggerDmg = Math.max(0, power - aff.stagger);
effects.push(`HP -${_hpDmg}, Stagger -${_staggerDmg}`);
} else if (isBlock(type)) {
if (!isCounter(type)) {
// Block Unopposed → Saved Defense Die
_saved = true;
effects.push("Saved Defense Die");
} else {
// Counter-Block Unopposed → ne se déclenche pas, détruit
effects.push("Counter : pas de déclenchement sans opposition");
}
} else if (isEvade(type)) {
if (!isCounter(type)) {
// Evade Unopposed → Saved Defense Die (sauf si recyclé, géré ailleurs)
_saved = true;
effects.push("Saved Defense Die");
} else {
// Counter-Evade Unopposed → ne se déclenche pas, détruit
effects.push("Counter : pas de déclenchement sans opposition");
}
}
return {
side: "atk", dieType: type, power, formulaDisplay: atk.formulaDisplay,
oppType: null, oppPower: null,
clashType: "unopposed",
verdict: _getUnopposedVerdict(type),
effects,
_hpDmg, _staggerDmg, _saved,
_oppRecycled: false, _oppConsumed: false,
};
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé attaquant en clash */
/* ------------------------------------------------------------------ */
function _resolveAtkClash(atk, opp, defAff, atkAff) {
const atkType = atk.die.type;
const oppType = opp.die.type;
const atkPower = atk.total;
const oppPower = opp.total;
const effects = [];
let _hpDmg = 0, _staggerDmg = 0;
let _reverseHpDmg = 0, _reverseStaggerDmg = 0;
let _defStaggerRegain = 0, _atkStaggerRegain = 0;
let _oppRecycled = false, _oppConsumed = false;
let verdict;
// Résultat du clash
const result = atkPower > oppPower ? "win"
: atkPower < oppPower ? "lose"
: "tie";
// ── Attaquant offensif vs dé défenseur ──────────────────────────────
if (isOffensive(atkType)) {
if (isOffensive(oppType)) {
// Offensive vs Offensive — les deux clashent
verdict = result === "win" ? "off-win"
: result === "lose" ? "off-lose"
: "tie";
if (result === "win") {
// Attaquant gagne → deal damage
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, atkPower - aff.damage);
_staggerDmg = Math.max(0, atkPower - aff.stagger);
effects.push(`WIN → HP -${_hpDmg}, Stagger -${_staggerDmg}`);
// Le dé défenseur offensif perd → rien de sa part
} else if (result === "lose") {
// Défenseur offensif gagne → il deal damage sur l'attaquant
if (atkAff) {
const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_reverseHpDmg = Math.max(0, oppPower - aff.damage);
_reverseStaggerDmg = Math.max(0, oppPower - aff.stagger);
effects.push(`LOSE → ennemi HP -${_reverseHpDmg}, ennemi Stagger -${_reverseStaggerDmg}`);
}
} else {
effects.push("TIE → Rien");
}
} else if (isBlock(oppType)) {
// Offensive vs Block
verdict = result === "win" ? "off-win-vs-block"
: result === "lose" ? "off-lose-vs-block"
: "tie";
if (result === "win") {
// Block perd → réduit dégâts de sa Power
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
const rawHp = Math.max(0, atkPower - aff.damage);
const rawStg = Math.max(0, atkPower - aff.stagger);
// Block lose : réduit HP et Stagger pris de Power_block
const reduction = oppPower;
_hpDmg = Math.max(0, rawHp - reduction);
_staggerDmg = Math.max(0, rawStg - reduction);
effects.push(`WIN vs Block → HP -${_hpDmg} (réduit de ${reduction}), Stagger -${_staggerDmg}`);
// Block Lose → réduit damage taken = Power_block (déjà géré ci-dessus dans notre sens)
} else if (result === "lose") {
// Attaque gagne → dégâts plein
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, atkPower - aff.damage);
_staggerDmg = Math.max(0, atkPower - aff.stagger);
effects.push(`LOSE vs Block → HP -${_hpDmg}, Stagger -${_staggerDmg}`);
// Block win → stagger on attacker = Power_block - Power_off
_reverseStaggerDmg = Math.max(0, oppPower - atkPower);
if (_reverseStaggerDmg > 0) effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`);
} else {
effects.push("TIE → Rien");
}
} else if (isEvade(oppType)) {
// Offensive vs Evade
verdict = result === "win" ? "off-win-vs-evade"
: result === "lose" ? "off-lose-vs-evade"
: "tie";
if (result === "win") {
// Evade perd → dégâts plein, Stagger réduit de Power_evade
const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, atkPower - aff.damage);
const rawStg = Math.max(0, atkPower - aff.stagger);
_staggerDmg = Math.max(0, rawStg - oppPower); // Evade Lose réduit Stagger
effects.push(`WIN vs Evade → HP -${_hpDmg}, Stagger -${_staggerDmg} (réduit de ${oppPower})`);
} else if (result === "lose") {
// Evade gagne vs Offensive → recycle le dé evade, aucun dégât
_oppRecycled = !isCounter(oppType); // Counter-Evade : ne se recycle pas vraiment
effects.push("LOSE vs Evade → Evade gagne, recyclé (aucun dégât)");
} else {
effects.push("TIE → Rien");
}
}
// ── Attaquant défensif vs dé défenseur ─────────────────────────────
} else if (isBlock(atkType)) {
if (isOffensive(oppType)) {
// Block vs Offensive
verdict = result === "win" ? "block-win-vs-off"
: result === "lose" ? "block-lose-vs-off"
: "tie";
if (result === "win") {
// Block Win → stagger sur le défenseur (opposant offensif)
_reverseStaggerDmg = Math.max(0, atkPower - oppPower);
effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`);
// Le dé offensif perd → dégâts réduits de Power_block
// (l'attaque adverse est annulée ici : on n'inflige rien)
} else if (result === "lose") {
// Block Lose vs Off → réduit HP damage et Stagger pris de Power_block
// L'offensif adverse inflige ses dégâts, réduits de notre Power
if (atkAff) {
const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
const rawHp = Math.max(0, oppPower - aff.damage);
const rawStg = Math.max(0, oppPower - aff.stagger);
_reverseHpDmg = Math.max(0, rawHp - atkPower);
_reverseStaggerDmg = Math.max(0, rawStg - atkPower);
effects.push(`Block LOSE → dégâts réduits de ${atkPower} → HP -${_reverseHpDmg}, Stagger -${_reverseStaggerDmg}`);
}
} else {
effects.push("TIE → Rien");
}
} else {
// Block vs Block ou Block vs Evade → clash normal (Win = stagger diff)
verdict = result === "win" ? "block-win-vs-def" : result === "lose" ? "block-lose-vs-def" : "tie";
if (result === "win") {
_reverseStaggerDmg = Math.max(0, atkPower - oppPower);
effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`);
} else {
effects.push(result === "tie" ? "TIE → Rien" : "Block LOSE → Rien");
}
}
} else if (isEvade(atkType)) {
if (isOffensive(oppType)) {
// Evade vs Offensive
verdict = result === "win" ? "evade-win-vs-off"
: result === "lose" ? "evade-lose-vs-off"
: "tie";
if (result === "win") {
// Evade Win vs Off → Recycle ce dé, aucun autre effet WIN
// (le dé offensif adverse ne deal pas de dégâts)
effects.push("Evade WIN vs Off → Recyclé, aucun dégât");
// Note : _oppRecycled n'est pas l'attaquant ici mais le dé evade lui-même
// On note ça dans le matchup via le verdict
} else if (result === "lose") {
// Evade Lose vs Off → réduit Stagger pris de Power_evade
if (atkAff) {
const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
const rawHp = Math.max(0, oppPower - aff.damage);
const rawStg = Math.max(0, oppPower - aff.stagger);
_reverseHpDmg = rawHp;
_reverseStaggerDmg = Math.max(0, rawStg - atkPower);
effects.push(`Evade LOSE → Stagger réduit de ${atkPower} → HP -${_reverseHpDmg}, Stagger -${_reverseStaggerDmg}`);
}
} else {
effects.push("TIE → Rien");
}
} else {
// Evade vs Defensive
verdict = result === "win" ? "evade-win-vs-def" : result === "lose" ? "evade-lose-vs-def" : "tie";
if (result === "win") {
// Evade Win vs Def → regain stagger resist = Power_evade - Power_def
_atkStaggerRegain = Math.max(0, atkPower - oppPower);
effects.push(`Evade WIN vs Def → Stagger regain +${_atkStaggerRegain}`);
} else {
effects.push(result === "tie" ? "TIE → Rien" : "Evade LOSE → Rien");
}
}
}
// Counter : après un clash perdu, le counter est consommé (pas recyclé)
if (isCounter(oppType) && result === "lose") {
_oppConsumed = true;
}
return {
side: "atk", dieType: atkType, power: atkPower, formulaDisplay: atk.formulaDisplay,
oppType, oppPower, oppFormulaDisplay: opp.formulaDisplay,
clashType: "clash",
verdict,
effects,
_hpDmg, _staggerDmg,
_reverseHpDmg, _reverseStaggerDmg,
_defStaggerRegain, _atkStaggerRegain,
_oppRecycled, _oppConsumed,
};
}
/* ------------------------------------------------------------------ */
/* Résolution d'un dé défenseur sans opposition */
/* ------------------------------------------------------------------ */
function _resolveDefUnopposed(def, atkAff, idx) {
const type = def.die.type;
const power = def.total;
const effects = [];
let _hpDmg = 0, _staggerDmg = 0, _defStaggerRegain = 0, _saved = false;
if (isOffensive(type)) {
// Offensif défenseur Unopposed → deal damage sur l'attaquant
const aff = atkAff?.[type.replace("counter-", "")] ?? { damage: 0, stagger: 0 };
_hpDmg = Math.max(0, power - aff.damage);
_staggerDmg = Math.max(0, power - aff.stagger);
effects.push(`HP -${_hpDmg}, Stagger -${_staggerDmg}`);
} else if (isBlock(type) || isEvade(type)) {
if (!isCounter(type)) {
_saved = true;
effects.push("Saved Defense Die");
} else {
effects.push("Counter : pas de déclenchement sans opposition");
}
}
return {
side: "def", dieType: type, power, formulaDisplay: def.formulaDisplay,
oppType: null, oppPower: null,
clashType: "unopposed",
verdict: _getUnopposedVerdict(type),
effects,
_hpDmg, _staggerDmg, _defStaggerRegain, _saved,
_oppRecycled: false, _oppConsumed: false,
};
}
/* ------------------------------------------------------------------ */
/* Verdict Unopposed */
/* ------------------------------------------------------------------ */
function _getUnopposedVerdict(type) {
if (isOffensive(type)) return "unopposed-off";
if ((isBlock(type) || isEvade(type)) && !isCounter(type)) return "unopposed-saved";
return "unopposed-counter-noop";
}
/* ------------------------------------------------------------------ */
/* Affinités d'un acteur */
/* ------------------------------------------------------------------ */
function _getAffinities(actor) {
const m = actor.system.modifiers ?? {};
return {
slash: { damage: m.slash_damage_affinity ?? 0, stagger: m.slash_stagger_affinity ?? 0 },
pierce: { damage: m.pierce_damage_affinity ?? 0, stagger: m.pierce_stagger_affinity ?? 0 },
blunt: { damage: m.blunt_damage_affinity ?? 0, stagger: m.blunt_stagger_affinity ?? 0 },
};
}
/* ------------------------------------------------------------------ */
/* Application des effets sur les acteurs */
/* ------------------------------------------------------------------ */
export async function applyClashResults(result, attackerActor, defenderActor, attackerCombatant, defenderCombatant) {
// Dégâts sur le défenseur
if (result.atkHpDamage > 0 || result.atkStaggerDamage > 0) {
await _applyDamage(defenderActor, result.atkHpDamage, result.atkStaggerDamage);
}
// Dégâts sur l'attaquant (clash)
if (result.defHpDamage > 0 || result.defStaggerDamage > 0) {
await _applyDamage(attackerActor, result.defHpDamage, result.defStaggerDamage);
}
// Regain Stagger du défenseur (Evade Win)
if (result.defStaggerRegain > 0) {
await _applyStaggerRegain(defenderActor, result.defStaggerRegain);
}
// Regain Stagger de l'attaquant (Evade Win vs Def)
if (result.atkStaggerRegain > 0) {
await _applyStaggerRegain(attackerActor, result.atkStaggerRegain);
}
// PE
if (result.atkPE > 0) await _applyPE(attackerActor, result.atkPE);
if (result.defPE > 0) await _applyPE(defenderActor, result.defPE);
// États des dés de vitesse
// Attaquant → toujours Used (état 1) après résolution
await markDieState(attackerCombatant, 1);
// Défenseur :
// - Si des dés sauvegardés → état 2 (Saved Defense Die)
// - Sinon → état 1 (Used)
if (defenderCombatant) {
const defState = result.defSavedDice ? 2 : 1;
await markDieState(defenderCombatant, defState);
}
// Counter dice côté attaquant → état 2 aussi
if (result.atkSavedDice) {
await markDieState(attackerCombatant, 2);
}
}
async function _applyDamage(actor, hpDmg, staggerDmg) {
const sys = actor.system;
let hp = (sys.health?.value ?? 0) - hpDmg;
let stg = (sys.stagger?.value ?? 0) - staggerDmg;
// Cascade stagger → HP si stagger < 0
if (stg < 0) {
hp -= Math.floor(Math.abs(stg) / 2);
stg = 0;
}
await actor.update({
"system.health.value": Math.max(0, hp),
"system.stagger.value": Math.max(0, stg),
});
}
async function _applyStaggerRegain(actor, amount) {
const sys = actor.system;
const curr = sys.stagger?.value ?? 0;
const max = sys.stagger?.max ?? curr;
await actor.update({ "system.stagger.value": Math.min(max, curr + amount) });
}
async function _applyPE(actor, amount) {
const curr = actor.system.emotion ?? 0;
await actor.update({ "system.emotion": curr + amount });
}
/* ------------------------------------------------------------------ */
/* Gestion de l'état d'un Dé de Vitesse */
/* ------------------------------------------------------------------ */
/**
* Passe un combatant à l'état donné dans SBSA + SIO, et désassigne la compétence.
* @param {Combatant} combatant
* @param {number} state 0=Unused, 1=Used, 2=Saved Defense Die, 3=Assigned
*/
export async function markDieState(combatant, state) {
if (!combatant) return;
// Désassigner la compétence (sauf si on passe en état 3)
if (state !== 3) {
await combatant.unsetFlag("sotc-battle-show-ability", "assignedSkillId");
}
await combatant.setFlag("sotc-battle-show-ability", "dieState", state);
await combatant.setFlag("sotc", "used", state !== 0);
// Synchroniser SIO
const tokenDoc = combatant.token;
if (tokenDoc && game.modules.get("stars-initiative-overlay")?.active) {
const sorted = game.combat.combatants
.filter(c => c.tokenId === combatant.tokenId)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
const dieIndex = sorted.findIndex(c => c.id === combatant.id);
if (dieIndex === -1) return;
const numDice = combatant.actor?.system?.speed_dice?.num_dice ?? 1;
const states = [...(tokenDoc.getFlag("stars-initiative-overlay", "diceStates") || [])];
while (states.length < numDice) states.push(0);
states[dieIndex] = state;
await tokenDoc.setFlag("stars-initiative-overlay", "diceStates", states);
}
}
// Alias conservé pour compatibilité avec main.js
export const markDieAsUsed = (combatant) => markDieState(combatant, 1);
/* ------------------------------------------------------------------ */
/* Chat message résumé */
/* ------------------------------------------------------------------ */
export async function sendResultToChat({ attackerName, defenderName, skillName, result }) {
const { matchups, atkHpDamage, atkStaggerDamage, defHpDamage, defStaggerDamage,
defStaggerRegain, atkStaggerRegain, atkPE, defPE } = result;
const matchupLines = matchups.map(m => {
const sideLabel = m.side === "atk" ? attackerName : defenderName;
const vs = m.clashType === "clash"
? `<span style="color:#4a4a7a;font-size:10px;"> VS </span>
<span class="sotc-dw-tag-${m.oppType}">${m.oppType} ${m.oppPower}</span>`
: `<span style="color:#4a4a6a;font-size:10px;"> (sans opposition)</span>`;
const icon = `systems/sotc/assets/dice types/${m.dieType}.png`;
return `
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;font-size:11px;">
<img src="${icon}" style="width:18px;height:18px;border:none;flex-shrink:0;"/>
<strong>${m.dieType} ${m.power}</strong>${vs}
<span style="margin-left:auto;font-size:10px;color:#8888aa;">${m.effects.join(", ")}</span>
</div>`;
}).join("");
const defenderSection = (atkHpDamage > 0 || atkStaggerDamage > 0)
? `<p>🎯 <strong>${defenderName}</strong> → HP -${atkHpDamage}, Stagger -${atkStaggerDamage}</p>`
: `<p>🎯 <strong>${defenderName}</strong> → Aucun dégât</p>`;
const attackerSection = (defHpDamage > 0 || defStaggerDamage > 0)
? `<p>🎯 <strong>${attackerName}</strong> → HP -${defHpDamage}, Stagger -${defStaggerDamage}</p>`
: "";
const regainSection = (defStaggerRegain > 0 || atkStaggerRegain > 0)
? `<p>💚 Stagger regain : ${defStaggerRegain > 0 ? `${defenderName} +${defStaggerRegain} ` : ""}${atkStaggerRegain > 0 ? `${attackerName} +${atkStaggerRegain}` : ""}</p>`
: "";
const peSection = (atkPE > 0 || defPE > 0)
? `<p>💜 PE : ${attackerName} +${atkPE}, ${defenderName} +${defPE}</p>`
: "";
const content = `
<div class="sotc-dw-chat">
<h3>⚔ ${skillName ?? "?"} — ${attackerName} → ${defenderName}</h3>
<div style="margin-bottom:6px;">${matchupLines}</div>
<hr/>
${defenderSection}
${attackerSection}
${regainSection}
${peSection}
</div>`;
await ChatMessage.create({ speaker: ChatMessage.getSpeaker(), content });
}