From 43d2f2b2ab1f1709218193bebe449ecf57695275 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:41:08 +0200 Subject: [PATCH 1/2] feat(leathercraft): add ancient runic leather archery quiver bench, arrow divider, and ammunition engine --- .../ancientRunicLeatherArcheryQuiverBench.ts | 255 ++++++++++++++++++ ...ientRunicLeatherArcheryQuiverBench.test.ts | 155 +++++++++++ 2 files changed, 410 insertions(+) create mode 100644 api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts create mode 100644 api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts diff --git a/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts b/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts new file mode 100644 index 00000000..72f1f41b --- /dev/null +++ b/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts @@ -0,0 +1,255 @@ +import crypto from "node:crypto"; + +/** + * Ancient Runic Leather Archery Quiver Bench, Arrow Divider & Arcane Ammunition Engine for OpenAO MMORPG. + * Simulates quiver framing benches and arrow separator jigs (Oak Quiver Stitching Bench, Runic Ironwood Arrow Divider Rig, Celestial Void Seraphic Quiver Sanctum), + * raw tanned deerskin and hardened ironwood spine stiffeners (Tanned Deerskin Quiver Body, Hardened Ironwood Divider Stiffener, Celestial Void Starlight Ammunition Leather), + * ranger swift-draw hip quivers and endless arrow quiver recipes (Ranger Swift-Draw Hip Quiver, Master Sniper Back Quiver, Celestial Void Seraphic Endless Arrow Quiver), + * independent draw speed fluidity ratings (scaled across catalog baselines ~14% to 100%), calibrated clamped ranged attack speed and clamped ammo retention scaling, + * upfront leather material deduction on all craft attempts, consistent remainingProvidedLeathers return shapes across all paths, cached static catalog maxima, crypto-secure default gameplay rolls strictly in [0, 1), authoritative catalog power ratio without dead instance fields, and quiver bench maintenance. + */ + +export type QuiverBenchType = "OAK_QUIVER_STITCHING_BENCH" | "RUNIC_IRONWOOD_ARROW_DIVIDER_RIG" | "CELESTIAL_VOID_SERAPHIC_QUIVER_SANCTUM"; +export type RawLeatherQuiverType = "TANNED_DEERSKIN_QUIVER_BODY" | "HARDENED_IRONWOOD_DIVIDER_STIFFENER" | "CELESTIAL_VOID_STARLIGHT_AMMUNITION_LEATHER"; +export type ArcheryQuiverRecipeType = "RANGER_SWIFT_DRAW_HIP_QUIVER" | "MASTER_SNIPER_BACK_QUIVER" | "CELESTIAL_VOID_SERAPHIC_ENDLESS_ARROW_QUIVER"; + +export interface QuiverBenchData { + benchType: QuiverBenchType; + maxDurability: number; + leathercraftPower: number; + baseSuccessRatePercent: number; // 0 to 100 + fluidityBonusPercent: number; +} + +export interface ArcheryQuiverRecipeData { + recipeType: ArcheryQuiverRecipeType; + requiredLeatherType: RawLeatherQuiverType; + requiredLeatherCount: number; + baseRangedAttackSpeedPercent: number; + baseAmmoRetentionPercent: number; +} + +export interface ActiveQuiverBench { + benchId: string; + leatherworkerPlayerId: string; + benchType: QuiverBenchType; + currentDurability: number; + maxDurability: number; + isFunctional: boolean; +} + +export interface CraftedArcheryQuiver { + quiverId: string; + recipeType: ArcheryQuiverRecipeType; + finalRangedAttackSpeedPercent: number; + finalAmmoRetentionPercent: number; + drawSpeedFluidityPercent: number; // Scaled rating (clamped 0 to 100%, with catalog bench baselines ~14% to 100%) + consumedLeatherCount: number; + consumedLeatherType: RawLeatherQuiverType; + remainingProvidedLeathers: RawLeatherQuiverType[]; + craftedEpochMs: number; +} + +export const QUIVER_BENCH_CATALOG: Record = { + OAK_QUIVER_STITCHING_BENCH: { benchType: "OAK_QUIVER_STITCHING_BENCH", maxDurability: 75, leathercraftPower: 25, baseSuccessRatePercent: 85, fluidityBonusPercent: 10 }, + RUNIC_IRONWOOD_ARROW_DIVIDER_RIG: { benchType: "RUNIC_IRONWOOD_ARROW_DIVIDER_RIG", maxDurability: 170, leathercraftPower: 65, baseSuccessRatePercent: 92, fluidityBonusPercent: 20 }, + CELESTIAL_VOID_SERAPHIC_QUIVER_SANCTUM: { benchType: "CELESTIAL_VOID_SERAPHIC_QUIVER_SANCTUM", maxDurability: 310, leathercraftPower: 120, baseSuccessRatePercent: 99, fluidityBonusPercent: 35 }, +}; + +export const QUIVER_RECIPE_CATALOG: Record = { + RANGER_SWIFT_DRAW_HIP_QUIVER: { recipeType: "RANGER_SWIFT_DRAW_HIP_QUIVER", requiredLeatherType: "TANNED_DEERSKIN_QUIVER_BODY", requiredLeatherCount: 2, baseRangedAttackSpeedPercent: 20, baseAmmoRetentionPercent: 10 }, + MASTER_SNIPER_BACK_QUIVER: { recipeType: "MASTER_SNIPER_BACK_QUIVER", requiredLeatherType: "HARDENED_IRONWOOD_DIVIDER_STIFFENER", requiredLeatherCount: 2, baseRangedAttackSpeedPercent: 45, baseAmmoRetentionPercent: 25 }, + CELESTIAL_VOID_SERAPHIC_ENDLESS_ARROW_QUIVER: { recipeType: "CELESTIAL_VOID_SERAPHIC_ENDLESS_ARROW_QUIVER", requiredLeatherType: "CELESTIAL_VOID_STARLIGHT_AMMUNITION_LEATHER", requiredLeatherCount: 2, baseRangedAttackSpeedPercent: 80, baseAmmoRetentionPercent: 60 }, +}; + +export class AncientRunicLeatherArcheryQuiverBenchEngine { + 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(QUIVER_BENCH_CATALOG).map(b => b.leathercraftPower), 1), + maxBonus: Math.max(...Object.values(QUIVER_BENCH_CATALOG).map(b => b.fluidityBonusPercent), 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 an archery quiver stitching bench or divider rig. + */ + public static constructBench( + leatherworkerPlayerId: string, + benchType: QuiverBenchType + ): ActiveQuiverBench { + const data = QUIVER_BENCH_CATALOG[benchType]; + if (!data) { + throw new Error(`Unsupported quiver bench type: ${String(benchType)}`); + } + + const uuid = this.generateSecureId(); + + return { + benchId: `bench_${benchType.toLowerCase()}_${uuid}`, + leatherworkerPlayerId, + benchType, + currentDurability: data.maxDurability, + maxDurability: data.maxDurability, + isFunctional: true, + }; + } + + /** + * Forms and stitches deerskin quiver bodies and arrow dividers into hip and back quivers. + * Note: Mutates the passed `bench` in place and returns it as `updatedBench` for caller ergonomics. + */ + public static craftQuiver( + bench: ActiveQuiverBench, + recipeType: ArcheryQuiverRecipeType, + providedLeathers: RawLeatherQuiverType[], + craftRoll?: number, + fluidityRoll?: number, + currentEpochMs = Date.now() + ): { success: boolean; quiver?: CraftedArcheryQuiver; updatedBench?: ActiveQuiverBench; remainingDurability: number; remainingProvidedLeathers: RawLeatherQuiverType[]; reason?: string } { + const fallbackLeathers = Array.isArray(providedLeathers) ? [...providedLeathers] : []; + + if (!bench || !bench.isFunctional || bench.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + return { + success: false, + updatedBench: bench, + remainingDurability: bench?.currentDurability ?? 0, + remainingProvidedLeathers: fallbackLeathers, + reason: `Quiver bench is warped or lacks durability (requires ${this.DURABILITY_COST_PER_CRAFT}).`, + }; + } + + const benchData = QUIVER_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 = QUIVER_RECIPE_CATALOG[recipeType]; + if (!recipe) { + return { success: false, updatedBench: bench, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown quiver 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 quiver leather: requires ${recipe.requiredLeatherCount}x ${recipe.requiredLeatherType}, provided ${matchingCount}.`, + }; + } + + // Deduct durability in place + bench.currentDurability -= this.DURABILITY_COST_PER_CRAFT; + if (bench.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + bench.currentDurability = Math.max(0, bench.currentDurability); + bench.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: bench, + remainingDurability: bench.currentDurability, + remainingProvidedLeathers: remaining, + reason: `Quiver split: divider stitching cleaved deerskin seam, rolled ${rollPercent.toFixed(1)}, needed <= ${benchData.baseSuccessRatePercent}.`, + }; + } + + // Calculate independent draw speed fluidity score dynamically using cached catalog maxima & authoritative catalog values (clamped 0% to 100%, scaling across catalog baselines) + const { maxPower, maxBonus } = this.CATALOG_MAXIMA; + const safeFluidityRoll = typeof fluidityRoll === "number" && Number.isFinite(fluidityRoll) ? Math.max(0, Math.min(1, fluidityRoll)) : this.generateSecureRoll(); + const powerRatio = Math.min(1.0, benchData.leathercraftPower / maxPower); + const bonusPoints = (benchData.fluidityBonusPercent / maxBonus) * 20; + const fluidityScore = Math.max(0, Math.min(100, Math.round( + (safeFluidityRoll * 40) + (powerRatio * 40) + bonusPoints + ))); + const qualityMultiplier = 0.8 + ((fluidityScore / 100) * 0.4); // 0.8 to 1.2x + + const finalSpeed = Math.max(0, Math.min(100, Math.round(recipe.baseRangedAttackSpeedPercent * qualityMultiplier))); + const finalRetention = Math.max(0, Math.min(100, Math.round(recipe.baseAmmoRetentionPercent * qualityMultiplier))); + + const uuid = this.generateSecureId(); + + const quiver: CraftedArcheryQuiver = { + quiverId: `quiver_${recipeType.toLowerCase()}_${uuid}`, + recipeType, + finalRangedAttackSpeedPercent: finalSpeed, + finalAmmoRetentionPercent: finalRetention, + drawSpeedFluidityPercent: fluidityScore, + consumedLeatherCount: recipe.requiredLeatherCount, + consumedLeatherType: recipe.requiredLeatherType, + remainingProvidedLeathers: remaining, + craftedEpochMs: currentEpochMs, + }; + + return { + success: true, + quiver, + updatedBench: bench, + remainingDurability: bench.currentDurability, + remainingProvidedLeathers: remaining, + }; + } + + /** + * Re-wires divider separator frames and maintains quiver bench. + */ + public static maintainBench( + bench: ActiveQuiverBench, + repairAmount = 50 + ): { success: boolean; newDurability: number; isFunctional: boolean } { + if (!bench) return { success: false, newDurability: 0, isFunctional: false }; + + const amt = Number.isFinite(repairAmount) ? Math.max(0, repairAmount) : 50; + bench.currentDurability = Math.min(bench.maxDurability, bench.currentDurability + amt); + bench.isFunctional = bench.currentDurability >= this.DURABILITY_COST_PER_CRAFT; + + return { + success: true, + newDurability: bench.currentDurability, + isFunctional: bench.isFunctional, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts b/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts new file mode 100644 index 00000000..c9bad6ce --- /dev/null +++ b/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { + AncientRunicLeatherArcheryQuiverBenchEngine, + ActiveQuiverBench, +} from "../lib/ancientRunicLeatherArcheryQuiverBench"; + +describe("AncientRunicLeatherArcheryQuiverBenchEngine Quiver Benches & Ammunition Separators", () => { + it("crafts Celestial Void Seraphic Endless Arrow Quiver in Quiver Sanctum achieving 100% fluidity and returns spliced leathers", () => { + const bench = AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("leather_01", "CELESTIAL_VOID_SERAPHIC_QUIVER_SANCTUM"); + expect(bench.benchType).toBe("CELESTIAL_VOID_SERAPHIC_QUIVER_SANCTUM"); + expect(bench.currentDurability).toBe(310); + + const initialLeathers = [ + "CELESTIAL_VOID_STARLIGHT_AMMUNITION_LEATHER", + "CELESTIAL_VOID_STARLIGHT_AMMUNITION_LEATHER", + "CELESTIAL_VOID_STARLIGHT_AMMUNITION_LEATHER" + ] as any[]; + + const craftRes = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( + bench, + "CELESTIAL_VOID_SERAPHIC_ENDLESS_ARROW_QUIVER", + initialLeathers, + 0.1, // Success roll + 1.0, // Fluidity roll 1.0 -> 40 + 40 + 20 = 100% + 100000 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.quiver?.recipeType).toBe("CELESTIAL_VOID_SERAPHIC_ENDLESS_ARROW_QUIVER"); + expect(craftRes.quiver?.drawSpeedFluidityPercent).toBe(100); + expect(craftRes.quiver?.finalRangedAttackSpeedPercent).toBe(96); // 80 * 1.20 = 96% + expect(craftRes.quiver?.finalAmmoRetentionPercent).toBe(72); // 60 * 1.20 = 72% + expect(craftRes.quiver?.consumedLeatherCount).toBe(2); + expect(craftRes.quiver?.consumedLeatherType).toBe("CELESTIAL_VOID_STARLIGHT_AMMUNITION_LEATHER"); + expect(craftRes.quiver?.remainingProvidedLeathers.length).toBe(1); + expect(craftRes.remainingDurability).toBe(300); // 310 - 10 + }); + + it("verifies mid-range fluidity roll and sub-100% quality scaling on Oak stitching bench", () => { + const bench = AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("leather_mid", "OAK_QUIVER_STITCHING_BENCH"); + // powerRatio = 25/120 = 0.20833, bonusPoints = (10/35)*20 = 5.714 + // safeFluidityRoll = 0.5 -> 0.5 * 40 = 20 + // fluidityScore = Math.round(20 + 8.333 + 5.714) = 34 + // qualityMultiplier = 0.8 + (34/100)*0.4 = 0.8 + 0.136 = 0.936 + // finalSpeed = Math.round(20 * 0.936) = 19 + // finalRetention = Math.round(10 * 0.936) = 9 + const craftRes = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( + bench, + "RANGER_SWIFT_DRAW_HIP_QUIVER", + ["TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY"], + 0.1, + 0.5 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.quiver?.drawSpeedFluidityPercent).toBe(34); + expect(craftRes.quiver?.finalRangedAttackSpeedPercent).toBe(19); + expect(craftRes.quiver?.finalAmmoRetentionPercent).toBe(9); + }); + + it("handles bench becoming non-functional after successful craft when durability falls below threshold", () => { + const bench = AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("leather_wear", "OAK_QUIVER_STITCHING_BENCH"); + bench.currentDurability = 15; + expect(bench.isFunctional).toBe(true); + + // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false + const res1 = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( + bench, + "RANGER_SWIFT_DRAW_HIP_QUIVER", + ["TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY"], + 0.1 + ); + expect(res1.success).toBe(true); + expect(res1.remainingDurability).toBe(5); + expect(bench.isFunctional).toBe(false); + + // Subsequent craft is rejected and returns fallback array + const res2 = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( + bench, + "RANGER_SWIFT_DRAW_HIP_QUIVER", + ["TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY"] + ); + 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 = AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("leather_02", "OAK_QUIVER_STITCHING_BENCH"); + + const failRes = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( + bench, + "MASTER_SNIPER_BACK_QUIVER", + ["HARDENED_IRONWOOD_DIVIDER_STIFFENER"] + ); + + expect(failRes.success).toBe(false); + expect(failRes.reason).toContain("Insufficient quiver leather"); + expect(failRes.remainingProvidedLeathers.length).toBe(1); + expect(bench.currentDurability).toBe(75); + }); + + it("handles quiver split failure roll consuming durability and leathers", () => { + const bench = AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("leather_03", "OAK_QUIVER_STITCHING_BENCH"); // 85% success + + const fail = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( + bench, + "RANGER_SWIFT_DRAW_HIP_QUIVER", + ["TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY"], + 0.95 + ); + + expect(fail.success).toBe(false); + expect(fail.reason).toContain("split"); + expect(fail.remainingProvidedLeathers?.length).toBe(1); // 3 - 2 = 1 remaining + expect(bench.currentDurability).toBe(65); // 75 - 10 + }); + + it("gates isFunctional in maintainBench based on DURABILITY_COST_PER_CRAFT threshold", () => { + const bench = AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("leather_04", "OAK_QUIVER_STITCHING_BENCH"); + bench.currentDurability = 0; + bench.isFunctional = false; + + // Maintain 5 (below 10 required) -> isFunctional remains false + const repLow = AncientRunicLeatherArcheryQuiverBenchEngine.maintainBench(bench, 5); + expect(repLow.success).toBe(true); + expect(repLow.newDurability).toBe(5); + expect(repLow.isFunctional).toBe(false); + + // Maintain 10 more -> 15 (>= 10) -> isFunctional becomes true + const repHigh = AncientRunicLeatherArcheryQuiverBenchEngine.maintainBench(bench, 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(() => AncientRunicLeatherArcheryQuiverBenchEngine.constructBench("l", "PLASTIC_BENCH" as any)).toThrow( + "Unsupported quiver bench type" + ); + + const invalidBench: ActiveQuiverBench = { + benchId: "bad", + leatherworkerPlayerId: "p", + benchType: "BENCH" as any, + currentDurability: 50, + maxDurability: 50, + isFunctional: true, + }; + + expect(AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver(invalidBench, "RANGER_SWIFT_DRAW_HIP_QUIVER", ["TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY"]).success).toBe(false); + expect(AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver(null as any, "RANGER_SWIFT_DRAW_HIP_QUIVER", []).success).toBe(false); + expect(AncientRunicLeatherArcheryQuiverBenchEngine.maintainBench(null as any).success).toBe(false); + }); +}); \ No newline at end of file From 2058c49d9ef5a7869c5373bf6906426cd982e6f1 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:50:25 +0200 Subject: [PATCH 2/2] fix(leathercraft): clone bench in craftQuiver for immutable input safety --- .../ancientRunicLeatherArcheryQuiverBench.ts | 35 ++++++++++--------- ...ientRunicLeatherArcheryQuiverBench.test.ts | 10 +++--- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts b/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts index 72f1f41b..f46bef03 100644 --- a/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts +++ b/api/src/lib/ancientRunicLeatherArcheryQuiverBench.ts @@ -6,7 +6,7 @@ import crypto from "node:crypto"; * raw tanned deerskin and hardened ironwood spine stiffeners (Tanned Deerskin Quiver Body, Hardened Ironwood Divider Stiffener, Celestial Void Starlight Ammunition Leather), * ranger swift-draw hip quivers and endless arrow quiver recipes (Ranger Swift-Draw Hip Quiver, Master Sniper Back Quiver, Celestial Void Seraphic Endless Arrow Quiver), * independent draw speed fluidity ratings (scaled across catalog baselines ~14% to 100%), calibrated clamped ranged attack speed and clamped ammo retention scaling, - * upfront leather material deduction on all craft attempts, consistent remainingProvidedLeathers return shapes across all paths, cached static catalog maxima, crypto-secure default gameplay rolls strictly in [0, 1), authoritative catalog power ratio without dead instance fields, and quiver bench maintenance. + * upfront leather material deduction on all craft attempts, consistent remainingProvidedLeathers return shapes across all paths, immutable bench cloning for safe rollbacks, cached static catalog maxima, crypto-secure default gameplay rolls strictly in [0, 1), authoritative catalog power ratio without dead instance fields, and quiver bench maintenance. */ export type QuiverBenchType = "OAK_QUIVER_STITCHING_BENCH" | "RUNIC_IRONWOOD_ARROW_DIVIDER_RIG" | "CELESTIAL_VOID_SERAPHIC_QUIVER_SANCTUM"; @@ -119,7 +119,7 @@ export class AncientRunicLeatherArcheryQuiverBenchEngine { /** * Forms and stitches deerskin quiver bodies and arrow dividers into hip and back quivers. - * Note: Mutates the passed `bench` in place and returns it as `updatedBench` for caller ergonomics. + * Returns an updated clone of `bench` leaving the input instance immutable. */ public static craftQuiver( bench: ActiveQuiverBench, @@ -134,7 +134,7 @@ export class AncientRunicLeatherArcheryQuiverBenchEngine { if (!bench || !bench.isFunctional || bench.currentDurability < this.DURABILITY_COST_PER_CRAFT) { return { success: false, - updatedBench: bench, + updatedBench: bench ? { ...bench } : undefined, remainingDurability: bench?.currentDurability ?? 0, remainingProvidedLeathers: fallbackLeathers, reason: `Quiver bench is warped or lacks durability (requires ${this.DURABILITY_COST_PER_CRAFT}).`, @@ -143,16 +143,16 @@ export class AncientRunicLeatherArcheryQuiverBenchEngine { const benchData = QUIVER_BENCH_CATALOG[bench.benchType]; if (!benchData) { - return { success: false, updatedBench: bench, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown bench model: ${String(bench.benchType)}` }; + return { success: false, updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown bench model: ${String(bench.benchType)}` }; } const recipe = QUIVER_RECIPE_CATALOG[recipeType]; if (!recipe) { - return { success: false, updatedBench: bench, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown quiver recipe: ${String(recipeType)}` }; + return { success: false, updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Unknown quiver recipe: ${String(recipeType)}` }; } if (!Array.isArray(providedLeathers)) { - return { success: false, updatedBench: bench, remainingDurability: bench.currentDurability, remainingProvidedLeathers: [], reason: "Invalid leathers array." }; + return { success: false, updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: [], reason: "Invalid leathers array." }; } // Count matching leather materials @@ -160,18 +160,21 @@ export class AncientRunicLeatherArcheryQuiverBenchEngine { if (matchingCount < recipe.requiredLeatherCount) { return { success: false, - updatedBench: bench, + updatedBench: { ...bench }, remainingDurability: bench.currentDurability, remainingProvidedLeathers: fallbackLeathers, reason: `Insufficient quiver leather: requires ${recipe.requiredLeatherCount}x ${recipe.requiredLeatherType}, provided ${matchingCount}.`, }; } - // Deduct durability in place - bench.currentDurability -= this.DURABILITY_COST_PER_CRAFT; - if (bench.currentDurability < this.DURABILITY_COST_PER_CRAFT) { - bench.currentDurability = Math.max(0, bench.currentDurability); - bench.isFunctional = false; + // 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 @@ -190,8 +193,8 @@ export class AncientRunicLeatherArcheryQuiverBenchEngine { if (rollPercent > benchData.baseSuccessRatePercent) { return { success: false, - updatedBench: bench, - remainingDurability: bench.currentDurability, + updatedBench, + remainingDurability: updatedBench.currentDurability, remainingProvidedLeathers: remaining, reason: `Quiver split: divider stitching cleaved deerskin seam, rolled ${rollPercent.toFixed(1)}, needed <= ${benchData.baseSuccessRatePercent}.`, }; @@ -227,8 +230,8 @@ export class AncientRunicLeatherArcheryQuiverBenchEngine { return { success: true, quiver, - updatedBench: bench, - remainingDurability: bench.currentDurability, + updatedBench, + remainingDurability: updatedBench.currentDurability, remainingProvidedLeathers: remaining, }; } diff --git a/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts b/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts index c9bad6ce..cebb75d4 100644 --- a/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts +++ b/api/src/tests/ancientRunicLeatherArcheryQuiverBench.test.ts @@ -63,7 +63,7 @@ describe("AncientRunicLeatherArcheryQuiverBenchEngine Quiver Benches & Ammunitio bench.currentDurability = 15; expect(bench.isFunctional).toBe(true); - // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false + // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false in updatedBench const res1 = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( bench, "RANGER_SWIFT_DRAW_HIP_QUIVER", @@ -72,11 +72,11 @@ describe("AncientRunicLeatherArcheryQuiverBenchEngine Quiver Benches & Ammunitio ); expect(res1.success).toBe(true); expect(res1.remainingDurability).toBe(5); - expect(bench.isFunctional).toBe(false); + expect(res1.updatedBench?.isFunctional).toBe(false); - // Subsequent craft is rejected and returns fallback array + // Subsequent craft on updated bench is rejected and returns fallback array const res2 = AncientRunicLeatherArcheryQuiverBenchEngine.craftQuiver( - bench, + res1.updatedBench!, "RANGER_SWIFT_DRAW_HIP_QUIVER", ["TANNED_DEERSKIN_QUIVER_BODY", "TANNED_DEERSKIN_QUIVER_BODY"] ); @@ -113,7 +113,7 @@ describe("AncientRunicLeatherArcheryQuiverBenchEngine Quiver Benches & Ammunitio expect(fail.success).toBe(false); expect(fail.reason).toContain("split"); expect(fail.remainingProvidedLeathers?.length).toBe(1); // 3 - 2 = 1 remaining - expect(bench.currentDurability).toBe(65); // 75 - 10 + expect(fail.remainingDurability).toBe(65); // 75 - 10 }); it("gates isFunctional in maintainBench based on DURABILITY_COST_PER_CRAFT threshold", () => {