From 090456f720f629952a4eba057366725cea46da47 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:01:09 +0200 Subject: [PATCH 1/2] feat(alchemy): add alchemical tincture infusions, solvent bases, and toxicity meter engine --- api/src/lib/alchemyTinctureInfusion.ts | 183 ++++++++++++++++++ api/src/tests/alchemyTinctureInfusion.test.ts | 86 ++++++++ 2 files changed, 269 insertions(+) create mode 100644 api/src/lib/alchemyTinctureInfusion.ts create mode 100644 api/src/tests/alchemyTinctureInfusion.test.ts diff --git a/api/src/lib/alchemyTinctureInfusion.ts b/api/src/lib/alchemyTinctureInfusion.ts new file mode 100644 index 00000000..b1f28a06 --- /dev/null +++ b/api/src/lib/alchemyTinctureInfusion.ts @@ -0,0 +1,183 @@ +/** + * Alchemical Herb Infusion, Solvent Bases & Toxicity Meter Engine for OpenAO MMORPG. + * Simulates steeping rare botanical reagents in solvent bases, computing tincture potencies, + * tracking player bloodstream toxicity buildup, and administering herbal antidotes. + */ + +export type HerbReagent = "MANDRAKE_ROOT" | "STARLIGHT_LOTUS" | "NIGHTSHADE_PETAL" | "DRAGON_FIRE_LEAF"; +export type SolventBase = "SPRING_WATER" | "MOONWELL_DEW" | "VOLATILE_SPIRITS"; + +export interface TinctureRecipe { + recipeId: string; + tinctureName: string; + primaryHerb: HerbReagent; + optimalSteepSeconds: number; + basePotency: number; + baseToxicityPoints: number; +} + +export interface InfusionFlaskSession { + sessionId: string; + recipeId: string; + solvent: SolventBase; + herb: HerbReagent; + steepDurationSeconds: number; + alchemistSkill: number; + isBrewed: boolean; +} + +export interface BrewedTincture { + tinctureId: string; + recipeId: string; + potencyPercent: number; // 0 to 100+ + toxicityPoints: number; + isBurned: boolean; +} + +export interface PlayerToxicityState { + playerId: string; + currentToxicity: number; // 0 to 100 + isToxicShock: boolean; +} + +export const TINCTURE_RECIPES: Record = { + tincture_of_clarity: { + recipeId: "tincture_of_clarity", + tinctureName: "Tincture of Clarity", + primaryHerb: "STARLIGHT_LOTUS", + optimalSteepSeconds: 15, + basePotency: 50, + baseToxicityPoints: 10, + }, + tincture_of_berserk: { + recipeId: "tincture_of_berserk", + tinctureName: "Tincture of Berserker Might", + primaryHerb: "DRAGON_FIRE_LEAF", + optimalSteepSeconds: 20, + basePotency: 80, + baseToxicityPoints: 25, + }, +}; + +export const SOLVENT_MODIFIERS: Record = { + SPRING_WATER: { potencyMultiplier: 1.0, addedToxicity: 0 }, + MOONWELL_DEW: { potencyMultiplier: 1.4, addedToxicity: 5 }, + VOLATILE_SPIRITS: { potencyMultiplier: 1.8, addedToxicity: 20 }, +}; + +export class AlchemyTinctureInfusionEngine { + public static readonly MAX_TOXICITY_THRESHOLD = 100; + + /** + * Brews a tincture flask session into a final potency tincture. + */ + public static brewTincture( + session: InfusionFlaskSession + ): { success: boolean; tincture?: BrewedTincture; reason?: string } { + if (!session || !session.recipeId) { + return { success: false, reason: "Invalid brewing session." }; + } + + const recipe = TINCTURE_RECIPES[session.recipeId]; + if (!recipe) { + return { success: false, reason: `Unknown recipe: ${session.recipeId}` }; + } + + if (session.herb !== recipe.primaryHerb) { + return { success: false, reason: `Mismatch herb! ${recipe.tinctureName} requires ${recipe.primaryHerb}.` }; + } + + const solventMod = SOLVENT_MODIFIERS[session.solvent]; + if (!solventMod) { + return { success: false, reason: `Unsupported solvent: ${String(session.solvent)}` }; + } + + const steepTime = Number.isFinite(session.steepDurationSeconds) ? Math.max(0, session.steepDurationSeconds) : 0; + const optimalTime = recipe.optimalSteepSeconds; + const timeDiff = Math.abs(steepTime - optimalTime); + + // If steeped more than 2x optimal time, tincture burns + const isBurned = steepTime > optimalTime * 2; + + let timeFactor = 1.0; + if (isBurned) { + timeFactor = 0.20; // Burned ruins potency + } else if (timeDiff > 0) { + timeFactor = Math.max(0.30, 1.0 - (timeDiff / optimalTime) * 0.50); + } + + const skill = Math.min(100, Math.max(1, Number.isFinite(session.alchemistSkill) ? session.alchemistSkill : 1)); + const skillFactor = 1.0 + (skill / 100) * 0.30; + + const finalPotency = Math.floor(recipe.basePotency * solventMod.potencyMultiplier * timeFactor * skillFactor); + const finalToxicity = recipe.baseToxicityPoints + solventMod.addedToxicity; + + const tincture: BrewedTincture = { + tinctureId: `tincture_${session.recipeId}_${Date.now()}`, + recipeId: recipe.recipeId, + potencyPercent: finalPotency, + toxicityPoints: finalToxicity, + isBurned, + }; + + session.isBrewed = true; + + return { + success: true, + tincture, + }; + } + + /** + * Simulates a player consuming a tincture, accumulating toxicity. + */ + public static consumeTincture( + player: PlayerToxicityState, + tincture: BrewedTincture + ): { success: boolean; toxicityAdded: number; newToxicity: number; isToxicShock: boolean } { + if (!player || !tincture) { + return { success: false, toxicityAdded: 0, newToxicity: player?.currentToxicity ?? 0, isToxicShock: player?.isToxicShock ?? false }; + } + + const toxicityIncrease = Number.isFinite(tincture.toxicityPoints) ? Math.max(0, tincture.toxicityPoints) : 0; + player.currentToxicity = Math.min(this.MAX_TOXICITY_THRESHOLD + 20, player.currentToxicity + toxicityIncrease); + + if (player.currentToxicity >= this.MAX_TOXICITY_THRESHOLD) { + player.isToxicShock = true; + } + + return { + success: true, + toxicityAdded: toxicityIncrease, + newToxicity: player.currentToxicity, + isToxicShock: player.isToxicShock, + }; + } + + /** + * Administers an herbal antidote, purging bloodstream toxicity. + */ + public static applyAntidote( + player: PlayerToxicityState, + cleanseAmount = 40 + ): { success: boolean; toxicityPurged: number; remainingToxicity: number; shockCleared: boolean } { + if (!player) { + return { success: false, toxicityPurged: 0, remainingToxicity: 0, shockCleared: false }; + } + + const cleanse = Number.isFinite(cleanseAmount) ? Math.max(0, Math.floor(cleanseAmount)) : 0; + const actualPurged = Math.min(player.currentToxicity, cleanse); + + player.currentToxicity = Math.max(0, player.currentToxicity - actualPurged); + if (player.currentToxicity < this.MAX_TOXICITY_THRESHOLD) { + player.isToxicShock = false; + } + + return { + success: true, + toxicityPurged: actualPurged, + remainingToxicity: player.currentToxicity, + shockCleared: !player.isToxicShock, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/alchemyTinctureInfusion.test.ts b/api/src/tests/alchemyTinctureInfusion.test.ts new file mode 100644 index 00000000..a7ba5187 --- /dev/null +++ b/api/src/tests/alchemyTinctureInfusion.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { + AlchemyTinctureInfusionEngine, + InfusionFlaskSession, + PlayerToxicityState, +} from "../lib/alchemyTinctureInfusion.js"; + +describe("AlchemyTinctureInfusionEngine Brewing, Potency & Toxicity Mechanics", () => { + it("brews Tincture of Clarity in Moonwell Dew with high skill bonus", () => { + const session: InfusionFlaskSession = { + sessionId: "sess_01", + recipeId: "tincture_of_clarity", + solvent: "MOONWELL_DEW", + herb: "STARLIGHT_LOTUS", + steepDurationSeconds: 15, // Optimal + alchemistSkill: 100, // Max skill + isBrewed: false, + }; + + const brewRes = AlchemyTinctureInfusionEngine.brewTincture(session); + expect(brewRes.success).toBe(true); + expect(brewRes.tincture?.isBurned).toBe(false); + // Base 50 * 1.4 solvent * 1.0 time * 1.30 skill = 91% potency + expect(brewRes.tincture?.potencyPercent).toBe(91); + expect(brewRes.tincture?.toxicityPoints).toBe(15); // 10 base + 5 moonwell + }); + + it("penalizes over-steeped burned tinctures", () => { + const burnedSession: InfusionFlaskSession = { + sessionId: "sess_02", + recipeId: "tincture_of_clarity", + solvent: "SPRING_WATER", + herb: "STARLIGHT_LOTUS", + steepDurationSeconds: 40, // > 30s (2x optimal 15) -> Burned + alchemistSkill: 20, + isBrewed: false, + }; + + const res = AlchemyTinctureInfusionEngine.brewTincture(burnedSession); + expect(res.success).toBe(true); + expect(res.tincture?.isBurned).toBe(true); + expect(res.tincture?.potencyPercent).toBeLessThan(15); + }); + + it("accumulates toxicity and triggers toxic shock at threshold", () => { + const player: PlayerToxicityState = { playerId: "hero_1", currentToxicity: 85, isToxicShock: false }; + const toxicTincture = { + tinctureId: "t_1", + recipeId: "tincture_of_berserk", + potencyPercent: 80, + toxicityPoints: 25, + isBurned: false, + }; + + // 85 + 25 = 110 (>= 100 threshold) -> Toxic shock + const consumeRes = AlchemyTinctureInfusionEngine.consumeTincture(player, toxicTincture); + expect(consumeRes.isToxicShock).toBe(true); + expect(player.currentToxicity).toBe(110); + }); + + it("purges toxicity and clears toxic shock with antidote", () => { + const player: PlayerToxicityState = { playerId: "hero_1", currentToxicity: 105, isToxicShock: true }; + + const antidoteRes = AlchemyTinctureInfusionEngine.applyAntidote(player, 50); + expect(antidoteRes.success).toBe(true); + expect(antidoteRes.remainingToxicity).toBe(55); + expect(antidoteRes.shockCleared).toBe(true); + expect(player.isToxicShock).toBe(false); + }); + + it("guards defensively against herb mismatch and invalid sessions", () => { + const mismatchSession: InfusionFlaskSession = { + sessionId: "sess_03", + recipeId: "tincture_of_clarity", + solvent: "SPRING_WATER", + herb: "NIGHTSHADE_PETAL", // Wrong herb + steepDurationSeconds: 15, + alchemistSkill: 50, + isBrewed: false, + }; + + const res = AlchemyTinctureInfusionEngine.brewTincture(mismatchSession); + expect(res.success).toBe(false); + expect(res.reason).toContain("Mismatch herb"); + }); +}); \ No newline at end of file From edd2b99d593226dcb85df2c48dc23a477d454b41 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:00:20 +0200 Subject: [PATCH 2/2] fix(alchemy): clamp toxicity to 100 max, track shock state transition, and add unique suffix to tincture ID --- api/src/lib/alchemyTinctureInfusion.ts | 16 +++++--- api/src/tests/alchemyTinctureInfusion.test.ts | 37 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/api/src/lib/alchemyTinctureInfusion.ts b/api/src/lib/alchemyTinctureInfusion.ts index b1f28a06..e2b86ef4 100644 --- a/api/src/lib/alchemyTinctureInfusion.ts +++ b/api/src/lib/alchemyTinctureInfusion.ts @@ -69,7 +69,7 @@ export class AlchemyTinctureInfusionEngine { public static readonly MAX_TOXICITY_THRESHOLD = 100; /** - * Brews a tincture flask session into a final potency tincture. + * Brews a tincture flask session into a final potency tincture with unique IDs. */ public static brewTincture( session: InfusionFlaskSession @@ -112,8 +112,9 @@ export class AlchemyTinctureInfusionEngine { const finalPotency = Math.floor(recipe.basePotency * solventMod.potencyMultiplier * timeFactor * skillFactor); const finalToxicity = recipe.baseToxicityPoints + solventMod.addedToxicity; + const uniqueSuffix = Math.random().toString(36).substring(2, 8); const tincture: BrewedTincture = { - tinctureId: `tincture_${session.recipeId}_${Date.now()}`, + tinctureId: `tincture_${session.recipeId}_${Date.now()}_${uniqueSuffix}`, recipeId: recipe.recipeId, potencyPercent: finalPotency, toxicityPoints: finalToxicity, @@ -129,7 +130,7 @@ export class AlchemyTinctureInfusionEngine { } /** - * Simulates a player consuming a tincture, accumulating toxicity. + * Simulates a player consuming a tincture, accumulating toxicity up to max 100. */ public static consumeTincture( player: PlayerToxicityState, @@ -140,7 +141,7 @@ export class AlchemyTinctureInfusionEngine { } const toxicityIncrease = Number.isFinite(tincture.toxicityPoints) ? Math.max(0, tincture.toxicityPoints) : 0; - player.currentToxicity = Math.min(this.MAX_TOXICITY_THRESHOLD + 20, player.currentToxicity + toxicityIncrease); + player.currentToxicity = Math.min(this.MAX_TOXICITY_THRESHOLD, player.currentToxicity + toxicityIncrease); if (player.currentToxicity >= this.MAX_TOXICITY_THRESHOLD) { player.isToxicShock = true; @@ -155,7 +156,7 @@ export class AlchemyTinctureInfusionEngine { } /** - * Administers an herbal antidote, purging bloodstream toxicity. + * Administers an herbal antidote, purging bloodstream toxicity and tracking state transition. */ public static applyAntidote( player: PlayerToxicityState, @@ -165,6 +166,7 @@ export class AlchemyTinctureInfusionEngine { return { success: false, toxicityPurged: 0, remainingToxicity: 0, shockCleared: false }; } + const hadShock = player.isToxicShock; const cleanse = Number.isFinite(cleanseAmount) ? Math.max(0, Math.floor(cleanseAmount)) : 0; const actualPurged = Math.min(player.currentToxicity, cleanse); @@ -173,11 +175,13 @@ export class AlchemyTinctureInfusionEngine { player.isToxicShock = false; } + const shockCleared = hadShock && !player.isToxicShock; + return { success: true, toxicityPurged: actualPurged, remainingToxicity: player.currentToxicity, - shockCleared: !player.isToxicShock, + shockCleared, }; } } \ No newline at end of file diff --git a/api/src/tests/alchemyTinctureInfusion.test.ts b/api/src/tests/alchemyTinctureInfusion.test.ts index a7ba5187..22e70b8a 100644 --- a/api/src/tests/alchemyTinctureInfusion.test.ts +++ b/api/src/tests/alchemyTinctureInfusion.test.ts @@ -6,23 +6,23 @@ import { } from "../lib/alchemyTinctureInfusion.js"; describe("AlchemyTinctureInfusionEngine Brewing, Potency & Toxicity Mechanics", () => { - it("brews Tincture of Clarity in Moonwell Dew with high skill bonus", () => { + it("brews Tincture of Clarity in Moonwell Dew with unique ID and high skill bonus", () => { const session: InfusionFlaskSession = { sessionId: "sess_01", recipeId: "tincture_of_clarity", solvent: "MOONWELL_DEW", herb: "STARLIGHT_LOTUS", - steepDurationSeconds: 15, // Optimal - alchemistSkill: 100, // Max skill + steepDurationSeconds: 15, + alchemistSkill: 100, isBrewed: false, }; const brewRes = AlchemyTinctureInfusionEngine.brewTincture(session); expect(brewRes.success).toBe(true); expect(brewRes.tincture?.isBurned).toBe(false); - // Base 50 * 1.4 solvent * 1.0 time * 1.30 skill = 91% potency expect(brewRes.tincture?.potencyPercent).toBe(91); - expect(brewRes.tincture?.toxicityPoints).toBe(15); // 10 base + 5 moonwell + expect(brewRes.tincture?.toxicityPoints).toBe(15); + expect(brewRes.tincture?.tinctureId).toContain("tincture_tincture_of_clarity_"); }); it("penalizes over-steeped burned tinctures", () => { @@ -31,7 +31,7 @@ describe("AlchemyTinctureInfusionEngine Brewing, Potency & Toxicity Mechanics", recipeId: "tincture_of_clarity", solvent: "SPRING_WATER", herb: "STARLIGHT_LOTUS", - steepDurationSeconds: 40, // > 30s (2x optimal 15) -> Burned + steepDurationSeconds: 40, alchemistSkill: 20, isBrewed: false, }; @@ -42,7 +42,7 @@ describe("AlchemyTinctureInfusionEngine Brewing, Potency & Toxicity Mechanics", expect(res.tincture?.potencyPercent).toBeLessThan(15); }); - it("accumulates toxicity and triggers toxic shock at threshold", () => { + it("accumulates toxicity clamped to max 100 and triggers toxic shock", () => { const player: PlayerToxicityState = { playerId: "hero_1", currentToxicity: 85, isToxicShock: false }; const toxicTincture = { tinctureId: "t_1", @@ -52,20 +52,25 @@ describe("AlchemyTinctureInfusionEngine Brewing, Potency & Toxicity Mechanics", isBurned: false, }; - // 85 + 25 = 110 (>= 100 threshold) -> Toxic shock const consumeRes = AlchemyTinctureInfusionEngine.consumeTincture(player, toxicTincture); expect(consumeRes.isToxicShock).toBe(true); - expect(player.currentToxicity).toBe(110); + expect(player.currentToxicity).toBe(100); // Clamped strictly to 100 }); - it("purges toxicity and clears toxic shock with antidote", () => { - const player: PlayerToxicityState = { playerId: "hero_1", currentToxicity: 105, isToxicShock: true }; + it("purges toxicity and accurately tracks shockCleared state transition", () => { + const playerInShock: PlayerToxicityState = { playerId: "hero_1", currentToxicity: 100, isToxicShock: true }; - const antidoteRes = AlchemyTinctureInfusionEngine.applyAntidote(player, 50); + const antidoteRes = AlchemyTinctureInfusionEngine.applyAntidote(playerInShock, 50); expect(antidoteRes.success).toBe(true); - expect(antidoteRes.remainingToxicity).toBe(55); - expect(antidoteRes.shockCleared).toBe(true); - expect(player.isToxicShock).toBe(false); + expect(antidoteRes.remainingToxicity).toBe(50); + expect(antidoteRes.shockCleared).toBe(true); // Shock transitioned from true to false + expect(playerInShock.isToxicShock).toBe(false); + + // Applying antidote when player has no shock returns shockCleared: false + const playerNoShock: PlayerToxicityState = { playerId: "hero_2", currentToxicity: 30, isToxicShock: false }; + const res2 = AlchemyTinctureInfusionEngine.applyAntidote(playerNoShock, 20); + expect(res2.shockCleared).toBe(false); + expect(playerNoShock.currentToxicity).toBe(10); }); it("guards defensively against herb mismatch and invalid sessions", () => { @@ -73,7 +78,7 @@ describe("AlchemyTinctureInfusionEngine Brewing, Potency & Toxicity Mechanics", sessionId: "sess_03", recipeId: "tincture_of_clarity", solvent: "SPRING_WATER", - herb: "NIGHTSHADE_PETAL", // Wrong herb + herb: "NIGHTSHADE_PETAL", steepDurationSeconds: 15, alchemistSkill: 50, isBrewed: false,