diff --git a/api/src/lib/ancientRunicGlassHourglassSandCaster.ts b/api/src/lib/ancientRunicGlassHourglassSandCaster.ts new file mode 100644 index 00000000..c98d86f3 --- /dev/null +++ b/api/src/lib/ancientRunicGlassHourglassSandCaster.ts @@ -0,0 +1,245 @@ +import crypto from "node:crypto"; + +/** + * Ancient Runic Glass Hourglass Sand Caster, Chronomantic Crucible & Temporal Flow Engine for OpenAO MMORPG. + * Simulates hourglass caster hearths and chronomantic calibration stands (Cedar Hourglass Caster Stand, Runic Brass Chronomantic Gimbal, Celestial Void Chronos Flow Sanctum), + * raw fused quartz bulbs and chronomantic sand phials (Fused Quartz Glass Bulb, Chronomantic Gold Sand Phial, Celestial Void Temporal Stardust Ampoule), + * temporal tether hourglasses and epoch clepsydra recipes (Wanderer Chrono-Tether Hourglass, Time-Warp Spell-Haste Sandglass, Celestial Void Chronos Epoch Clepsydra), + * independent temporal precision ratings (scaled across catalog baselines ~14% to 100%), calibrated clamped cooldown reduction aura and clamped haste flow duration scaling, + * upfront bulb material deduction on all craft attempts, consistent remainingProvidedBulbs return shapes across all paths, cached static catalog maxima, authoritative catalog power ratio without dead instance fields, and hourglass stand maintenance. + */ + +export type HourglassStandType = "CEDAR_HOURGLASS_CASTER_STAND" | "RUNIC_BRASS_CHRONOMANTIC_GIMBAL" | "CELESTIAL_VOID_CHRONOS_FLOW_SANCTUM"; +export type RawHourglassBulbType = "FUSED_QUARTZ_GLASS_BULB" | "CHRONOMANTIC_GOLD_SAND_PHIAL" | "CELESTIAL_VOID_TEMPORAL_STARDUST_AMPOULE"; +export type TemporalHourglassRecipeType = "WANDERER_CHRONO_TETHER_HOURGLASS" | "TIME_WARP_SPELL_HASTE_SANDGLASS" | "CELESTIAL_VOID_CHRONOS_EPOCH_CLEPSYDRA"; + +export interface HourglassStandData { + standType: HourglassStandType; + maxDurability: number; + chronomanticPower: number; + baseSuccessRatePercent: number; // 0 to 100 + temporalBonusPercent: number; +} + +export interface TemporalHourglassRecipeData { + recipeType: TemporalHourglassRecipeType; + requiredBulbType: RawHourglassBulbType; + requiredBulbCount: number; + baseCooldownReductionPercent: number; + baseHasteFlowDurationPercent: number; +} + +export interface ActiveHourglassStand { + standId: string; + casterPlayerId: string; + standType: HourglassStandType; + currentDurability: number; + maxDurability: number; + isFunctional: boolean; +} + +export interface CraftedTemporalHourglass { + hourglassId: string; + recipeType: TemporalHourglassRecipeType; + finalCooldownReductionPercent: number; + finalHasteFlowDurationPercent: number; + temporalPrecisionPercent: number; // Scaled rating (clamped 0 to 100%, with catalog stand baselines ~14% to 100%) + consumedBulbCount: number; + consumedBulbType: RawHourglassBulbType; + remainingProvidedBulbs: RawHourglassBulbType[]; + craftedEpochMs: number; +} + +export const HOURGLASS_STAND_CATALOG: Record = { + CEDAR_HOURGLASS_CASTER_STAND: { standType: "CEDAR_HOURGLASS_CASTER_STAND", maxDurability: 75, chronomanticPower: 25, baseSuccessRatePercent: 85, temporalBonusPercent: 10 }, + RUNIC_BRASS_CHRONOMANTIC_GIMBAL: { standType: "RUNIC_BRASS_CHRONOMANTIC_GIMBAL", maxDurability: 170, chronomanticPower: 65, baseSuccessRatePercent: 92, temporalBonusPercent: 20 }, + CELESTIAL_VOID_CHRONOS_FLOW_SANCTUM: { standType: "CELESTIAL_VOID_CHRONOS_FLOW_SANCTUM", maxDurability: 310, chronomanticPower: 120, baseSuccessRatePercent: 99, temporalBonusPercent: 35 }, +}; + +export const HOURGLASS_RECIPE_CATALOG: Record = { + WANDERER_CHRONO_TETHER_HOURGLASS: { recipeType: "WANDERER_CHRONO_TETHER_HOURGLASS", requiredBulbType: "FUSED_QUARTZ_GLASS_BULB", requiredBulbCount: 2, baseCooldownReductionPercent: 20, baseHasteFlowDurationPercent: 10 }, + TIME_WARP_SPELL_HASTE_SANDGLASS: { recipeType: "TIME_WARP_SPELL_HASTE_SANDGLASS", requiredBulbType: "CHRONOMANTIC_GOLD_SAND_PHIAL", requiredBulbCount: 2, baseCooldownReductionPercent: 45, baseHasteFlowDurationPercent: 25 }, + CELESTIAL_VOID_CHRONOS_EPOCH_CLEPSYDRA: { recipeType: "CELESTIAL_VOID_CHRONOS_EPOCH_CLEPSYDRA", requiredBulbType: "CELESTIAL_VOID_TEMPORAL_STARDUST_AMPOULE", requiredBulbCount: 2, baseCooldownReductionPercent: 80, baseHasteFlowDurationPercent: 60 }, +}; + +export class AncientRunicGlassHourglassSandCasterEngine { + 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(HOURGLASS_STAND_CATALOG).map(s => s.chronomanticPower), 1), + maxBonus: Math.max(...Object.values(HOURGLASS_STAND_CATALOG).map(s => s.temporalBonusPercent), 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 an hourglass sand caster stand or chronomantic gimbal. + */ + public static constructStand( + casterPlayerId: string, + standType: HourglassStandType + ): ActiveHourglassStand { + const data = HOURGLASS_STAND_CATALOG[standType]; + if (!data) { + throw new Error(`Unsupported hourglass stand type: ${String(standType)}`); + } + + const uuid = this.generateSecureId(); + + return { + standId: `stand_${standType.toLowerCase()}_${uuid}`, + casterPlayerId, + standType, + currentDurability: data.maxDurability, + maxDurability: data.maxDurability, + isFunctional: true, + }; + } + + /** + * Fills and seals quartz bulbs with chronomantic sand into temporal sandglasses and epoch clepsydras. + * Note: Mutates the passed `stand` in place and returns it as `updatedStand` for caller ergonomics. + */ + public static castHourglass( + stand: ActiveHourglassStand, + recipeType: TemporalHourglassRecipeType, + providedBulbs: RawHourglassBulbType[], + craftRoll = Math.random(), + precisionRoll = Math.random(), + currentEpochMs = Date.now() + ): { success: boolean; hourglass?: CraftedTemporalHourglass; updatedStand?: ActiveHourglassStand; remainingDurability: number; remainingProvidedBulbs: RawHourglassBulbType[]; reason?: string } { + const fallbackBulbs = Array.isArray(providedBulbs) ? [...providedBulbs] : []; + + if (!stand || !stand.isFunctional || stand.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + return { + success: false, + updatedStand: stand, + remainingDurability: stand?.currentDurability ?? 0, + remainingProvidedBulbs: fallbackBulbs, + reason: `Hourglass stand is misaligned or lacks durability (requires ${this.DURABILITY_COST_PER_CRAFT}).`, + }; + } + + const standData = HOURGLASS_STAND_CATALOG[stand.standType]; + if (!standData) { + return { success: false, updatedStand: stand, remainingDurability: stand.currentDurability, remainingProvidedBulbs: fallbackBulbs, reason: `Unknown stand model: ${String(stand.standType)}` }; + } + + const recipe = HOURGLASS_RECIPE_CATALOG[recipeType]; + if (!recipe) { + return { success: false, updatedStand: stand, remainingDurability: stand.currentDurability, remainingProvidedBulbs: fallbackBulbs, reason: `Unknown hourglass recipe: ${String(recipeType)}` }; + } + + if (!Array.isArray(providedBulbs)) { + return { success: false, updatedStand: stand, remainingDurability: stand.currentDurability, remainingProvidedBulbs: [], reason: "Invalid bulbs array." }; + } + + // Count matching glass bulbs + const matchingCount = providedBulbs.filter(b => b === recipe.requiredBulbType).length; + if (matchingCount < recipe.requiredBulbCount) { + return { + success: false, + updatedStand: stand, + remainingDurability: stand.currentDurability, + remainingProvidedBulbs: fallbackBulbs, + reason: `Insufficient glass bulb: requires ${recipe.requiredBulbCount}x ${recipe.requiredBulbType}, provided ${matchingCount}.`, + }; + } + + // Deduct durability in place + stand.currentDurability -= this.DURABILITY_COST_PER_CRAFT; + if (stand.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + stand.currentDurability = Math.max(0, stand.currentDurability); + stand.isFunctional = false; + } + + // Deduct materials upfront on all craft attempts + const remaining = [...providedBulbs]; + let removed = 0; + for (let i = remaining.length - 1; i >= 0 && removed < recipe.requiredBulbCount; i--) { + if (remaining[i] === recipe.requiredBulbType) { + 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 > standData.baseSuccessRatePercent) { + return { + success: false, + updatedStand: stand, + remainingDurability: stand.currentDurability, + remainingProvidedBulbs: remaining, + reason: `Hourglass cracked: thermal stress cleaved waist neck, rolled ${rollPercent.toFixed(1)}, needed <= ${standData.baseSuccessRatePercent}.`, + }; + } + + // Calculate independent temporal precision score dynamically using cached catalog maxima & authoritative catalog values (clamped 0% to 100%, scaling across catalog baselines) + const { maxPower, maxBonus } = this.CATALOG_MAXIMA; + const safePrecisionRoll = Number.isFinite(precisionRoll) ? Math.max(0, Math.min(1, precisionRoll)) : Math.random(); + const powerRatio = Math.min(1.0, standData.chronomanticPower / maxPower); + const bonusPoints = (standData.temporalBonusPercent / maxBonus) * 20; + const precisionScore = Math.max(0, Math.min(100, Math.round( + (safePrecisionRoll * 40) + (powerRatio * 40) + bonusPoints + ))); + const qualityMultiplier = 0.8 + ((precisionScore / 100) * 0.4); // 0.8 to 1.2x + + const finalCooldown = Math.max(0, Math.min(100, Math.round(recipe.baseCooldownReductionPercent * qualityMultiplier))); + const finalHaste = Math.max(0, Math.min(100, Math.round(recipe.baseHasteFlowDurationPercent * qualityMultiplier))); + + const uuid = this.generateSecureId(); + + const hourglass: CraftedTemporalHourglass = { + hourglassId: `hourglass_${recipeType.toLowerCase()}_${uuid}`, + recipeType, + finalCooldownReductionPercent: finalCooldown, + finalHasteFlowDurationPercent: finalHaste, + temporalPrecisionPercent: precisionScore, + consumedBulbCount: recipe.requiredBulbCount, + consumedBulbType: recipe.requiredBulbType, + remainingProvidedBulbs: remaining, + craftedEpochMs: currentEpochMs, + }; + + return { + success: true, + hourglass, + updatedStand: stand, + remainingDurability: stand.currentDurability, + remainingProvidedBulbs: remaining, + }; + } + + /** + * Re-levels chronomantic gimbals and maintains hourglass caster stand. + */ + public static maintainStand( + stand: ActiveHourglassStand, + repairAmount = 50 + ): { success: boolean; newDurability: number; isFunctional: boolean } { + if (!stand) return { success: false, newDurability: 0, isFunctional: false }; + + const amt = Number.isFinite(repairAmount) ? Math.max(0, repairAmount) : 50; + stand.currentDurability = Math.min(stand.maxDurability, stand.currentDurability + amt); + stand.isFunctional = stand.currentDurability >= this.DURABILITY_COST_PER_CRAFT; + + return { + success: true, + newDurability: stand.currentDurability, + isFunctional: stand.isFunctional, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/ancientRunicGlassHourglassSandCaster.test.ts b/api/src/tests/ancientRunicGlassHourglassSandCaster.test.ts new file mode 100644 index 00000000..58e00b2a --- /dev/null +++ b/api/src/tests/ancientRunicGlassHourglassSandCaster.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { + AncientRunicGlassHourglassSandCasterEngine, + ActiveHourglassStand, +} from "../lib/ancientRunicGlassHourglassSandCaster"; + +describe("AncientRunicGlassHourglassSandCasterEngine Hourglass Stands & Chronomantic Clepsydras", () => { + it("casts Celestial Void Chronos Epoch Clepsydra in Flow Sanctum achieving 100% precision and returns spliced bulbs", () => { + const stand = AncientRunicGlassHourglassSandCasterEngine.constructStand("caster_01", "CELESTIAL_VOID_CHRONOS_FLOW_SANCTUM"); + expect(stand.standType).toBe("CELESTIAL_VOID_CHRONOS_FLOW_SANCTUM"); + expect(stand.currentDurability).toBe(310); + + const initialBulbs = [ + "CELESTIAL_VOID_TEMPORAL_STARDUST_AMPOULE", + "CELESTIAL_VOID_TEMPORAL_STARDUST_AMPOULE", + "CELESTIAL_VOID_TEMPORAL_STARDUST_AMPOULE" + ] as any[]; + + const craftRes = AncientRunicGlassHourglassSandCasterEngine.castHourglass( + stand, + "CELESTIAL_VOID_CHRONOS_EPOCH_CLEPSYDRA", + initialBulbs, + 0.1, // Success roll + 1.0, // Precision roll 1.0 -> 40 + 40 + 20 = 100% + 100000 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.hourglass?.recipeType).toBe("CELESTIAL_VOID_CHRONOS_EPOCH_CLEPSYDRA"); + expect(craftRes.hourglass?.temporalPrecisionPercent).toBe(100); + expect(craftRes.hourglass?.finalCooldownReductionPercent).toBe(96); // 80 * 1.20 = 96% + expect(craftRes.hourglass?.finalHasteFlowDurationPercent).toBe(72); // 60 * 1.20 = 72% + expect(craftRes.hourglass?.consumedBulbCount).toBe(2); + expect(craftRes.hourglass?.consumedBulbType).toBe("CELESTIAL_VOID_TEMPORAL_STARDUST_AMPOULE"); + expect(craftRes.hourglass?.remainingProvidedBulbs.length).toBe(1); + expect(craftRes.remainingDurability).toBe(300); // 310 - 10 + }); + + it("verifies mid-range precision roll and sub-100% quality scaling on Cedar stand", () => { + const stand = AncientRunicGlassHourglassSandCasterEngine.constructStand("caster_mid", "CEDAR_HOURGLASS_CASTER_STAND"); + // powerRatio = 25/120 = 0.20833, bonusPoints = (10/35)*20 = 5.714 + // safePrecisionRoll = 0.5 -> 0.5 * 40 = 20 + // precisionScore = Math.round(20 + 8.333 + 5.714) = 34 + // qualityMultiplier = 0.8 + (34/100)*0.4 = 0.8 + 0.136 = 0.936 + // finalCooldown = Math.round(20 * 0.936) = 19 + // finalHaste = Math.round(10 * 0.936) = 9 + const craftRes = AncientRunicGlassHourglassSandCasterEngine.castHourglass( + stand, + "WANDERER_CHRONO_TETHER_HOURGLASS", + ["FUSED_QUARTZ_GLASS_BULB", "FUSED_QUARTZ_GLASS_BULB"], + 0.1, + 0.5 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.hourglass?.temporalPrecisionPercent).toBe(34); + expect(craftRes.hourglass?.finalCooldownReductionPercent).toBe(19); + expect(craftRes.hourglass?.finalHasteFlowDurationPercent).toBe(9); + }); + + it("handles stand becoming non-functional after successful craft when durability falls below threshold", () => { + const stand = AncientRunicGlassHourglassSandCasterEngine.constructStand("caster_wear", "CEDAR_HOURGLASS_CASTER_STAND"); + stand.currentDurability = 15; + expect(stand.isFunctional).toBe(true); + + // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false + const res1 = AncientRunicGlassHourglassSandCasterEngine.castHourglass( + stand, + "WANDERER_CHRONO_TETHER_HOURGLASS", + ["FUSED_QUARTZ_GLASS_BULB", "FUSED_QUARTZ_GLASS_BULB"], + 0.1 + ); + expect(res1.success).toBe(true); + expect(res1.remainingDurability).toBe(5); + expect(stand.isFunctional).toBe(false); + + // Subsequent craft is rejected and returns fallback array + const res2 = AncientRunicGlassHourglassSandCasterEngine.castHourglass( + stand, + "WANDERER_CHRONO_TETHER_HOURGLASS", + ["FUSED_QUARTZ_GLASS_BULB", "FUSED_QUARTZ_GLASS_BULB"] + ); + expect(res2.success).toBe(false); + expect(res2.reason).toContain("misaligned or lacks durability"); + expect(res2.remainingProvidedBulbs.length).toBe(2); + }); + + it("rejects crafting when insufficient bulb is provided and returns provided bulbs", () => { + const stand = AncientRunicGlassHourglassSandCasterEngine.constructStand("caster_02", "CEDAR_HOURGLASS_CASTER_STAND"); + + const failRes = AncientRunicGlassHourglassSandCasterEngine.castHourglass( + stand, + "TIME_WARP_SPELL_HASTE_SANDGLASS", + ["CHRONOMANTIC_GOLD_SAND_PHIAL"] + ); + + expect(failRes.success).toBe(false); + expect(failRes.reason).toContain("Insufficient glass bulb"); + expect(failRes.remainingProvidedBulbs.length).toBe(1); + expect(stand.currentDurability).toBe(75); + }); + + it("handles hourglass cracked failure roll consuming durability and bulbs", () => { + const stand = AncientRunicGlassHourglassSandCasterEngine.constructStand("caster_03", "CEDAR_HOURGLASS_CASTER_STAND"); // 85% success + + const fail = AncientRunicGlassHourglassSandCasterEngine.castHourglass( + stand, + "WANDERER_CHRONO_TETHER_HOURGLASS", + ["FUSED_QUARTZ_GLASS_BULB", "FUSED_QUARTZ_GLASS_BULB", "FUSED_QUARTZ_GLASS_BULB"], + 0.95 + ); + + expect(fail.success).toBe(false); + expect(fail.reason).toContain("cracked"); + expect(fail.remainingProvidedBulbs?.length).toBe(1); // 3 - 2 = 1 remaining + expect(stand.currentDurability).toBe(65); // 75 - 10 + }); + + it("gates isFunctional in maintainStand based on DURABILITY_COST_PER_CRAFT threshold", () => { + const stand = AncientRunicGlassHourglassSandCasterEngine.constructStand("caster_04", "CEDAR_HOURGLASS_CASTER_STAND"); + stand.currentDurability = 0; + stand.isFunctional = false; + + // Maintain 5 (below 10 required) -> isFunctional remains false + const repLow = AncientRunicGlassHourglassSandCasterEngine.maintainStand(stand, 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 = AncientRunicGlassHourglassSandCasterEngine.maintainStand(stand, 10); + expect(repHigh.success).toBe(true); + expect(repHigh.newDurability).toBe(15); + expect(repHigh.isFunctional).toBe(true); + }); + + it("guards against null inputs and unsupported stand models", () => { + expect(() => AncientRunicGlassHourglassSandCasterEngine.constructStand("c", "PLASTIC_STAND" as any)).toThrow( + "Unsupported hourglass stand type" + ); + + const invalidStand: ActiveHourglassStand = { + standId: "bad", + casterPlayerId: "p", + standType: "STAND" as any, + currentDurability: 50, + maxDurability: 50, + isFunctional: true, + }; + + expect(AncientRunicGlassHourglassSandCasterEngine.castHourglass(invalidStand, "WANDERER_CHRONO_TETHER_HOURGLASS", ["FUSED_QUARTZ_GLASS_BULB", "FUSED_QUARTZ_GLASS_BULB"]).success).toBe(false); + expect(AncientRunicGlassHourglassSandCasterEngine.castHourglass(null as any, "WANDERER_CHRONO_TETHER_HOURGLASS", []).success).toBe(false); + expect(AncientRunicGlassHourglassSandCasterEngine.maintainStand(null as any).success).toBe(false); + }); +}); \ No newline at end of file