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
187 changes: 187 additions & 0 deletions api/src/lib/alchemyTinctureInfusion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* 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;
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

export const TINCTURE_RECIPES: Record<string, TinctureRecipe> = {
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<SolventBase, { potencyMultiplier: number; addedToxicity: number }> = {
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 with unique IDs.
*/
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 uniqueSuffix = Math.random().toString(36).substring(2, 8);
const tincture: BrewedTincture = {
tinctureId: `tincture_${session.recipeId}_${Date.now()}_${uniqueSuffix}`,
recipeId: recipe.recipeId,
potencyPercent: finalPotency,
toxicityPoints: finalToxicity,
isBurned,
};

session.isBrewed = true;

return {
success: true,
tincture,
};
}

/**
* Simulates a player consuming a tincture, accumulating toxicity up to max 100.
*/
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, 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 and tracking state transition.
*/
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 hadShock = player.isToxicShock;
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;
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

const shockCleared = hadShock && !player.isToxicShock;

return {
success: true,
toxicityPurged: actualPurged,
remainingToxicity: player.currentToxicity,
shockCleared,
};
}
}
91 changes: 91 additions & 0 deletions api/src/tests/alchemyTinctureInfusion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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 unique ID and high skill bonus", () => {
const session: InfusionFlaskSession = {
sessionId: "sess_01",
recipeId: "tincture_of_clarity",
solvent: "MOONWELL_DEW",
herb: "STARLIGHT_LOTUS",
steepDurationSeconds: 15,
alchemistSkill: 100,
isBrewed: false,
};

const brewRes = AlchemyTinctureInfusionEngine.brewTincture(session);
expect(brewRes.success).toBe(true);
expect(brewRes.tincture?.isBurned).toBe(false);
expect(brewRes.tincture?.potencyPercent).toBe(91);
expect(brewRes.tincture?.toxicityPoints).toBe(15);
expect(brewRes.tincture?.tinctureId).toContain("tincture_tincture_of_clarity_");
});

it("penalizes over-steeped burned tinctures", () => {
const burnedSession: InfusionFlaskSession = {
sessionId: "sess_02",
recipeId: "tincture_of_clarity",
solvent: "SPRING_WATER",
herb: "STARLIGHT_LOTUS",
steepDurationSeconds: 40,
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 clamped to max 100 and triggers toxic shock", () => {
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,
};

const consumeRes = AlchemyTinctureInfusionEngine.consumeTincture(player, toxicTincture);
expect(consumeRes.isToxicShock).toBe(true);
expect(player.currentToxicity).toBe(100); // Clamped strictly to 100
});

it("purges toxicity and accurately tracks shockCleared state transition", () => {
const playerInShock: PlayerToxicityState = { playerId: "hero_1", currentToxicity: 100, isToxicShock: true };

const antidoteRes = AlchemyTinctureInfusionEngine.applyAntidote(playerInShock, 50);
expect(antidoteRes.success).toBe(true);
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", () => {
const mismatchSession: InfusionFlaskSession = {
sessionId: "sess_03",
recipeId: "tincture_of_clarity",
solvent: "SPRING_WATER",
herb: "NIGHTSHADE_PETAL",
steepDurationSeconds: 15,
alchemistSkill: 50,
isBrewed: false,
};

const res = AlchemyTinctureInfusionEngine.brewTincture(mismatchSession);
expect(res.success).toBe(false);
expect(res.reason).toContain("Mismatch herb");
});
});