diff --git a/api/src/lib/ancientRunicGlassStainedChandelier.ts b/api/src/lib/ancientRunicGlassStainedChandelier.ts new file mode 100644 index 00000000..95406269 --- /dev/null +++ b/api/src/lib/ancientRunicGlassStainedChandelier.ts @@ -0,0 +1,247 @@ +import crypto from "node:crypto"; + +/** + * Ancient Runic Glass Stained Chandelier, Iron Armature Assembly & Cathedral Radiance Engine for OpenAO MMORPG. + * Simulates chandelier assembly hoists and brazing hearths (Cedar Chandelier Assembly Hoist, Runic Wrought Iron Armature Bench, Celestial Void Cathedral Corona Sanctum), + * raw blown glass cupolas and faceted lead crystal pendants (Cathedral Amber Glass Cupola, Faceted Lead Crystal Pendant, Celestial Void Starfire Corona Glass), + * sanctuary vesper chandeliers and cathedral corona recipes (Sanctuary Vesper Chandelier, High Altar Iron Corona Chandelier, Celestial Void Seraphic Cathedral Chandelier), + * independent illuminance radiance ratings (scaled across catalog baselines ~14% to 100%), calibrated clamped holy aura radius and clamped mana recharge aura scaling, + * upfront cupola material deduction on all craft attempts, consistent remainingProvidedCupolas return shapes across all paths, cached static catalog maxima, authoritative catalog power ratio, and chandelier hoist maintenance. + */ + +export type ChandelierHoistType = "CEDAR_CHANDELIER_ASSEMBLY_HOIST" | "RUNIC_WROUGHT_IRON_ARMATURE_BENCH" | "CELESTIAL_VOID_CATHEDRAL_CORONA_SANCTUM"; +export type RawGlassCupolaType = "CATHEDRAL_AMBER_GLASS_CUPOLA" | "FACETED_LEAD_CRYSTAL_PENDANT" | "CELESTIAL_VOID_STARFIRE_CORONA_GLASS"; +export type SacredChandelierRecipeType = "SANCTUARY_VESPER_CHANDELIER" | "HIGH_ALTAR_IRON_CORONA_CHANDELIER" | "CELESTIAL_VOID_SERAPHIC_CATHEDRAL_CHANDELIER"; + +export interface ChandelierHoistData { + hoistType: ChandelierHoistType; + maxDurability: number; + glazieryPower: number; + baseSuccessRatePercent: number; // 0 to 100 + radianceBonusPercent: number; +} + +export interface SacredChandelierRecipeData { + recipeType: SacredChandelierRecipeType; + requiredCupolaType: RawGlassCupolaType; + requiredCupolaCount: number; + baseHolyAuraRadiusPercent: number; + baseManaRechargeAuraPercent: number; +} + +export interface ActiveChandelierHoist { + hoistId: string; + glazierPlayerId: string; + hoistType: ChandelierHoistType; + currentDurability: number; + maxDurability: number; + glazieryPower: number; + isFunctional: boolean; +} + +export interface CraftedSacredChandelier { + chandelierId: string; + recipeType: SacredChandelierRecipeType; + finalHolyAuraRadiusPercent: number; + finalManaRechargeAuraPercent: number; + illuminanceRadiancePercent: number; // Scaled rating (clamped 0 to 100%, with catalog hoist baselines ~14% to 100%) + consumedCupolaCount: number; + consumedCupolaType: RawGlassCupolaType; + remainingProvidedCupolas: RawGlassCupolaType[]; + craftedEpochMs: number; +} + +export const CHANDELIER_HOIST_CATALOG: Record = { + CEDAR_CHANDELIER_ASSEMBLY_HOIST: { hoistType: "CEDAR_CHANDELIER_ASSEMBLY_HOIST", maxDurability: 75, glazieryPower: 25, baseSuccessRatePercent: 85, radianceBonusPercent: 10 }, + RUNIC_WROUGHT_IRON_ARMATURE_BENCH: { hoistType: "RUNIC_WROUGHT_IRON_ARMATURE_BENCH", maxDurability: 170, glazieryPower: 65, baseSuccessRatePercent: 92, radianceBonusPercent: 20 }, + CELESTIAL_VOID_CATHEDRAL_CORONA_SANCTUM: { hoistType: "CELESTIAL_VOID_CATHEDRAL_CORONA_SANCTUM", maxDurability: 310, glazieryPower: 120, baseSuccessRatePercent: 99, radianceBonusPercent: 35 }, +}; + +export const CHANDELIER_RECIPE_CATALOG: Record = { + SANCTUARY_VESPER_CHANDELIER: { recipeType: "SANCTUARY_VESPER_CHANDELIER", requiredCupolaType: "CATHEDRAL_AMBER_GLASS_CUPOLA", requiredCupolaCount: 2, baseHolyAuraRadiusPercent: 20, baseManaRechargeAuraPercent: 10 }, + HIGH_ALTAR_IRON_CORONA_CHANDELIER: { recipeType: "HIGH_ALTAR_IRON_CORONA_CHANDELIER", requiredCupolaType: "FACETED_LEAD_CRYSTAL_PENDANT", requiredCupolaCount: 2, baseHolyAuraRadiusPercent: 45, baseManaRechargeAuraPercent: 25 }, + CELESTIAL_VOID_SERAPHIC_CATHEDRAL_CHANDELIER: { recipeType: "CELESTIAL_VOID_SERAPHIC_CATHEDRAL_CHANDELIER", requiredCupolaType: "CELESTIAL_VOID_STARFIRE_CORONA_GLASS", requiredCupolaCount: 2, baseHolyAuraRadiusPercent: 80, baseManaRechargeAuraPercent: 60 }, +}; + +export class AncientRunicGlassStainedChandelierEngine { + 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(CHANDELIER_HOIST_CATALOG).map(h => h.glazieryPower), 1), + maxBonus: Math.max(...Object.values(CHANDELIER_HOIST_CATALOG).map(h => h.radianceBonusPercent), 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"); + } + + /** + * Constructs and initializes a chandelier assembly hoist or brazing armature bench. + */ + public static constructHoist( + glazierPlayerId: string, + hoistType: ChandelierHoistType + ): ActiveChandelierHoist { + const data = CHANDELIER_HOIST_CATALOG[hoistType]; + if (!data) { + throw new Error(`Unsupported chandelier hoist type: ${String(hoistType)}`); + } + + const uuid = this.generateSecureId(); + + return { + hoistId: `hoist_${hoistType.toLowerCase()}_${uuid}`, + glazierPlayerId, + hoistType, + currentDurability: data.maxDurability, + maxDurability: data.maxDurability, + glazieryPower: data.glazieryPower, + isFunctional: true, + }; + } + + /** + * Assembles and wires blown glass cupolas and iron armatures into cathedral corona chandeliers. + * Note: Mutates the passed `hoist` in place and returns it as `updatedHoist` for caller ergonomics. + */ + public static assembleChandelier( + hoist: ActiveChandelierHoist, + recipeType: SacredChandelierRecipeType, + providedCupolas: RawGlassCupolaType[], + craftRoll = Math.random(), + radianceRoll = Math.random(), + currentEpochMs = Date.now() + ): { success: boolean; chandelier?: CraftedSacredChandelier; updatedHoist?: ActiveChandelierHoist; remainingDurability: number; remainingProvidedCupolas: RawGlassCupolaType[]; reason?: string } { + const fallbackCupolas = Array.isArray(providedCupolas) ? [...providedCupolas] : []; + + if (!hoist || !hoist.isFunctional || hoist.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + return { + success: false, + updatedHoist: hoist, + remainingDurability: hoist?.currentDurability ?? 0, + remainingProvidedCupolas: fallbackCupolas, + reason: `Chandelier hoist is jammed or lacks durability (requires ${this.DURABILITY_COST_PER_CRAFT}).`, + }; + } + + const hoistData = CHANDELIER_HOIST_CATALOG[hoist.hoistType]; + if (!hoistData) { + return { success: false, updatedHoist: hoist, remainingDurability: hoist.currentDurability, remainingProvidedCupolas: fallbackCupolas, reason: `Unknown hoist model: ${String(hoist.hoistType)}` }; + } + + const recipe = CHANDELIER_RECIPE_CATALOG[recipeType]; + if (!recipe) { + return { success: false, updatedHoist: hoist, remainingDurability: hoist.currentDurability, remainingProvidedCupolas: fallbackCupolas, reason: `Unknown chandelier recipe: ${String(recipeType)}` }; + } + + if (!Array.isArray(providedCupolas)) { + return { success: false, updatedHoist: hoist, remainingDurability: hoist.currentDurability, remainingProvidedCupolas: [], reason: "Invalid cupolas array." }; + } + + // Count matching glass cupolas + const matchingCount = providedCupolas.filter(c => c === recipe.requiredCupolaType).length; + if (matchingCount < recipe.requiredCupolaCount) { + return { + success: false, + updatedHoist: hoist, + remainingDurability: hoist.currentDurability, + remainingProvidedCupolas: fallbackCupolas, + reason: `Insufficient glass cupola: requires ${recipe.requiredCupolaCount}x ${recipe.requiredCupolaType}, provided ${matchingCount}.`, + }; + } + + // Deduct durability in place + hoist.currentDurability -= this.DURABILITY_COST_PER_CRAFT; + if (hoist.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + hoist.currentDurability = Math.max(0, hoist.currentDurability); + hoist.isFunctional = false; + } + + // Deduct materials upfront on all craft attempts + const remaining = [...providedCupolas]; + let removed = 0; + for (let i = remaining.length - 1; i >= 0 && removed < recipe.requiredCupolaCount; i--) { + if (remaining[i] === recipe.requiredCupolaType) { + remaining.splice(i, 1); + removed++; + } + } + + const safeRoll = Number.isFinite(craftRoll) ? Math.max(0, Math.min(1, craftRoll)) : Math.random(); + const rollPercent = safeRoll * 100; + + if (rollPercent > hoistData.baseSuccessRatePercent) { + return { + success: false, + updatedHoist: hoist, + remainingDurability: hoist.currentDurability, + remainingProvidedCupolas: remaining, + reason: `Armature bent: chandelier tackle snapped dropping iron frame, rolled ${rollPercent.toFixed(1)}, needed <= ${hoistData.baseSuccessRatePercent}.`, + }; + } + + // Calculate independent illuminance radiance score dynamically using cached catalog maxima & authoritative catalog values (clamped 0% to 100%, scaling across catalog baselines) + const { maxPower, maxBonus } = this.CATALOG_MAXIMA; + const safeRadianceRoll = Number.isFinite(radianceRoll) ? Math.max(0, Math.min(1, radianceRoll)) : Math.random(); + const powerRatio = Math.min(1.0, hoistData.glazieryPower / maxPower); + const bonusPoints = (hoistData.radianceBonusPercent / maxBonus) * 20; + const radianceScore = Math.max(0, Math.min(100, Math.round( + (safeRadianceRoll * 40) + (powerRatio * 40) + bonusPoints + ))); + const qualityMultiplier = 0.8 + ((radianceScore / 100) * 0.4); // 0.8 to 1.2x + + const finalRadius = Math.max(0, Math.min(100, Math.round(recipe.baseHolyAuraRadiusPercent * qualityMultiplier))); + const finalRecharge = Math.max(0, Math.min(100, Math.round(recipe.baseManaRechargeAuraPercent * qualityMultiplier))); + + const uuid = this.generateSecureId(); + + const chandelier: CraftedSacredChandelier = { + chandelierId: `chandelier_${recipeType.toLowerCase()}_${uuid}`, + recipeType, + finalHolyAuraRadiusPercent: finalRadius, + finalManaRechargeAuraPercent: finalRecharge, + illuminanceRadiancePercent: radianceScore, + consumedCupolaCount: recipe.requiredCupolaCount, + consumedCupolaType: recipe.requiredCupolaType, + remainingProvidedCupolas: remaining, + craftedEpochMs: currentEpochMs, + }; + + return { + success: true, + chandelier, + updatedHoist: hoist, + remainingDurability: hoist.currentDurability, + remainingProvidedCupolas: remaining, + }; + } + + /** + * Re-lubricates hoist pulleys and maintains chandelier assembly hoist. + */ + public static maintainHoist( + hoist: ActiveChandelierHoist, + repairAmount = 50 + ): { success: boolean; newDurability: number; isFunctional: boolean } { + if (!hoist) return { success: false, newDurability: 0, isFunctional: false }; + + const amt = Number.isFinite(repairAmount) ? Math.max(0, repairAmount) : 50; + hoist.currentDurability = Math.min(hoist.maxDurability, hoist.currentDurability + amt); + hoist.isFunctional = hoist.currentDurability >= this.DURABILITY_COST_PER_CRAFT; + + return { + success: true, + newDurability: hoist.currentDurability, + isFunctional: hoist.isFunctional, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/ancientRunicGlassStainedChandelier.test.ts b/api/src/tests/ancientRunicGlassStainedChandelier.test.ts new file mode 100644 index 00000000..0b5ec727 --- /dev/null +++ b/api/src/tests/ancientRunicGlassStainedChandelier.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { + AncientRunicGlassStainedChandelierEngine, + ActiveChandelierHoist, +} from "../lib/ancientRunicGlassStainedChandelier"; + +describe("AncientRunicGlassStainedChandelierEngine Chandelier Hoists & Cathedral Coronas", () => { + it("assembles Celestial Void Seraphic Cathedral Chandelier in Corona Sanctum achieving 100% radiance and returns spliced cupolas", () => { + const hoist = AncientRunicGlassStainedChandelierEngine.constructHoist("glazier_01", "CELESTIAL_VOID_CATHEDRAL_CORONA_SANCTUM"); + expect(hoist.hoistType).toBe("CELESTIAL_VOID_CATHEDRAL_CORONA_SANCTUM"); + expect(hoist.currentDurability).toBe(310); + + const initialCupolas = [ + "CELESTIAL_VOID_STARFIRE_CORONA_GLASS", + "CELESTIAL_VOID_STARFIRE_CORONA_GLASS", + "CELESTIAL_VOID_STARFIRE_CORONA_GLASS" + ] as any[]; + + const craftRes = AncientRunicGlassStainedChandelierEngine.assembleChandelier( + hoist, + "CELESTIAL_VOID_SERAPHIC_CATHEDRAL_CHANDELIER", + initialCupolas, + 0.1, // Success roll + 1.0, // Radiance roll 1.0 -> 40 + 40 + 20 = 100% + 100000 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.chandelier?.recipeType).toBe("CELESTIAL_VOID_SERAPHIC_CATHEDRAL_CHANDELIER"); + expect(craftRes.chandelier?.illuminanceRadiancePercent).toBe(100); + expect(craftRes.chandelier?.finalHolyAuraRadiusPercent).toBe(96); // 80 * 1.20 = 96% + expect(craftRes.chandelier?.finalManaRechargeAuraPercent).toBe(72); // 60 * 1.20 = 72% + expect(craftRes.chandelier?.consumedCupolaCount).toBe(2); + expect(craftRes.chandelier?.consumedCupolaType).toBe("CELESTIAL_VOID_STARFIRE_CORONA_GLASS"); + expect(craftRes.chandelier?.remainingProvidedCupolas.length).toBe(1); + expect(craftRes.remainingDurability).toBe(300); // 310 - 10 + }); + + it("verifies mid-range radiance roll and sub-100% quality scaling on Cedar hoist", () => { + const hoist = AncientRunicGlassStainedChandelierEngine.constructHoist("glazier_mid", "CEDAR_CHANDELIER_ASSEMBLY_HOIST"); + // powerRatio = 25/120 = 0.20833, bonusPoints = (10/35)*20 = 5.714 + // safeRadianceRoll = 0.5 -> 0.5 * 40 = 20 + // radianceScore = Math.round(20 + 8.333 + 5.714) = 34 + // qualityMultiplier = 0.8 + (34/100)*0.4 = 0.8 + 0.136 = 0.936 + // finalRadius = Math.round(20 * 0.936) = 19 + // finalRecharge = Math.round(10 * 0.936) = 9 + const craftRes = AncientRunicGlassStainedChandelierEngine.assembleChandelier( + hoist, + "SANCTUARY_VESPER_CHANDELIER", + ["CATHEDRAL_AMBER_GLASS_CUPOLA", "CATHEDRAL_AMBER_GLASS_CUPOLA"], + 0.1, + 0.5 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.chandelier?.illuminanceRadiancePercent).toBe(34); + expect(craftRes.chandelier?.finalHolyAuraRadiusPercent).toBe(19); + expect(craftRes.chandelier?.finalManaRechargeAuraPercent).toBe(9); + }); + + it("handles hoist becoming non-functional after successful craft when durability falls below threshold", () => { + const hoist = AncientRunicGlassStainedChandelierEngine.constructHoist("glazier_wear", "CEDAR_CHANDELIER_ASSEMBLY_HOIST"); + hoist.currentDurability = 15; + expect(hoist.isFunctional).toBe(true); + + // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false + const res1 = AncientRunicGlassStainedChandelierEngine.assembleChandelier( + hoist, + "SANCTUARY_VESPER_CHANDELIER", + ["CATHEDRAL_AMBER_GLASS_CUPOLA", "CATHEDRAL_AMBER_GLASS_CUPOLA"], + 0.1 + ); + expect(res1.success).toBe(true); + expect(res1.remainingDurability).toBe(5); + expect(hoist.isFunctional).toBe(false); + + // Subsequent craft is rejected and returns fallback array + const res2 = AncientRunicGlassStainedChandelierEngine.assembleChandelier( + hoist, + "SANCTUARY_VESPER_CHANDELIER", + ["CATHEDRAL_AMBER_GLASS_CUPOLA", "CATHEDRAL_AMBER_GLASS_CUPOLA"] + ); + expect(res2.success).toBe(false); + expect(res2.reason).toContain("jammed or lacks durability"); + expect(res2.remainingProvidedCupolas.length).toBe(2); + }); + + it("rejects crafting when insufficient cupola is provided and returns provided cupolas", () => { + const hoist = AncientRunicGlassStainedChandelierEngine.constructHoist("glazier_02", "CEDAR_CHANDELIER_ASSEMBLY_HOIST"); + + const failRes = AncientRunicGlassStainedChandelierEngine.assembleChandelier( + hoist, + "HIGH_ALTAR_IRON_CORONA_CHANDELIER", + ["FACETED_LEAD_CRYSTAL_PENDANT"] + ); + + expect(failRes.success).toBe(false); + expect(failRes.reason).toContain("Insufficient glass cupola"); + expect(failRes.remainingProvidedCupolas.length).toBe(1); + expect(hoist.currentDurability).toBe(75); + }); + + it("handles armature bent failure roll consuming durability and cupolas", () => { + const hoist = AncientRunicGlassStainedChandelierEngine.constructHoist("glazier_03", "CEDAR_CHANDELIER_ASSEMBLY_HOIST"); // 85% success + + const fail = AncientRunicGlassStainedChandelierEngine.assembleChandelier( + hoist, + "SANCTUARY_VESPER_CHANDELIER", + ["CATHEDRAL_AMBER_GLASS_CUPOLA", "CATHEDRAL_AMBER_GLASS_CUPOLA", "CATHEDRAL_AMBER_GLASS_CUPOLA"], + 0.95 + ); + + expect(fail.success).toBe(false); + expect(fail.reason).toContain("bent"); + expect(fail.remainingProvidedCupolas?.length).toBe(1); // 3 - 2 = 1 remaining + expect(hoist.currentDurability).toBe(65); // 75 - 10 + }); + + it("gates isFunctional in maintainHoist based on DURABILITY_COST_PER_CRAFT threshold", () => { + const hoist = AncientRunicGlassStainedChandelierEngine.constructHoist("glazier_04", "CEDAR_CHANDELIER_ASSEMBLY_HOIST"); + hoist.currentDurability = 0; + hoist.isFunctional = false; + + // Maintain 5 (below 10 required) -> isFunctional remains false + const repLow = AncientRunicGlassStainedChandelierEngine.maintainHoist(hoist, 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 = AncientRunicGlassStainedChandelierEngine.maintainHoist(hoist, 10); + expect(repHigh.success).toBe(true); + expect(repHigh.newDurability).toBe(15); + expect(repHigh.isFunctional).toBe(true); + }); + + it("guards against null inputs and unsupported hoist models", () => { + expect(() => AncientRunicGlassStainedChandelierEngine.constructHoist("g", "PLASTIC_HOIST" as any)).toThrow( + "Unsupported chandelier hoist type" + ); + + const invalidHoist: ActiveChandelierHoist = { + hoistId: "bad", + glazierPlayerId: "p", + hoistType: "HOIST" as any, + currentDurability: 50, + maxDurability: 50, + glazieryPower: 10, + isFunctional: true, + }; + + expect(AncientRunicGlassStainedChandelierEngine.assembleChandelier(invalidHoist, "SANCTUARY_VESPER_CHANDELIER", ["CATHEDRAL_AMBER_GLASS_CUPOLA", "CATHEDRAL_AMBER_GLASS_CUPOLA"]).success).toBe(false); + expect(AncientRunicGlassStainedChandelierEngine.assembleChandelier(null as any, "SANCTUARY_VESPER_CHANDELIER", []).success).toBe(false); + expect(AncientRunicGlassStainedChandelierEngine.maintainHoist(null as any).success).toBe(false); + }); +}); \ No newline at end of file