/** Stable ordering used to break ties when multiple buffs claim the same field. */
const SOURCE_KIND_RANK = {
    "ability:army": 0,
    "ability:detachment": 1,
    "ability:detachment-stratagem": 2,
    "ability:unit": 3,
    "ability:attached": 4,
    "ability:support": 5,
    manual: 6,
    "weapon-keyword": 7,
};
function rank(s) {
    if (s.kind === "ability")
        return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;
    return SOURCE_KIND_RANK[s.kind] ?? 99;
}
function applies(buff, ctx) {
    const w = buff.applicableWhen;
    if (!w)
        return true;
    if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase))
        return false;
    if (w.rollType && buff.contribution.type === "reroll" && buff.contribution.roll !== w.rollType) {
        return false;
    }
    if (w.requiresTargetKeyword) {
        const target = ctx.targetKeywords ?? [];
        if (!target.includes(w.requiresTargetKeyword.toLowerCase()))
            return false;
    }
    if (w.requiresAttackerKeyword) {
        const attacker = ctx.attackerKeywords ?? [];
        if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase()))
            return false;
    }
    return true;
}
/**
 * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure
 * function; the engine — and any UI that wants to render the resolved table
 * before crunching — both go through this.
 */
export function resolveBuffs(buffs, ctx) {
    const live = buffs.filter((b) => applies(b, ctx));
    const out = {
        hitMod: { value: 0, dominantSource: null },
        woundMod: { value: 0, dominantSource: null },
        saveMod: { value: 0, sources: [] },
        cover: { active: false, source: null },
        rerolls: {},
        extraKeywords: [],
        feelNoPain: null,
        feelNoPainMortal: null,
        damageMod: { value: 0, sources: [] },
        attacksMod: { value: 0, sources: [] },
        strengthMod: { value: 0, sources: [] },
        toughnessMod: { value: 0, sources: [] },
        apMod: { value: 0, sources: [] },
        damageReduction: { value: 0, dominantSource: null },
        invulnerable: null,
    };
    // Hit / wound mods: sum, then cap at ±1, with dominant source picked from
    // the contributors whose sign matches the surviving value.
    const hitContribs = [];
    const woundContribs = [];
    for (const b of live) {
        const c = b.contribution;
        switch (c.type) {
            case "hit-mod":
                hitContribs.push({ value: c.value, source: b.source });
                break;
            case "wound-mod":
                woundContribs.push({ value: c.value, source: b.source });
                break;
            case "save-mod":
                out.saveMod.value += c.value;
                out.saveMod.sources.push(b.source);
                break;
            case "cover":
                if (!out.cover.active || rank(b.source) < rank(out.cover.source)) {
                    out.cover = { active: true, source: b.source };
                }
                break;
            case "reroll": {
                const cur = out.rerolls[c.roll];
                const incoming = c.subset;
                if (!cur) {
                    out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };
                }
                else {
                    const incomingStronger = (incoming === "all-failures" && cur.subset === "ones") ||
                        (incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));
                    if (incomingStronger) {
                        out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };
                    }
                }
                break;
            }
            case "extra-keyword": {
                const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;
                if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {
                    out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });
                }
                break;
            }
            case "feel-no-pain": {
                // Best (lowest) threshold wins per scope. An undeclared scope is
                // treated as "all" — that's the existing convention (unscoped FNP =
                // applies to every wound) and keeps every shipped FNP buff regression-safe.
                const scope = c.scope ?? "all";
                const slot = scope === "mortal" ? "feelNoPainMortal" : "feelNoPain";
                if (out[slot] === null || c.threshold < out[slot].threshold) {
                    out[slot] = { threshold: c.threshold, dominantSource: b.source };
                }
                break;
            }
            case "damage-mod":
                out.damageMod.value += c.value;
                out.damageMod.sources.push(b.source);
                break;
            case "attacks-mod":
                out.attacksMod.value += c.value;
                out.attacksMod.sources.push(b.source);
                break;
            case "strength-mod":
                out.strengthMod.value += c.value;
                out.strengthMod.sources.push(b.source);
                break;
            case "toughness-mod":
                out.toughnessMod.value += c.value;
                out.toughnessMod.sources.push(b.source);
                break;
            case "ap-mod":
                out.apMod.value += c.value;
                out.apMod.sources.push(b.source);
                break;
            case "damage-reduction":
                // Highest reduction wins (no stacking). Ties break by source rank so
                // an ability source is preferred over a manual one for provenance
                // purposes; either way the resolved value is unchanged.
                if (out.damageReduction.dominantSource === null ||
                    c.value > out.damageReduction.value ||
                    (c.value === out.damageReduction.value &&
                        rank(b.source) < rank(out.damageReduction.dominantSource))) {
                    out.damageReduction = { value: c.value, dominantSource: b.source };
                }
                break;
            case "invulnerable-save":
                // Best (lowest threshold) wins. Same tie-break by source rank.
                if (out.invulnerable === null ||
                    c.threshold < out.invulnerable.threshold ||
                    (c.threshold === out.invulnerable.threshold &&
                        rank(b.source) < rank(out.invulnerable.dominantSource))) {
                    out.invulnerable = { threshold: c.threshold, dominantSource: b.source };
                }
                break;
        }
    }
    out.hitMod = capModifier(hitContribs);
    out.woundMod = capModifier(woundContribs);
    return out;
}
function keyOf(ref) {
    return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;
}
/** Sum, clamp to ±1, then pick the dominant contributing source by rank. */
function capModifier(contribs) {
    if (contribs.length === 0)
        return { value: 0, dominantSource: null };
    const sum = contribs.reduce((a, c) => a + c.value, 0);
    const capped = Math.max(-1, Math.min(1, sum));
    if (capped === 0)
        return { value: 0, dominantSource: null };
    const sign = Math.sign(capped);
    const matching = contribs.filter((c) => Math.sign(c.value) === sign);
    matching.sort((a, b) => rank(a.source) - rank(b.source));
    return { value: capped, dominantSource: matching[0]?.source ?? null };
}
//# sourceMappingURL=buffs.js.map