feat(engine): damage-resistance modifier descriptor

- Add DAMAGE_RESISTANCE_DESCRIPTOR with multiplicative stacking rule
- Export applyResistance() and stackResistances() math helpers
- 14 tests covering registry, apply, describe, applyResistance, stackResistances
- Fix eslint varsIgnorePattern to allow _-prefixed destructuring vars
- Remove now-redundant eslint-disable comment in schema.test.ts
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:22:31 -06:00
commit 99a091509d
No known key found for this signature in database
4 changed files with 137 additions and 2 deletions

View file

@ -8,7 +8,7 @@ export default tseslint.config(
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
// Engine RHS purity: ban impure globals in rete/src

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { Session } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import "./damage-resistance.js";
import {
DAMAGE_RESISTANCE_DESCRIPTOR,
applyResistance,
stackResistances,
} from "./damage-resistance.js";
describe("damage-resistance descriptor — registry", () => {
it("registers in MODIFIER_REGISTRY under key 'damage-resistance'", () => {
expect(MODIFIER_REGISTRY.has("damage-resistance")).toBe(true);
expect(MODIFIER_REGISTRY.get("damage-resistance")).toBe(DAMAGE_RESISTANCE_DESCRIPTOR);
});
});
describe("damage-resistance descriptor — apply()", () => {
it("seeds DamageResistance fact on piece entity in session", () => {
const session = new Session();
const pieceId = session.nextId();
session.insert(pieceId, "PieceType", "pawn");
DAMAGE_RESISTANCE_DESCRIPTOR.apply(session, pieceId, 0.5);
expect(session.contains(pieceId, "DamageResistance")).toBe(true);
expect(session.get(pieceId, "DamageResistance")).toBe(0.5);
});
});
describe("damage-resistance descriptor — describe()", () => {
it("formats 0.5 as '50% damage resistance'", () => {
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0.5)).toBe("50% damage resistance");
});
it("formats 0 as '0% damage resistance'", () => {
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0)).toBe("0% damage resistance");
});
it("formats 1 as '100% damage resistance'", () => {
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(1)).toBe("100% damage resistance");
});
});
describe("applyResistance()", () => {
it("halves damage at 0.5 resistance", () => {
expect(applyResistance(4, 0.5)).toBe(2);
});
it("passes full damage through at 0 resistance", () => {
expect(applyResistance(4, 0)).toBe(4);
});
it("reduces damage to 0 at 1 (full immunity)", () => {
expect(applyResistance(4, 1)).toBe(0);
});
it("clamps negative results to 0", () => {
// Resistance > 1 is schema-invalid, but applyResistance is defensive
expect(applyResistance(4, 1.5)).toBe(0);
});
});
describe("stackResistances()", () => {
it("returns 0 for an empty array", () => {
expect(stackResistances([])).toBe(0);
});
it("stacks two 0.5 resistances multiplicatively to 0.75", () => {
// 1 - (0.5 * 0.5) = 0.75
expect(stackResistances([0.5, 0.5])).toBe(0.75);
});
it("stacks three 0.5 resistances to 0.875", () => {
// 1 - (0.5 * 0.5 * 0.5) = 0.875
expect(stackResistances([0.5, 0.5, 0.5])).toBe(0.875);
});
it("returns 0 for a single 0 resistance", () => {
expect(stackResistances([0])).toBe(0);
});
it("returns 1 (clamped) for a single full immunity", () => {
expect(stackResistances([1])).toBe(1);
});
});

View file

@ -0,0 +1,49 @@
import { z } from "zod";
import type { Session, EntityId } from "@paratype/rete";
import { MODIFIER_REGISTRY } from "../registry.js";
import type { ModifierDescriptor } from "../types.js";
// Resistance: 0 = no resistance, 1 = immune (100% damage reduction)
const schema = z.number().min(0).max(1);
type Value = z.infer<typeof schema>;
export const DAMAGE_RESISTANCE_DESCRIPTOR: ModifierDescriptor<Value> = {
id: "damage-resistance",
attrName: "DamageResistance",
label: "Damage Resistance",
valueSchema: schema,
stackingRule: "multiplicative",
uiForm: "percentage",
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
session.insert(pieceId, "DamageResistance", effectiveValue);
},
describe(value: Value): string {
return `${Math.round(value * 100)}% damage resistance`;
},
};
MODIFIER_REGISTRY.register(DAMAGE_RESISTANCE_DESCRIPTOR);
/**
* Apply damage resistance to an incoming damage amount.
* Returns the effective damage after applying resistance.
* Result is always ≥ 0.
*
* Formula (from ADR-4 stacking): effective resistance already stacked
* multiplicatively before being seeded in DamageResistance fact.
* So: effectiveDamage = amount * (1 - resistance), clamped to [0, ∞).
*/
export function applyResistance(amount: number, resistance: number): number {
return Math.max(0, amount * (1 - resistance));
}
/**
* Compute stacked resistance from multiple sources using multiplicative formula.
* ADR-4: effectiveResistance = 1 - ∏(1 - r_i)
* Result clamped to [0, 1].
*/
export function stackResistances(resistances: readonly number[]): number {
if (resistances.length === 0) return 0;
const product = resistances.reduce((acc, r) => acc * (1 - r), 1);
return Math.max(0, Math.min(1, 1 - product));
}

View file

@ -198,7 +198,7 @@ describe("ModifierProfileSchema — invalid", () => {
});
it("rejects profile with missing 'name' field", () => {
const { name: _name, ...rest } = VALID_PROFILE; // eslint-disable-line @typescript-eslint/no-unused-vars
const { name: _name, ...rest } = VALID_PROFILE;
expect(() => ModifierProfileSchema.parse(rest)).toThrow(z.ZodError);
});