From a54ed07e7bf78b6774ec0dacfaeb463750b88555 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:01:06 +0200 Subject: [PATCH 1/2] feat(alchemy): add interactive brewing cauldron temperature and purity engine --- api/src/lib/alchemyBrewingCauldron.ts | 174 +++++++++++++++++++ api/src/tests/alchemyBrewingCauldron.test.ts | 48 +++++ 2 files changed, 222 insertions(+) create mode 100644 api/src/lib/alchemyBrewingCauldron.ts create mode 100644 api/src/tests/alchemyBrewingCauldron.test.ts diff --git a/api/src/lib/alchemyBrewingCauldron.ts b/api/src/lib/alchemyBrewingCauldron.ts new file mode 100644 index 00000000..85573c6d --- /dev/null +++ b/api/src/lib/alchemyBrewingCauldron.ts @@ -0,0 +1,174 @@ +/** + * Alchemy Brewing Cauldron & Potion Purity Simulation Engine for OpenAO MMORPG. + * Simulates interactive reagent additions, heat control (Underheated, Optimal, Overheated), + * stirring stabilization cycles, and output potion purity 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 100 + let purity = 100; + + // Stirring accuracy penalty + if (session.lastStirDirection !== recipe.requiredStirDirection) { + purity -= 25; + } + const stirDiff = Math.abs(session.completedStirCycles - recipe.requiredStirCycles); + purity -= stirDiff * 10; + + // Heat penalty + 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..c2a47e3f --- /dev/null +++ b/api/src/tests/alchemyBrewingCauldron.test.ts @@ -0,0 +1,48 @@ +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"); // Missing other 2 items + + 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("downgrades purity if cauldron overheats and stirring direction is reversed", () => { + const session = AlchemyBrewingCauldronEngine.startSession("greater_mana_potion")!; + AlchemyBrewingCauldronEngine.addIngredient(session, "moonflower_petal"); + AlchemyBrewingCauldronEngine.addIngredient(session, "silver_leaf"); + AlchemyBrewingCauldronEngine.addIngredient(session, "crystal_water"); + + // Overheat for 2 ticks (-30) + wrong direction (-25) -> purity 45% -> DILUTED_BREW + AlchemyBrewingCauldronEngine.adjustHeat(session, "OVERHEATED", 2); + AlchemyBrewingCauldronEngine.stir(session, "COUNTER_CLOCKWISE", 3); + + const result = AlchemyBrewingCauldronEngine.finishBrew(session, 50); + expect(result.success).toBe(true); + expect(result.qualityTier).toBe("DILUTED_BREW"); + expect(result.purityPercent).toBe(45); + }); +}); \ No newline at end of file From d13f3663df87f5acf75394f371873c98153c3d64 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:08:00 +0200 Subject: [PATCH 2/2] fix(alchemy): penalize unheated brews, factor skill into purity, and expand test suite --- api/src/lib/alchemyBrewingCauldron.ts | 14 +++-- api/src/tests/alchemyBrewingCauldron.test.ts | 56 +++++++++++++++++--- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/api/src/lib/alchemyBrewingCauldron.ts b/api/src/lib/alchemyBrewingCauldron.ts index 85573c6d..b46656f0 100644 --- a/api/src/lib/alchemyBrewingCauldron.ts +++ b/api/src/lib/alchemyBrewingCauldron.ts @@ -1,7 +1,7 @@ /** * Alchemy Brewing Cauldron & Potion Purity Simulation Engine for OpenAO MMORPG. * Simulates interactive reagent additions, heat control (Underheated, Optimal, Overheated), - * stirring stabilization cycles, and output potion purity tiering. + * stirring stabilization cycles, skill-scaled purity, and output potion quality tiering. */ export type HeatLevel = "UNDERHEATED" | "OPTIMAL" | "OVERHEATED"; @@ -143,8 +143,10 @@ export class AlchemyBrewingCauldronEngine { }; } - // Calculate Purity score: Base 100 - let purity = 100; + // 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) { @@ -153,7 +155,11 @@ export class AlchemyBrewingCauldronEngine { const stirDiff = Math.abs(session.completedStirCycles - recipe.requiredStirCycles); purity -= stirDiff * 10; - // Heat penalty + // 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; diff --git a/api/src/tests/alchemyBrewingCauldron.test.ts b/api/src/tests/alchemyBrewingCauldron.test.ts index c2a47e3f..252f7111 100644 --- a/api/src/tests/alchemyBrewingCauldron.test.ts +++ b/api/src/tests/alchemyBrewingCauldron.test.ts @@ -22,7 +22,7 @@ describe("AlchemyBrewingCauldronEngine Recipe Cooking & Purity Rating", () => { it("ruins the brew into sludge if ingredients are missing or wrong", () => { const session = AlchemyBrewingCauldronEngine.startSession("greater_mana_potion")!; - AlchemyBrewingCauldronEngine.addIngredient(session, "moonflower_petal"); // Missing other 2 items + AlchemyBrewingCauldronEngine.addIngredient(session, "moonflower_petal"); const result = AlchemyBrewingCauldronEngine.finishBrew(session, 50); expect(result.success).toBe(false); @@ -30,19 +30,61 @@ describe("AlchemyBrewingCauldronEngine Recipe Cooking & Purity Rating", () => { expect(result.reason).toContain("Incorrect or missing ingredients"); }); - it("downgrades purity if cauldron overheats and stirring direction is reversed", () => { + 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"); - // Overheat for 2 ticks (-30) + wrong direction (-25) -> purity 45% -> DILUTED_BREW - AlchemyBrewingCauldronEngine.adjustHeat(session, "OVERHEATED", 2); - AlchemyBrewingCauldronEngine.stir(session, "COUNTER_CLOCKWISE", 3); + // Stirred correctly but ZERO heat ever applied + AlchemyBrewingCauldronEngine.stir(session, "CLOCKWISE", 3); const result = AlchemyBrewingCauldronEngine.finishBrew(session, 50); - expect(result.success).toBe(true); + // Base 100 + 3 skill - 50 unheated penalty = 53% -> DILUTED_BREW (not perfect elixir!) expect(result.qualityTier).toBe("DILUTED_BREW"); - expect(result.purityPercent).toBe(45); + expect(result.purityPercent).toBe(53); }); }); \ No newline at end of file