diff --git a/api/src/lib/alchemyBrewingCauldron.ts b/api/src/lib/alchemyBrewingCauldron.ts new file mode 100644 index 00000000..b46656f0 --- /dev/null +++ b/api/src/lib/alchemyBrewingCauldron.ts @@ -0,0 +1,180 @@ +/** + * Alchemy Brewing Cauldron & Potion Purity Simulation Engine for OpenAO MMORPG. + * Simulates interactive reagent additions, heat control (Underheated, Optimal, Overheated), + * stirring stabilization cycles, skill-scaled purity, and output potion quality tiering. + */ + +export type HeatLevel = "UNDERHEATED" | "OPTIMAL" | "OVERHEATED"; +export type StirDirection = "CLOCKWISE" | "COUNTER_CLOCKWISE"; +export type PotionQualityTier = "PERFECT_ELIXIR" | "STANDARD_POTION" | "DILUTED_BREW" | "RUINED_SLUDGE"; + +export interface BrewingRecipe { + recipeId: string; + resultItemTemplateId: string; + requiredIngredients: string[]; + targetHeat: HeatLevel; + requiredStirCycles: number; + requiredStirDirection: StirDirection; + minAlchemySkill: number; +} + +export interface CauldronSessionState { + recipeId: string; + addedIngredients: string[]; + currentHeat: HeatLevel; + completedStirCycles: number; + lastStirDirection?: StirDirection; + heatExposureTicks: { + optimal: number; + underheated: number; + overheated: number; + }; +} + +export interface BrewCompletionResult { + success: boolean; + resultItemTemplateId?: string; + qualityTier: PotionQualityTier; + purityPercent: number; // 0 to 100 + reason?: string; +} + +export const ALCHEMY_RECIPES: Record = { + greater_mana_potion: { + recipeId: "greater_mana_potion", + resultItemTemplateId: "potion_greater_mana", + requiredIngredients: ["moonflower_petal", "silver_leaf", "crystal_water"], + targetHeat: "OPTIMAL", + requiredStirCycles: 3, + requiredStirDirection: "CLOCKWISE", + minAlchemySkill: 35, + }, + elixir_of_strength: { + recipeId: "elixir_of_strength", + resultItemTemplateId: "potion_elixir_strength", + requiredIngredients: ["ogre_blood", "mountain_sage", "dragon_scale_dust"], + targetHeat: "OPTIMAL", + requiredStirCycles: 5, + requiredStirDirection: "COUNTER_CLOCKWISE", + minAlchemySkill: 60, + }, +}; + +export class AlchemyBrewingCauldronEngine { + /** + * Creates a new brewing cauldron session for a recipe. + */ + public static startSession(recipeId: string): CauldronSessionState | null { + if (!ALCHEMY_RECIPES[recipeId]) return null; + return { + recipeId, + addedIngredients: [], + currentHeat: "UNDERHEATED", + completedStirCycles: 0, + heatExposureTicks: { optimal: 0, underheated: 0, overheated: 0 }, + }; + } + + /** + * Adds an ingredient into the cauldron. + */ + public static addIngredient(session: CauldronSessionState, ingredientId: string): void { + session.addedIngredients.push(ingredientId); + } + + /** + * Adjusts heat level and records tick exposure. + */ + public static adjustHeat(session: CauldronSessionState, newHeat: HeatLevel, durationTicks = 1): void { + session.currentHeat = newHeat; + if (newHeat === "OPTIMAL") session.heatExposureTicks.optimal += durationTicks; + else if (newHeat === "UNDERHEATED") session.heatExposureTicks.underheated += durationTicks; + else session.heatExposureTicks.overheated += durationTicks; + } + + /** + * Performs a stirring action. + */ + public static stir(session: CauldronSessionState, direction: StirDirection, cycles = 1): void { + session.lastStirDirection = direction; + session.completedStirCycles += cycles; + } + + /** + * Finalizes brewing and calculates output purity. + */ + public static finishBrew( + session: CauldronSessionState, + playerAlchemySkill: number + ): BrewCompletionResult { + const recipe = ALCHEMY_RECIPES[session.recipeId]; + if (!recipe) { + return { + success: false, + qualityTier: "RUINED_SLUDGE", + purityPercent: 0, + reason: "Invalid recipe.", + }; + } + + const skill = Math.min(100, Math.max(1, playerAlchemySkill)); + if (skill < recipe.minAlchemySkill) { + return { + success: false, + qualityTier: "RUINED_SLUDGE", + purityPercent: 10, + reason: `Insufficient Alchemy skill. Requires level ${recipe.minAlchemySkill}.`, + }; + } + + // Check ingredients exact match (ignoring order) + const sortedAdded = [...session.addedIngredients].sort(); + const sortedRequired = [...recipe.requiredIngredients].sort(); + const hasAllIngredients = + sortedAdded.length === sortedRequired.length && + sortedAdded.every((item, idx) => item === sortedRequired[idx]); + + if (!hasAllIngredients) { + return { + success: false, + qualityTier: "RUINED_SLUDGE", + purityPercent: 0, + reason: "Incorrect or missing ingredients in the cauldron.", + }; + } + + // Calculate Purity score: Base starts with skill modifier + // Skill gives up to +10 bonus purity for master alchemists (skill 100) vs novice + const skillBonus = Math.floor((skill - recipe.minAlchemySkill) / 5); + let purity = 100 + skillBonus; + + // Stirring accuracy penalty + if (session.lastStirDirection !== recipe.requiredStirDirection) { + purity -= 25; + } + const stirDiff = Math.abs(session.completedStirCycles - recipe.requiredStirCycles); + purity -= stirDiff * 10; + + // Heat penalty: If optimal heat was NEVER applied, heavily penalize cold brew (-50) + if (session.heatExposureTicks.optimal === 0) { + purity -= 50; + } + + const badHeatTicks = session.heatExposureTicks.overheated * 15 + session.heatExposureTicks.underheated * 5; + purity -= badHeatTicks; + + purity = Math.min(100, Math.max(0, purity)); + + let qualityTier: PotionQualityTier = "RUINED_SLUDGE"; + if (purity >= 90) qualityTier = "PERFECT_ELIXIR"; + else if (purity >= 70) qualityTier = "STANDARD_POTION"; + else if (purity >= 40) qualityTier = "DILUTED_BREW"; + + return { + success: purity >= 40, + resultItemTemplateId: purity >= 40 ? recipe.resultItemTemplateId : undefined, + qualityTier, + purityPercent: purity, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/alchemyBrewingCauldron.test.ts b/api/src/tests/alchemyBrewingCauldron.test.ts new file mode 100644 index 00000000..252f7111 --- /dev/null +++ b/api/src/tests/alchemyBrewingCauldron.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { AlchemyBrewingCauldronEngine } from "../lib/alchemyBrewingCauldron.js"; + +describe("AlchemyBrewingCauldronEngine Recipe Cooking & Purity Rating", () => { + it("brews a PERFECT_ELIXIR under optimal stirring and temperature", () => { + const session = AlchemyBrewingCauldronEngine.startSession("greater_mana_potion")!; + expect(session).toBeDefined(); + + AlchemyBrewingCauldronEngine.addIngredient(session, "moonflower_petal"); + AlchemyBrewingCauldronEngine.addIngredient(session, "silver_leaf"); + AlchemyBrewingCauldronEngine.addIngredient(session, "crystal_water"); + + AlchemyBrewingCauldronEngine.adjustHeat(session, "OPTIMAL", 5); + AlchemyBrewingCauldronEngine.stir(session, "CLOCKWISE", 3); + + const result = AlchemyBrewingCauldronEngine.finishBrew(session, 50); + expect(result.success).toBe(true); + expect(result.qualityTier).toBe("PERFECT_ELIXIR"); + expect(result.purityPercent).toBe(100); + expect(result.resultItemTemplateId).toBe("potion_greater_mana"); + }); + + it("ruins the brew into sludge if ingredients are missing or wrong", () => { + const session = AlchemyBrewingCauldronEngine.startSession("greater_mana_potion")!; + AlchemyBrewingCauldronEngine.addIngredient(session, "moonflower_petal"); + + const result = AlchemyBrewingCauldronEngine.finishBrew(session, 50); + expect(result.success).toBe(false); + expect(result.qualityTier).toBe("RUINED_SLUDGE"); + expect(result.reason).toContain("Incorrect or missing ingredients"); + }); + + it("rejects brewing when player lacks required alchemy skill", () => { + const session = AlchemyBrewingCauldronEngine.startSession("elixir_of_strength")!; // Requires 60 + AlchemyBrewingCauldronEngine.addIngredient(session, "ogre_blood"); + AlchemyBrewingCauldronEngine.addIngredient(session, "mountain_sage"); + AlchemyBrewingCauldronEngine.addIngredient(session, "dragon_scale_dust"); + + const result = AlchemyBrewingCauldronEngine.finishBrew(session, 30); // Skill only 30 + expect(result.success).toBe(false); + expect(result.purityPercent).toBe(10); + expect(result.reason).toContain("Insufficient Alchemy skill"); + }); + + it("handles null session for unknown recipes and finishBrew invalid session", () => { + const nullSession = AlchemyBrewingCauldronEngine.startSession("non_existent_recipe"); + expect(nullSession).toBeNull(); + + const fakeSession = { + recipeId: "unknown_id", + addedIngredients: [], + currentHeat: "UNDERHEATED" as const, + completedStirCycles: 0, + heatExposureTicks: { optimal: 0, underheated: 0, overheated: 0 }, + }; + const invalidRes = AlchemyBrewingCauldronEngine.finishBrew(fakeSession, 50); + expect(invalidRes.success).toBe(false); + expect(invalidRes.reason).toBe("Invalid recipe."); + }); + + it("brews elixir_of_strength with counter-clockwise stirring and optimal heat", () => { + const session = AlchemyBrewingCauldronEngine.startSession("elixir_of_strength")!; + AlchemyBrewingCauldronEngine.addIngredient(session, "ogre_blood"); + AlchemyBrewingCauldronEngine.addIngredient(session, "mountain_sage"); + AlchemyBrewingCauldronEngine.addIngredient(session, "dragon_scale_dust"); + + AlchemyBrewingCauldronEngine.adjustHeat(session, "OPTIMAL", 3); + AlchemyBrewingCauldronEngine.stir(session, "COUNTER_CLOCKWISE", 5); + + const result = AlchemyBrewingCauldronEngine.finishBrew(session, 80); + expect(result.success).toBe(true); + expect(result.qualityTier).toBe("PERFECT_ELIXIR"); + expect(result.resultItemTemplateId).toBe("potion_elixir_strength"); + }); + + it("penalizes unheated cold brew even if stirring is correct", () => { + const session = AlchemyBrewingCauldronEngine.startSession("greater_mana_potion")!; + AlchemyBrewingCauldronEngine.addIngredient(session, "moonflower_petal"); + AlchemyBrewingCauldronEngine.addIngredient(session, "silver_leaf"); + AlchemyBrewingCauldronEngine.addIngredient(session, "crystal_water"); + + // Stirred correctly but ZERO heat ever applied + AlchemyBrewingCauldronEngine.stir(session, "CLOCKWISE", 3); + + const result = AlchemyBrewingCauldronEngine.finishBrew(session, 50); + // Base 100 + 3 skill - 50 unheated penalty = 53% -> DILUTED_BREW (not perfect elixir!) + expect(result.qualityTier).toBe("DILUTED_BREW"); + expect(result.purityPercent).toBe(53); + }); +}); \ No newline at end of file