From 99a091509d7923655c39d1c90144e6929535cff2 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:22:31 -0600 Subject: [PATCH] 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 --- eslint.config.js | 2 +- .../descriptors/damage-resistance.test.ts | 86 +++++++++++++++++++ .../descriptors/damage-resistance.ts | 49 +++++++++++ packages/chess/src/modifiers/schema.test.ts | 2 +- 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/chess/src/modifiers/descriptors/damage-resistance.test.ts create mode 100644 packages/chess/src/modifiers/descriptors/damage-resistance.ts diff --git a/eslint.config.js b/eslint.config.js index 30bd4e4..515b2b7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 diff --git a/packages/chess/src/modifiers/descriptors/damage-resistance.test.ts b/packages/chess/src/modifiers/descriptors/damage-resistance.test.ts new file mode 100644 index 0000000..d20731e --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/damage-resistance.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/modifiers/descriptors/damage-resistance.ts b/packages/chess/src/modifiers/descriptors/damage-resistance.ts new file mode 100644 index 0000000..28ef80b --- /dev/null +++ b/packages/chess/src/modifiers/descriptors/damage-resistance.ts @@ -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; + +export const DAMAGE_RESISTANCE_DESCRIPTOR: ModifierDescriptor = { + 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)); +} diff --git a/packages/chess/src/modifiers/schema.test.ts b/packages/chess/src/modifiers/schema.test.ts index 43d94ea..05f2b96 100644 --- a/packages/chess/src/modifiers/schema.test.ts +++ b/packages/chess/src/modifiers/schema.test.ts @@ -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); });