-
Notifications
You must be signed in to change notification settings - Fork 27
feat(alchemy): interactive cauldron brewing, heat control, and potion purity engine #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, BrewingRecipe> = { | ||
| 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; | ||
|
Comment on lines
+147
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Quality: Skill bonus is uncapped and comment overstates the maxThe comment claims "up to +10 bonus purity for master alchemists (skill 100)", but Was this helpful? React with 👍 / 👎 |
||
|
|
||
| // 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; | ||
|
Comment on lines
+158
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Quality: finishBrew ignores recipe.targetHeat, hardcodes OPTIMAL checkThe unheated-brew penalty checks Was this helpful? React with 👍 / 👎 |
||
| } | ||
|
|
||
| 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, | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
|
||
|
gitar-bot[bot] marked this conversation as resolved.
|
||
| 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"); | ||
|
Comment on lines
+32
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.