export const VIGOR_PER_RANK = 8;
export const BASE_SKILL_COST = 20;
export const SKILL_RANK_COST = 10;
export type SkillId = "energy" | "strength" | "critical" | "poison";
export interface HeroProgression {
version: 1;
vigor: number;
skills: Record<SkillId, number>;
}
export const SKILLS: Record<
SkillId,
{
name: string;
description: string;
effect: string;
maxRank: number;
glyph: string;
}
> = {
energy: {
name: "Aether Well",
description: "Begin every turn with more energy.",
effect: "+1 energy per rank",
maxRank: 2,
glyph: "✦",
},
strength: {
name: "Tempered Might",
description: "Enter each battle with enhanced Strength.",
effect: "+1 starting Strength per rank",
maxRank: 5,
glyph: "↑",
},
critical: {
name: "Fateful Edge",
description: "Attacks can deal double damage.",
effect: "+5% critical chance per rank",
maxRank: 5,
glyph: "✧",
},
poison: {
name: "Venomcraft",
description: "Apply additional poison with every poison card.",
effect: "+1 poison applied per rank",
maxRank: 5,
glyph: "☠",
},
};
export const SKILL_IDS = Object.keys(SKILLS) as SkillId[];
export function initialProgression(): HeroProgression {
return {
version: 1,
vigor: 0,
skills: { energy: 0, strength: 0, critical: 0, poison: 0 },
};
}
function rank(value: unknown, max = 999): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.min(max, Math.floor(value)))
: 0;
}
export function parseProgression(
value: unknown,
level: number,
): HeroProgression {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
parsed = null;
}
}
if (!parsed || typeof parsed !== "object" || !("version" in parsed)) {
return {
...initialProgression(),
// Before attributes existed, every ascension granted one Vigor rank.
vigor: Math.max(0, Math.floor(level) - 1),
};
}
const candidate = parsed as {
version?: unknown;
vigor?: unknown;
skills?: Record<string, unknown>;
};
if (candidate.version !== 1) return initialProgression();
return {
version: 1,
vigor: rank(candidate.vigor),
skills: Object.fromEntries(
SKILL_IDS.map((id) => [
id,
rank(candidate.skills?.[id], SKILLS[id].maxRank),
]),
) as Record<SkillId, number>,
};
}
export function progressionPoints(
level: number,
progression: HeroProgression,
): { attribute: number } {
const earned = Math.max(0, Math.floor(level) - 1);
return {
attribute: Math.max(0, earned - progression.vigor),
};
}
export function skillRankCost(currentRank: number): number {
return BASE_SKILL_COST +
SKILL_RANK_COST * Math.max(0, Math.floor(currentRank));
}
export function levelUpCost(level: number): number {
return 25 + 15 * (Math.max(1, Math.floor(level)) - 1);
}
export function maxVigor(baseHp: number, progression: HeroProgression): number {
return baseHp + progression.vigor * VIGOR_PER_RANK;
}
export function maxEnergy(baseEnergy: number, progression: HeroProgression) {
return baseEnergy + progression.skills.energy;
}
export function criticalChance(progression: HeroProgression): number {
return progression.skills.critical * 5 / 100;
}
export function progressionChange(
previous: HeroProgression,
next: HeroProgression,
): "unchanged" | "attribute" | "skill" | "invalid" {
const vigorDelta = next.vigor - previous.vigor;
const changedSkills = SKILL_IDS.filter((id) =>
next.skills[id] !== previous.skills[id]
);
if (vigorDelta === 0 && changedSkills.length === 0) return "unchanged";
if (vigorDelta === 1 && changedSkills.length === 0) return "attribute";
if (
vigorDelta === 0 && changedSkills.length === 1 &&
next.skills[changedSkills[0]] === previous.skills[changedSkills[0]] + 1
) {
return "skill";
}
return "invalid";
}
export function isValidProgression(
level: number,
progression: HeroProgression,
): boolean {
const earned = Math.max(0, Math.floor(level) - 1);
return progression.vigor <= earned &&
SKILL_IDS.every((id) =>
progression.skills[id] >= 0 &&
progression.skills[id] <= SKILLS[id].maxRank
);
}
export function serializeProgression(progression: HeroProgression): string {
return JSON.stringify(progression);
}