diff --git a/api/src/lib/ancientRunicLeatherHorseNosebandBench.ts b/api/src/lib/ancientRunicLeatherHorseNosebandBench.ts new file mode 100644 index 00000000..e1f2f730 --- /dev/null +++ b/api/src/lib/ancientRunicLeatherHorseNosebandBench.ts @@ -0,0 +1,261 @@ +import crypto from "node:crypto"; + +/** + * Ancient Runic Leather Horse Noseband Bench, Mithril Cavesson Chape & Celestial Valkyrie Jaw Stabilization Engine for OpenAO MMORPG. + * Simulates muzzle noseband strap stitching benches and cavesson chape tension rigs (Cedar Noseband Bench, Runic Oak Cavesson Rig, Celestial Void Valkyrie Jaw Sanctum), + * raw tanned buffalo muzzle straps and tempered mithril cavesson chape sets (Tanned Buffalo Muzzle Strap, Tempered Mithril Cavesson Chape Set, Celestial Void Astral Noseband Pelt), + * novice trail muzzle nosebands and sovereign aerial cavesson recipes (Novice Trail Muzzle Noseband, Warmaster Mithril Cavesson Noseband, Celestial Void Valkyrie Sovereign Noseband), + * independent steed bit-evasion mitigation & jaw stabilization ratings (scaled across catalog baselines ~16% to 100%), calibrated clamped bit evasion mitigation bonus and jaw comfort scaling, + * upfront leather material deduction on all craft attempts, consistent remainingProvidedLeathers return shapes across all paths, immutable bench cloning for safe rollbacks on both craft and maintain operations, cached static catalog maxima, crypto-secure default gameplay rolls strictly in [0, 1), authoritative catalog power ratio without dead instance fields, and horse noseband bench maintenance. + */ + +export type NosebandBenchType = "CEDAR_NOSEBAND_BENCH" | "RUNIC_OAK_CAVESSON_RIG" | "CELESTIAL_VOID_VALKYRIE_JAW_SANCTUM"; +export type RawLeatherNosebandType = "TANNED_BUFFALO_MUZZLE_STRAP" | "TEMPERED_MITHRIL_CAVESSON_CHAPE_SET" | "CELESTIAL_VOID_ASTRAL_NOSEBAND_PELT"; +export type NosebandRecipeType = "NOVICE_TRAIL_MUZZLE_NOSEBAND" | "WARMASTER_MITHRIL_CAVESSON_NOSEBAND" | "CELESTIAL_VOID_VALKYRIE_SOVEREIGN_NOSEBAND"; + +export interface NosebandBenchData { + benchType: NosebandBenchType; + maxDurability: number; + leathercraftPower: number; + baseSuccessRatePercent: number; // 0 to 100 + jawStabilizationBonusPercent: number; +} + +export interface NosebandRecipeData { + recipeType: NosebandRecipeType; + requiredLeatherType: RawLeatherNosebandType; + requiredLeatherCount: number; + baseBitEvasionMitigationPercent: number; + baseJawComfortBonusPercent: number; +} + +export interface ActiveNosebandBench { + benchId: string; + leatherworkerPlayerId: string; + benchType: NosebandBenchType; + currentDurability: number; + maxDurability: number; + isFunctional: boolean; +} + +export interface CraftedHorseNoseband { + nosebandId: string; + recipeType: NosebandRecipeType; + finalBitEvasionMitigationPercent: number; + finalJawComfortBonusPercent: number; + jawStabilizationPercent: number; // Scaled rating (clamped 0 to 100%, with catalog bench baselines ~16% to 100%) + consumedLeatherCount: number; + consumedLeatherType: RawLeatherNosebandType; + remainingProvidedLeathers: RawLeatherNosebandType[]; + craftedEpochMs: number; +} + +export const NOSEBAND_BENCH_CATALOG: Record = { + CEDAR_NOSEBAND_BENCH: { benchType: "CEDAR_NOSEBAND_BENCH", maxDurability: 95, leathercraftPower: 30, baseSuccessRatePercent: 87, jawStabilizationBonusPercent: 14 }, + RUNIC_OAK_CAVESSON_RIG: { benchType: "RUNIC_OAK_CAVESSON_RIG", maxDurability: 200, leathercraftPower: 72, baseSuccessRatePercent: 94, jawStabilizationBonusPercent: 24 }, + CELESTIAL_VOID_VALKYRIE_JAW_SANCTUM: { benchType: "CELESTIAL_VOID_VALKYRIE_JAW_SANCTUM", maxDurability: 350, leathercraftPower: 130, baseSuccessRatePercent: 99, jawStabilizationBonusPercent: 40 }, +}; + +export const NOSEBAND_RECIPE_CATALOG: Record = { + NOVICE_TRAIL_MUZZLE_NOSEBAND: { recipeType: "NOVICE_TRAIL_MUZZLE_NOSEBAND", requiredLeatherType: "TANNED_BUFFALO_MUZZLE_STRAP", requiredLeatherCount: 2, baseBitEvasionMitigationPercent: 24, baseJawComfortBonusPercent: 14 }, + WARMASTER_MITHRIL_CAVESSON_NOSEBAND: { recipeType: "WARMASTER_MITHRIL_CAVESSON_NOSEBAND", requiredLeatherType: "TEMPERED_MITHRIL_CAVESSON_CHAPE_SET", requiredLeatherCount: 2, baseBitEvasionMitigationPercent: 50, baseJawComfortBonusPercent: 30 }, + CELESTIAL_VOID_VALKYRIE_SOVEREIGN_NOSEBAND: { recipeType: "CELESTIAL_VOID_VALKYRIE_SOVEREIGN_NOSEBAND", requiredLeatherType: "CELESTIAL_VOID_ASTRAL_NOSEBAND_PELT", requiredLeatherCount: 2, baseBitEvasionMitigationPercent: 84, baseJawComfortBonusPercent: 64 }, +}; + +export class AncientRunicLeatherHorseNosebandBenchEngine { + public static readonly DURABILITY_COST_PER_CRAFT = 10; + + /** + * Cached static catalog maxima to prevent runtime array reallocation. + */ + public static readonly CATALOG_MAXIMA = { + maxPower: Math.max(...Object.values(NOSEBAND_BENCH_CATALOG).map(b => b.leathercraftPower), 1), + maxBonus: Math.max(...Object.values(NOSEBAND_BENCH_CATALOG).map(b => b.jawStabilizationBonusPercent), 1), + }; + + /** + * Generates a crypto-secure UUID or 128-bit hex string using node:crypto. + */ + private static generateSecureId(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return crypto.randomBytes(16).toString("hex"); + } + + /** + * Generates a cryptographically secure random float strictly in [0, 1). + */ + public static generateSecureRoll(): number { + if (typeof crypto.randomInt === "function") { + return crypto.randomInt(0, 1000000) / 1000000; + } + return crypto.randomBytes(4).readUInt32LE(0) / 0x100000000; + } + + /** + * Constructs and initializes a horse noseband stitching bench or cavesson rig. + */ + public static constructBench( + leatherworkerPlayerId: string, + benchType: NosebandBenchType + ): ActiveNosebandBench { + const data = NOSEBAND_BENCH_CATALOG[benchType]; + if (!data) { + throw new Error(`Unsupported horse noseband bench type: ${String(benchType)}`); + } + + const uuid = this.generateSecureId(); + + return { + benchId: `bench_${benchType.toLowerCase()}_${uuid}`, + leatherworkerPlayerId, + benchType, + currentDurability: data.maxDurability, + maxDurability: data.maxDurability, + isFunctional: true, + }; + } + + /** + * Stitches and tensions muzzle straps and tempered mithril cavesson chapes into horse nosebands. + * Returns an updated clone of `bench` leaving the input instance immutable. + */ + public static craftNoseband( + bench: ActiveNosebandBench, + recipeType: NosebandRecipeType, + providedLeathers: RawLeatherNosebandType[], + craftRoll?: number, + stabilizationRoll?: number, + currentEpochMs = Date.now() + ): { success: boolean; noseband?: CraftedHorseNoseband; updatedBench?: ActiveNosebandBench; remainingDurability: number; remainingProvidedLeathers: RawLeatherNosebandType[]; reason?: string } { + const fallbackLeathers = Array.isArray(providedLeathers) ? [...providedLeathers] : []; + + if (!bench || !bench.isFunctional || bench.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + return { + success: false, + updatedBench: bench ? { ...bench } : undefined, + remainingDurability: bench?.currentDurability ?? 0, + remainingProvidedLeathers: fallbackLeathers, + reason: `Horse noseband bench is warped or lacks durability (requires ${this.DURABILITY_COST_PER_CRAFT}).`, + }; + } + + const benchData = NOSEBAND_BENCH_CATALOG[bench.benchType]; + if (!benchData) { + return { success: false, updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown bench model: ${String(bench.benchType)}` }; + } + + const recipe = NOSEBAND_RECIPE_CATALOG[recipeType]; + if (!recipe) { + return { success: false, updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown horse noseband recipe: ${String(recipeType)}` }; + } + + if (!Array.isArray(providedLeathers)) { + return { success: false, updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: [], reason: "Invalid leathers array." }; + } + + // Count matching leather materials + const matchingCount = providedLeathers.filter(l => l === recipe.requiredLeatherType).length; + if (matchingCount < recipe.requiredLeatherCount) { + return { + success: false, + updatedBench: { ...bench }, + remainingDurability: bench.currentDurability, + remainingProvidedLeathers: fallbackLeathers, + reason: `Insufficient noseband straps/cavesson chapes: requires ${recipe.requiredLeatherCount}x ${recipe.requiredLeatherType}, provided ${matchingCount}.`, + }; + } + + // Create updated bench clone + const updatedBench = { ...bench }; + + // Deduct durability on clone + updatedBench.currentDurability -= this.DURABILITY_COST_PER_CRAFT; + if (updatedBench.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + updatedBench.currentDurability = Math.max(0, updatedBench.currentDurability); + updatedBench.isFunctional = false; + } + + // Deduct materials upfront on all craft attempts + const remaining = [...providedLeathers]; + let removed = 0; + for (let i = remaining.length - 1; i >= 0 && removed < recipe.requiredLeatherCount; i--) { + if (remaining[i] === recipe.requiredLeatherType) { + remaining.splice(i, 1); + removed++; + } + } + + const safeRoll = typeof craftRoll === "number" && Number.isFinite(craftRoll) ? Math.max(0, Math.min(1, craftRoll)) : this.generateSecureRoll(); + const rollPercent = safeRoll * 100; + + if (rollPercent > benchData.baseSuccessRatePercent) { + return { + success: false, + updatedBench, + remainingDurability: updatedBench.currentDurability, + remainingProvidedLeathers: remaining, + reason: `Muzzle strap misaligned: mithril cavesson chape distorted during tension clamping, rolled ${rollPercent.toFixed(1)}, needed <= ${benchData.baseSuccessRatePercent}.`, + }; + } + + // Calculate independent jaw stabilization score dynamically using cached catalog maxima & authoritative catalog values (clamped 0% to 100%) + const { maxPower, maxBonus } = this.CATALOG_MAXIMA; + const safeStabilizationRoll = typeof stabilizationRoll === "number" && Number.isFinite(stabilizationRoll) ? Math.max(0, Math.min(1, stabilizationRoll)) : this.generateSecureRoll(); + const powerRatio = Math.min(1.0, benchData.leathercraftPower / maxPower); + const bonusPoints = (benchData.jawStabilizationBonusPercent / maxBonus) * 20; + const jawScore = Math.max(0, Math.min(100, Math.round( + (safeStabilizationRoll * 40) + (powerRatio * 40) + bonusPoints + ))); + const qualityMultiplier = 0.8 + ((jawScore / 100) * 0.4); // 0.8 to 1.2x + + const finalMitigationBonus = Math.max(0, Math.min(100, Math.round(recipe.baseBitEvasionMitigationPercent * qualityMultiplier))); + const finalComfortBonus = Math.max(0, Math.min(100, Math.round(recipe.baseJawComfortBonusPercent * qualityMultiplier))); + + const uuid = this.generateSecureId(); + + const noseband: CraftedHorseNoseband = { + nosebandId: `noseband_${recipeType.toLowerCase()}_${uuid}`, + recipeType, + finalBitEvasionMitigationPercent: finalMitigationBonus, + finalJawComfortBonusPercent: finalComfortBonus, + jawStabilizationPercent: jawScore, + consumedLeatherCount: recipe.requiredLeatherCount, + consumedLeatherType: recipe.requiredLeatherType, + remainingProvidedLeathers: remaining, + craftedEpochMs: currentEpochMs, + }; + + return { + success: true, + noseband, + updatedBench, + remainingDurability: updatedBench.currentDurability, + remainingProvidedLeathers: remaining, + }; + } + + /** + * Cleans equestrian trail grime and maintains horse noseband bench. + * Returns an updated clone of `bench` leaving the input instance immutable. + */ + public static maintainBench( + bench: ActiveNosebandBench, + repairAmount = 50 + ): { success: boolean; updatedBench?: ActiveNosebandBench; newDurability: number; isFunctional: boolean } { + if (!bench) return { success: false, newDurability: 0, isFunctional: false }; + + const updatedBench = { ...bench }; + const amt = Number.isFinite(repairAmount) ? Math.max(0, repairAmount) : 50; + updatedBench.currentDurability = Math.min(updatedBench.maxDurability, updatedBench.currentDurability + amt); + updatedBench.isFunctional = updatedBench.currentDurability >= this.DURABILITY_COST_PER_CRAFT; + + return { + success: true, + updatedBench, + newDurability: updatedBench.currentDurability, + isFunctional: updatedBench.isFunctional, + }; + } +} diff --git a/api/src/tests/ancientRunicLeatherHorseNosebandBench.test.ts b/api/src/tests/ancientRunicLeatherHorseNosebandBench.test.ts new file mode 100644 index 00000000..98a33d66 --- /dev/null +++ b/api/src/tests/ancientRunicLeatherHorseNosebandBench.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { AncientRunicLeatherHorseNosebandBenchEngine } from "../lib/ancientRunicLeatherHorseNosebandBench"; +import type { ActiveNosebandBench } from "../lib/ancientRunicLeatherHorseNosebandBench"; + +describe("AncientRunicLeatherHorseNosebandBenchEngine Jaw Stabilization Benches & Rigs", () => { + it("crafts Celestial Void Valkyrie Sovereign Noseband in Jaw Sanctum achieving 100% stabilization and returns spliced pelts", () => { + const bench = AncientRunicLeatherHorseNosebandBenchEngine.constructBench("leather_01", "CELESTIAL_VOID_VALKYRIE_JAW_SANCTUM"); + expect(bench.benchType).toBe("CELESTIAL_VOID_VALKYRIE_JAW_SANCTUM"); + expect(bench.currentDurability).toBe(350); + + const initialLeathers = [ + "CELESTIAL_VOID_ASTRAL_NOSEBAND_PELT", + "CELESTIAL_VOID_ASTRAL_NOSEBAND_PELT", + "CELESTIAL_VOID_ASTRAL_NOSEBAND_PELT" + ] as any[]; + + const craftRes = AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband( + bench, + "CELESTIAL_VOID_VALKYRIE_SOVEREIGN_NOSEBAND", + initialLeathers, + 0.1, // Success roll + 1.0, // Stabilization roll 1.0 -> 40 + 40 + 20 = 100% + 100000 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.noseband?.recipeType).toBe("CELESTIAL_VOID_VALKYRIE_SOVEREIGN_NOSEBAND"); + expect(craftRes.noseband?.jawStabilizationPercent).toBe(100); + expect(craftRes.noseband?.finalBitEvasionMitigationPercent).toBe(100); // 84 * 1.20 = 100.8 -> clamped to 100% + expect(craftRes.noseband?.finalJawComfortBonusPercent).toBe(77); // 64 * 1.20 = 76.8 -> 77% + expect(craftRes.noseband?.consumedLeatherCount).toBe(2); + expect(craftRes.noseband?.consumedLeatherType).toBe("CELESTIAL_VOID_ASTRAL_NOSEBAND_PELT"); + expect(craftRes.noseband?.remainingProvidedLeathers.length).toBe(1); + expect(craftRes.remainingDurability).toBe(340); // 350 - 10 + }); + + it("verifies mid-range stabilization roll and sub-100% quality scaling on Cedar noseband bench", () => { + const bench = AncientRunicLeatherHorseNosebandBenchEngine.constructBench("leather_mid", "CEDAR_NOSEBAND_BENCH"); + // powerRatio = 30/130 = 0.23077, bonusPoints = (14/40)*20 = 7.0 + // safeStabilizationRoll = 0.5 -> 0.5 * 40 = 20 + // score = Math.round(20 + 9.23077 + 7.0) = 36 + // qualityMultiplier = 0.8 + (36/100)*0.4 = 0.944 + // finalMitigation = Math.round(24 * 0.944) = 23 + // finalComfort = Math.round(14 * 0.944) = 13 + const craftRes = AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband( + bench, + "NOVICE_TRAIL_MUZZLE_NOSEBAND", + ["TANNED_BUFFALO_MUZZLE_STRAP", "TANNED_BUFFALO_MUZZLE_STRAP"], + 0.1, + 0.5 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.noseband?.jawStabilizationPercent).toBe(36); + expect(craftRes.noseband?.finalBitEvasionMitigationPercent).toBe(23); + expect(craftRes.noseband?.finalJawComfortBonusPercent).toBe(13); + }); + + it("handles bench becoming non-functional after successful craft when durability falls below threshold", () => { + const bench = AncientRunicLeatherHorseNosebandBenchEngine.constructBench("leather_wear", "CEDAR_NOSEBAND_BENCH"); + bench.currentDurability = 15; + expect(bench.isFunctional).toBe(true); + + // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false in updatedBench + const res1 = AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband( + bench, + "NOVICE_TRAIL_MUZZLE_NOSEBAND", + ["TANNED_BUFFALO_MUZZLE_STRAP", "TANNED_BUFFALO_MUZZLE_STRAP"], + 0.1 + ); + expect(res1.success).toBe(true); + expect(res1.remainingDurability).toBe(5); + expect(res1.updatedBench?.isFunctional).toBe(false); + + // Subsequent craft on updated bench is rejected and returns fallback array + const res2 = AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband( + res1.updatedBench!, + "NOVICE_TRAIL_MUZZLE_NOSEBAND", + ["TANNED_BUFFALO_MUZZLE_STRAP", "TANNED_BUFFALO_MUZZLE_STRAP"] + ); + expect(res2.success).toBe(false); + expect(res2.reason).toContain("warped or lacks durability"); + expect(res2.remainingProvidedLeathers.length).toBe(2); + }); + + it("rejects crafting when insufficient leather is provided and returns provided leathers", () => { + const bench = AncientRunicLeatherHorseNosebandBenchEngine.constructBench("leather_02", "CEDAR_NOSEBAND_BENCH"); + + const failRes = AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband( + bench, + "WARMASTER_MITHRIL_CAVESSON_NOSEBAND", + ["TEMPERED_MITHRIL_CAVESSON_CHAPE_SET"] + ); + + expect(failRes.success).toBe(false); + expect(failRes.reason).toContain("Insufficient noseband straps/cavesson chapes"); + expect(failRes.remainingProvidedLeathers.length).toBe(1); + expect(bench.currentDurability).toBe(95); + }); + + it("handles muzzle strap misaligned failure roll consuming durability and leathers", () => { + const bench = AncientRunicLeatherHorseNosebandBenchEngine.constructBench("leather_03", "CEDAR_NOSEBAND_BENCH"); // 87% success + + const fail = AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband( + bench, + "NOVICE_TRAIL_MUZZLE_NOSEBAND", + ["TANNED_BUFFALO_MUZZLE_STRAP", "TANNED_BUFFALO_MUZZLE_STRAP", "TANNED_BUFFALO_MUZZLE_STRAP"], + 0.95 + ); + + expect(fail.success).toBe(false); + expect(fail.reason).toContain("misaligned"); + expect(fail.remainingProvidedLeathers?.length).toBe(1); // 3 - 2 = 1 remaining + expect(fail.remainingDurability).toBe(85); // 95 - 10 + }); + + it("gates isFunctional in maintainBench based on DURABILITY_COST_PER_CRAFT threshold and returns clone", () => { + const bench = AncientRunicLeatherHorseNosebandBenchEngine.constructBench("leather_04", "CEDAR_NOSEBAND_BENCH"); + bench.currentDurability = 0; + bench.isFunctional = false; + + // Maintain 5 (below 10 required) -> isFunctional remains false + const repLow = AncientRunicLeatherHorseNosebandBenchEngine.maintainBench(bench, 5); + expect(repLow.success).toBe(true); + expect(repLow.newDurability).toBe(5); + expect(repLow.isFunctional).toBe(false); + expect(bench.currentDurability).toBe(0); // input unchanged + + // Maintain 10 more on clone -> 15 (>= 10) -> isFunctional becomes true + const repHigh = AncientRunicLeatherHorseNosebandBenchEngine.maintainBench(repLow.updatedBench!, 10); + expect(repHigh.success).toBe(true); + expect(repHigh.newDurability).toBe(15); + expect(repHigh.isFunctional).toBe(true); + }); + + it("guards against null inputs and unsupported bench models", () => { + expect(() => AncientRunicLeatherHorseNosebandBenchEngine.constructBench("l", "PLASTIC_BENCH" as any)).toThrow( + "Unsupported horse noseband bench type" + ); + + const invalidBench: ActiveNosebandBench = { + benchId: "bad", + leatherworkerPlayerId: "p", + benchType: "BENCH" as any, + currentDurability: 50, + maxDurability: 50, + isFunctional: true, + }; + + expect(AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband(invalidBench, "NOVICE_TRAIL_MUZZLE_NOSEBAND", ["TANNED_BUFFALO_MUZZLE_STRAP", "TANNED_BUFFALO_MUZZLE_STRAP"]).success).toBe(false); + expect(AncientRunicLeatherHorseNosebandBenchEngine.craftNoseband(null as any, "NOVICE_TRAIL_MUZZLE_NOSEBAND", []).success).toBe(false); + expect(AncientRunicLeatherHorseNosebandBenchEngine.maintainBench(null as any).success).toBe(false); + }); +});