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:
parent
ff8b8d9bb0
commit
99a091509d
4 changed files with 137 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue