diff --git a/api/src/lib/ancientRunicGlassEngravingLathe.ts b/api/src/lib/ancientRunicGlassEngravingLathe.ts new file mode 100644 index 00000000..19da2e4c --- /dev/null +++ b/api/src/lib/ancientRunicGlassEngravingLathe.ts @@ -0,0 +1,247 @@ +import crypto from "node:crypto"; + +/** + * Ancient Runic Glass Engraving Lathe, Copper Wheel Intaglio & Arcane Sigil Engine for OpenAO MMORPG. + * Simulates glass engraving lathes and intaglio copper wheels (Cedar Glass Engraving Lathe, Runic Copper Wheel Intaglio Bench, Celestial Void Sigil Sanctum), + * raw quartz crystal goblets and flacon blanks (Quartz Crystal Goblet Blank, Lead Crystal Decanter Blank, Celestial Void Starlight Flacon), + * engraved chalice and reliquary flacon recipes (Chalice of Sovereign Vitality, Decanter of Arcane Clarity, Celestial Void Reliquary Flacon), + * independent runic resonance ratings (0% to 100%), clamped spell empower and clamped mana conservation scaling, + * upfront blank material deduction on all craft attempts, consistent remainingProvidedBlanks return shapes across all paths, cached static catalog maxima, authoritative catalog power ratio, and engraving lathe maintenance. + */ + +export type EngravingLatheType = "CEDAR_GLASS_ENGRAVING_LATHE" | "RUNIC_COPPER_WHEEL_INTAGLIO_BENCH" | "CELESTIAL_VOID_SIGIL_SANCTUM"; +export type RawGobletBlankType = "QUARTZ_CRYSTAL_GOBLET_BLANK" | "LEAD_CRYSTAL_DECANTER_BLANK" | "CELESTIAL_VOID_STARLIGHT_FLACON"; +export type EngravedGlasswareRecipeType = "CHALICE_OF_SOVEREIGN_VITALITY" | "DECANTER_OF_ARCANE_CLARITY" | "CELESTIAL_VOID_RELIQUARY_FLACON"; + +export interface EngravingLatheData { + latheType: EngravingLatheType; + maxDurability: number; + engravingPower: number; + baseSuccessRatePercent: number; // 0 to 100 + sigilBonusPercent: number; +} + +export interface EngravedGlasswareRecipeData { + recipeType: EngravedGlasswareRecipeType; + requiredBlankType: RawGobletBlankType; + requiredBlankCount: number; + baseSpellEmpowerPercent: number; + baseManaConservationPercent: number; +} + +export interface ActiveEngravingLathe { + latheId: string; + engraverPlayerId: string; + latheType: EngravingLatheType; + currentDurability: number; + maxDurability: number; + engravingPower: number; + isFunctional: boolean; +} + +export interface CraftedEngravedGlassware { + glasswareId: string; + recipeType: EngravedGlasswareRecipeType; + finalSpellEmpowerPercent: number; + finalManaConservationPercent: number; + runicResonancePercent: number; // 0 to 100 + consumedBlankCount: number; + consumedBlankType: RawGobletBlankType; + remainingProvidedBlanks: RawGobletBlankType[]; + craftedEpochMs: number; +} + +export const LATHE_CATALOG: Record = { + CEDAR_GLASS_ENGRAVING_LATHE: { latheType: "CEDAR_GLASS_ENGRAVING_LATHE", maxDurability: 75, engravingPower: 25, baseSuccessRatePercent: 85, sigilBonusPercent: 10 }, + RUNIC_COPPER_WHEEL_INTAGLIO_BENCH: { latheType: "RUNIC_COPPER_WHEEL_INTAGLIO_BENCH", maxDurability: 170, engravingPower: 65, baseSuccessRatePercent: 92, sigilBonusPercent: 20 }, + CELESTIAL_VOID_SIGIL_SANCTUM: { latheType: "CELESTIAL_VOID_SIGIL_SANCTUM", maxDurability: 310, engravingPower: 120, baseSuccessRatePercent: 99, sigilBonusPercent: 35 }, +}; + +export const ENGRAVED_RECIPE_CATALOG: Record = { + CHALICE_OF_SOVEREIGN_VITALITY: { recipeType: "CHALICE_OF_SOVEREIGN_VITALITY", requiredBlankType: "QUARTZ_CRYSTAL_GOBLET_BLANK", requiredBlankCount: 2, baseSpellEmpowerPercent: 20, baseManaConservationPercent: 10 }, + DECANTER_OF_ARCANE_CLARITY: { recipeType: "DECANTER_OF_ARCANE_CLARITY", requiredBlankType: "LEAD_CRYSTAL_DECANTER_BLANK", requiredBlankCount: 2, baseSpellEmpowerPercent: 45, baseManaConservationPercent: 25 }, + CELESTIAL_VOID_RELIQUARY_FLACON: { recipeType: "CELESTIAL_VOID_RELIQUARY_FLACON", requiredBlankType: "CELESTIAL_VOID_STARLIGHT_FLACON", requiredBlankCount: 2, baseSpellEmpowerPercent: 80, baseManaConservationPercent: 60 }, +}; + +export class AncientRunicGlassEngravingLatheEngine { + 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(LATHE_CATALOG).map(l => l.engravingPower), 1), + maxBonus: Math.max(...Object.values(LATHE_CATALOG).map(l => l.sigilBonusPercent), 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 engraving lathe or intaglio wheel bench. + */ + public static constructLathe( + engraverPlayerId: string, + latheType: EngravingLatheType + ): ActiveEngravingLathe { + const data = LATHE_CATALOG[latheType]; + if (!data) { + throw new Error(`Unsupported engraving lathe type: ${String(latheType)}`); + } + + const uuid = this.generateSecureId(); + + return { + latheId: `lathe_${latheType.toLowerCase()}_${uuid}`, + engraverPlayerId, + latheType, + currentDurability: data.maxDurability, + maxDurability: data.maxDurability, + engravingPower: data.engravingPower, + isFunctional: true, + }; + } + + /** + * Engraves and carves intaglio sigils into crystal goblets and reliquary flacons. + * Note: Mutates the passed `lathe` in place and returns it as `updatedLathe` for caller ergonomics. + */ + public static engraveGlassware( + lathe: ActiveEngravingLathe, + recipeType: EngravedGlasswareRecipeType, + providedBlanks: RawGobletBlankType[], + craftRoll = Math.random(), + resonanceRoll = Math.random(), + currentEpochMs = Date.now() + ): { success: boolean; glassware?: CraftedEngravedGlassware; updatedLathe?: ActiveEngravingLathe; remainingDurability: number; remainingProvidedBlanks: RawGobletBlankType[]; reason?: string } { + const fallbackBlanks = Array.isArray(providedBlanks) ? [...providedBlanks] : []; + + if (!lathe || !lathe.isFunctional || lathe.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + return { + success: false, + updatedLathe: lathe, + remainingDurability: lathe?.currentDurability ?? 0, + remainingProvidedBlanks: fallbackBlanks, + reason: `Engraving lathe is misaligned or lacks durability (requires ${this.DURABILITY_COST_PER_CRAFT}).`, + }; + } + + const latheData = LATHE_CATALOG[lathe.latheType]; + if (!latheData) { + return { success: false, updatedLathe: lathe, remainingDurability: lathe.currentDurability, remainingProvidedBlanks: fallbackBlanks, reason: `Unknown lathe model: ${String(lathe.latheType)}` }; + } + + const recipe = ENGRAVED_RECIPE_CATALOG[recipeType]; + if (!recipe) { + return { success: false, updatedLathe: lathe, remainingDurability: lathe.currentDurability, remainingProvidedBlanks: fallbackBlanks, reason: `Unknown glassware recipe: ${String(recipeType)}` }; + } + + if (!Array.isArray(providedBlanks)) { + return { success: false, updatedLathe: lathe, remainingDurability: lathe.currentDurability, remainingProvidedBlanks: [], reason: "Invalid blanks array." }; + } + + // Count matching crystal blanks + const matchingCount = providedBlanks.filter(b => b === recipe.requiredBlankType).length; + if (matchingCount < recipe.requiredBlankCount) { + return { + success: false, + updatedLathe: lathe, + remainingDurability: lathe.currentDurability, + remainingProvidedBlanks: fallbackBlanks, + reason: `Insufficient crystal blank: requires ${recipe.requiredBlankCount}x ${recipe.requiredBlankType}, provided ${matchingCount}.`, + }; + } + + // Deduct durability in place + lathe.currentDurability -= this.DURABILITY_COST_PER_CRAFT; + if (lathe.currentDurability < this.DURABILITY_COST_PER_CRAFT) { + lathe.currentDurability = Math.max(0, lathe.currentDurability); + lathe.isFunctional = false; + } + + // Deduct materials upfront on all craft attempts + const remaining = [...providedBlanks]; + let removed = 0; + for (let i = remaining.length - 1; i >= 0 && removed < recipe.requiredBlankCount; i--) { + if (remaining[i] === recipe.requiredBlankType) { + 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 > latheData.baseSuccessRatePercent) { + return { + success: false, + updatedLathe: lathe, + remainingDurability: lathe.currentDurability, + remainingProvidedBlanks: remaining, + reason: `Crystal blank fractured: copper wheel chatter chipped intaglio rim, rolled ${rollPercent.toFixed(1)}, needed <= ${latheData.baseSuccessRatePercent}.`, + }; + } + + // Calculate independent runic resonance score (0% to 100%) dynamically using cached catalog maxima & authoritative catalog values + const { maxPower, maxBonus } = this.CATALOG_MAXIMA; + const safeResonanceRoll = Number.isFinite(resonanceRoll) ? Math.max(0, Math.min(1, resonanceRoll)) : Math.random(); + const powerRatio = Math.min(1.0, latheData.engravingPower / maxPower); + const bonusPoints = (latheData.sigilBonusPercent / maxBonus) * 20; + const resonanceScore = Math.max(0, Math.min(100, Math.round( + (safeResonanceRoll * 40) + (powerRatio * 40) + bonusPoints + ))); + const qualityMultiplier = 0.8 + ((resonanceScore / 100) * 0.4); // 0.8 to 1.2x + + const finalEmpower = Math.max(0, Math.min(100, Math.round(recipe.baseSpellEmpowerPercent * qualityMultiplier))); + const finalConservation = Math.max(0, Math.min(100, Math.round(recipe.baseManaConservationPercent * qualityMultiplier))); + + const uuid = this.generateSecureId(); + + const glassware: CraftedEngravedGlassware = { + glasswareId: `engraved_${recipeType.toLowerCase()}_${uuid}`, + recipeType, + finalSpellEmpowerPercent: finalEmpower, + finalManaConservationPercent: finalConservation, + runicResonancePercent: resonanceScore, + consumedBlankCount: recipe.requiredBlankCount, + consumedBlankType: recipe.requiredBlankType, + remainingProvidedBlanks: remaining, + craftedEpochMs: currentEpochMs, + }; + + return { + success: true, + glassware, + updatedLathe: lathe, + remainingDurability: lathe.currentDurability, + remainingProvidedBlanks: remaining, + }; + } + + /** + * Re-dresses copper intaglio wheels and maintains engraving lathe. + */ + public static maintainLathe( + lathe: ActiveEngravingLathe, + repairAmount = 50 + ): { success: boolean; newDurability: number; isFunctional: boolean } { + if (!lathe) return { success: false, newDurability: 0, isFunctional: false }; + + const amt = Number.isFinite(repairAmount) ? Math.max(0, repairAmount) : 50; + lathe.currentDurability = Math.min(lathe.maxDurability, lathe.currentDurability + amt); + lathe.isFunctional = lathe.currentDurability >= this.DURABILITY_COST_PER_CRAFT; + + return { + success: true, + newDurability: lathe.currentDurability, + isFunctional: lathe.isFunctional, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/ancientRunicGlassEngravingLathe.test.ts b/api/src/tests/ancientRunicGlassEngravingLathe.test.ts new file mode 100644 index 00000000..17e8de8a --- /dev/null +++ b/api/src/tests/ancientRunicGlassEngravingLathe.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { + AncientRunicGlassEngravingLatheEngine, + ActiveEngravingLathe, +} from "../lib/ancientRunicGlassEngravingLathe.js"; + +describe("AncientRunicGlassEngravingLatheEngine Engraving Lathes & Crystal Glassware", () => { + it("engraves Celestial Void Reliquary Flacon in Sigil Sanctum achieving 100% resonance and returns spliced flacons", () => { + const lathe = AncientRunicGlassEngravingLatheEngine.constructLathe("engraver_01", "CELESTIAL_VOID_SIGIL_SANCTUM"); + expect(lathe.latheType).toBe("CELESTIAL_VOID_SIGIL_SANCTUM"); + expect(lathe.currentDurability).toBe(310); + + const initialBlanks = [ + "CELESTIAL_VOID_STARLIGHT_FLACON", + "CELESTIAL_VOID_STARLIGHT_FLACON", + "CELESTIAL_VOID_STARLIGHT_FLACON" + ] as any[]; + + const craftRes = AncientRunicGlassEngravingLatheEngine.engraveGlassware( + lathe, + "CELESTIAL_VOID_RELIQUARY_FLACON", + initialBlanks, + 0.1, // Success roll + 1.0, // Resonance roll 1.0 -> 40 + 40 + 20 = 100% + 100000 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.glassware?.recipeType).toBe("CELESTIAL_VOID_RELIQUARY_FLACON"); + expect(craftRes.glassware?.runicResonancePercent).toBe(100); + expect(craftRes.glassware?.finalSpellEmpowerPercent).toBe(96); // 80 * 1.20 = 96% + expect(craftRes.glassware?.finalManaConservationPercent).toBe(72); // 60 * 1.20 = 72% + expect(craftRes.glassware?.consumedBlankCount).toBe(2); + expect(craftRes.glassware?.consumedBlankType).toBe("CELESTIAL_VOID_STARLIGHT_FLACON"); + expect(craftRes.glassware?.remainingProvidedBlanks.length).toBe(1); + expect(craftRes.remainingDurability).toBe(300); // 310 - 10 + }); + + it("verifies mid-range resonance roll and sub-100% quality scaling on Cedar lathe", () => { + const lathe = AncientRunicGlassEngravingLatheEngine.constructLathe("engraver_mid", "CEDAR_GLASS_ENGRAVING_LATHE"); + // powerRatio = 25/120 = 0.20833, bonusPoints = (10/35)*20 = 5.714 + // safeResonanceRoll = 0.5 -> 0.5 * 40 = 20 + // resonanceScore = Math.round(20 + 8.333 + 5.714) = 34 + // qualityMultiplier = 0.8 + (34/100)*0.4 = 0.8 + 0.136 = 0.936 + // finalEmpower = Math.round(20 * 0.936) = 19 + // finalConservation = Math.round(10 * 0.936) = 9 + const craftRes = AncientRunicGlassEngravingLatheEngine.engraveGlassware( + lathe, + "CHALICE_OF_SOVEREIGN_VITALITY", + ["QUARTZ_CRYSTAL_GOBLET_BLANK", "QUARTZ_CRYSTAL_GOBLET_BLANK"], + 0.1, + 0.5 + ); + + expect(craftRes.success).toBe(true); + expect(craftRes.glassware?.runicResonancePercent).toBe(34); + expect(craftRes.glassware?.finalSpellEmpowerPercent).toBe(19); + expect(craftRes.glassware?.finalManaConservationPercent).toBe(9); + }); + + it("handles lathe becoming non-functional after successful craft when durability falls below threshold", () => { + const lathe = AncientRunicGlassEngravingLatheEngine.constructLathe("engraver_wear", "CEDAR_GLASS_ENGRAVING_LATHE"); + lathe.currentDurability = 15; + expect(lathe.isFunctional).toBe(true); + + // First craft succeeds: 15 - 10 = 5 (< 10), so isFunctional flips to false + const res1 = AncientRunicGlassEngravingLatheEngine.engraveGlassware( + lathe, + "CHALICE_OF_SOVEREIGN_VITALITY", + ["QUARTZ_CRYSTAL_GOBLET_BLANK", "QUARTZ_CRYSTAL_GOBLET_BLANK"], + 0.1 + ); + expect(res1.success).toBe(true); + expect(res1.remainingDurability).toBe(5); + expect(lathe.isFunctional).toBe(false); + + // Subsequent craft is rejected and returns fallback array + const res2 = AncientRunicGlassEngravingLatheEngine.engraveGlassware( + lathe, + "CHALICE_OF_SOVEREIGN_VITALITY", + ["QUARTZ_CRYSTAL_GOBLET_BLANK", "QUARTZ_CRYSTAL_GOBLET_BLANK"] + ); + expect(res2.success).toBe(false); + expect(res2.reason).toContain("misaligned or lacks durability"); + expect(res2.remainingProvidedBlanks.length).toBe(2); + }); + + it("rejects crafting when insufficient blank is provided and returns provided blanks", () => { + const lathe = AncientRunicGlassEngravingLatheEngine.constructLathe("engraver_02", "CEDAR_GLASS_ENGRAVING_LATHE"); + + const failRes = AncientRunicGlassEngravingLatheEngine.engraveGlassware( + lathe, + "DECANTER_OF_ARCANE_CLARITY", + ["LEAD_CRYSTAL_DECANTER_BLANK"] + ); + + expect(failRes.success).toBe(false); + expect(failRes.reason).toContain("Insufficient crystal blank"); + expect(failRes.remainingProvidedBlanks.length).toBe(1); + expect(lathe.currentDurability).toBe(75); + }); + + it("handles crystal blank fractured failure roll consuming durability and blanks", () => { + const lathe = AncientRunicGlassEngravingLatheEngine.constructLathe("engraver_03", "CEDAR_GLASS_ENGRAVING_LATHE"); // 85% success + + const fail = AncientRunicGlassEngravingLatheEngine.engraveGlassware( + lathe, + "CHALICE_OF_SOVEREIGN_VITALITY", + ["QUARTZ_CRYSTAL_GOBLET_BLANK", "QUARTZ_CRYSTAL_GOBLET_BLANK", "QUARTZ_CRYSTAL_GOBLET_BLANK"], + 0.95 + ); + + expect(fail.success).toBe(false); + expect(fail.reason).toContain("fractured"); + expect(fail.remainingProvidedBlanks?.length).toBe(1); // 3 - 2 = 1 remaining + expect(lathe.currentDurability).toBe(65); // 75 - 10 + }); + + it("gates isFunctional in maintainLathe based on DURABILITY_COST_PER_CRAFT threshold", () => { + const lathe = AncientRunicGlassEngravingLatheEngine.constructLathe("engraver_04", "CEDAR_GLASS_ENGRAVING_LATHE"); + lathe.currentDurability = 0; + lathe.isFunctional = false; + + // Maintain 5 (below 10 required) -> isFunctional remains false + const repLow = AncientRunicGlassEngravingLatheEngine.maintainLathe(lathe, 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 = AncientRunicGlassEngravingLatheEngine.maintainLathe(lathe, 10); + expect(repHigh.success).toBe(true); + expect(repHigh.newDurability).toBe(15); + expect(repHigh.isFunctional).toBe(true); + }); + + it("guards against null inputs and unsupported lathe models", () => { + expect(() => AncientRunicGlassEngravingLatheEngine.constructLathe("e", "PLASTIC_LATHE" as any)).toThrow( + "Unsupported engraving lathe type" + ); + + const invalidLathe: ActiveEngravingLathe = { + latheId: "bad", + engraverPlayerId: "p", + latheType: "LATHE" as any, + currentDurability: 50, + maxDurability: 50, + engravingPower: 10, + isFunctional: true, + }; + + expect(AncientRunicGlassEngravingLatheEngine.engraveGlassware(invalidLathe, "CHALICE_OF_SOVEREIGN_VITALITY", ["QUARTZ_CRYSTAL_GOBLET_BLANK", "QUARTZ_CRYSTAL_GOBLET_BLANK"]).success).toBe(false); + expect(AncientRunicGlassEngravingLatheEngine.engraveGlassware(null as any, "CHALICE_OF_SOVEREIGN_VITALITY", []).success).toBe(false); + expect(AncientRunicGlassEngravingLatheEngine.maintainLathe(null as any).success).toBe(false); + }); +}); \ No newline at end of file