Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions api/src/lib/alchemyBrewingCauldron.ts
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 &&
Comment thread
gitar-bot[bot] marked this conversation as resolved.
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Skill bonus is uncapped and comment overstates the max

The comment claims "up to +10 bonus purity for master alchemists (skill 100)", but Math.floor((skill - recipe.minAlchemySkill) / 5) yields +13 for greater_mana_potion (minSkill 35) and +8 for elixir_of_strength (minSkill 60) at skill 100, so the bonus is neither capped at 10 nor consistent across recipes. Either clamp the bonus (e.g. Math.min(10, ...)) or fix the comment to match the actual formula.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: finishBrew ignores recipe.targetHeat, hardcodes OPTIMAL check

The unheated-brew penalty checks heatExposureTicks.optimal === 0 regardless of the recipe's targetHeat field, which is otherwise never read. Both current recipes target OPTIMAL so there is no visible bug, but any future recipe with a non-OPTIMAL targetHeat would be scored against the wrong heat band. Drive the penalty off recipe.targetHeat to keep the field meaningful.

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,
};
}
}
90 changes: 90 additions & 0 deletions api/src/tests/alchemyBrewingCauldron.test.ts
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);

Comment thread
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Quality: Commit drops test coverage for heat/stir-direction penalties

The removed test ("downgrades purity if cauldron overheats and stirring direction is reversed") was the only case exercising the overheated/underheated badHeatTicks penalty (L163-164) and the wrong-requiredStirDirection -25 penalty (L152-154). Its replacements all use the correct stir direction and either optimal or zero heat, so those two penalty branches are now completely untested and regressions in the core purity math would pass CI. Add a test that overheats/underheats and reverses stir direction to assert the expected purity reduction.

Was this helpful? React with 👍 / 👎

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);
});
});