diff --git a/client/src/components/GearPanel.tsx b/client/src/components/GearPanel.tsx
index f967d23..0df7209 100644
--- a/client/src/components/GearPanel.tsx
+++ b/client/src/components/GearPanel.tsx
@@ -1,4 +1,5 @@
import type { Character, GameItem } from "../types";
+import { getTalentGearSlotsBonus } from "../utils/talent-effects";
import GearList from "./GearList";
import styles from "./GearPanel.module.css";
@@ -27,6 +28,8 @@ export default function GearPanel({
onCurrencyChange,
}: GearPanelProps) {
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
+ const slotsMax =
+ character.gear_slots_max + getTalentGearSlotsBonus(character);
return (
@@ -36,7 +39,7 @@ export default function GearPanel({
sp={character.sp}
cp={character.cp}
slotsUsed={slotsUsed}
- slotsMax={character.gear_slots_max}
+ slotsMax={slotsMax}
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
onAddCustom={(data) => onAddGearCustom(character.id, data)}
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
diff --git a/client/src/components/StatBlock.module.css b/client/src/components/StatBlock.module.css
index dd8d76f..2543972 100644
--- a/client/src/components/StatBlock.module.css
+++ b/client/src/components/StatBlock.module.css
@@ -47,6 +47,11 @@
margin-top: 0.15rem;
}
+.bonus {
+ color: #4caf50;
+ font-size: 0.65rem;
+}
+
.btn {
width: 22px;
height: 22px;
diff --git a/client/src/components/StatBlock.tsx b/client/src/components/StatBlock.tsx
index 1e265e6..c96b3fa 100644
--- a/client/src/components/StatBlock.tsx
+++ b/client/src/components/StatBlock.tsx
@@ -7,6 +7,7 @@ const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
interface StatBlockProps {
stats: Stat[];
+ statBonuses?: Map
;
onStatChange: (statName: string, newValue: number) => void;
mode?: "view" | "edit";
campaignId?: number;
@@ -17,6 +18,7 @@ interface StatBlockProps {
export default function StatBlock({
stats,
+ statBonuses,
onStatChange,
mode = "view",
campaignId,
@@ -29,7 +31,9 @@ export default function StatBlock({
return (
{STAT_ORDER.map((name) => {
- const value = statMap.get(name) ?? 10;
+ const baseValue = statMap.get(name) ?? 10;
+ const bonus = statBonuses?.get(name) ?? 0;
+ const value = baseValue + bonus;
const mod = getModifier(value);
return (
@@ -38,7 +42,7 @@ export default function StatBlock({
{mode === "edit" && (
@@ -58,13 +62,16 @@ export default function StatBlock({
{mode === "edit" && (
)}
-
{value}
+
+ {value}
+ {bonus > 0 && (+{bonus})}
+
);
})}
diff --git a/client/src/components/StatsPanel.tsx b/client/src/components/StatsPanel.tsx
index 97a43e2..c6b3cd1 100644
--- a/client/src/components/StatsPanel.tsx
+++ b/client/src/components/StatsPanel.tsx
@@ -4,6 +4,8 @@ import StatBlock from "./StatBlock";
import AttackBlock from "./AttackBlock";
import styles from "./StatsPanel.module.css";
+const STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
+
interface StatsPanelProps {
character: Character;
mode: "view" | "edit";
@@ -21,11 +23,24 @@ export default function StatsPanel({
}: StatsPanelProps) {
const attacks = generateAttacks(character);
+ // Compute talent stat bonuses
+ const statBonuses = new Map();
+ for (const stat of STATS) {
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (talent.effect.stat_bonus === stat) {
+ bonus += (talent.effect.stat_bonus_value as number) || 0;
+ }
+ }
+ if (bonus > 0) statBonuses.set(stat, bonus);
+ }
+
return (
Ability Scores
0 ? statBonuses : undefined}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
diff --git a/client/src/utils/derived-ac.ts b/client/src/utils/derived-ac.ts
index 820ed3d..c8f31ba 100644
--- a/client/src/utils/derived-ac.ts
+++ b/client/src/utils/derived-ac.ts
@@ -1,5 +1,6 @@
import type { Character } from "../types";
import { getModifier } from "./modifiers";
+import { getEffectiveStat, getTalentAcBonus } from "./talent-effects";
export interface AcBreakdown {
calculated: number;
@@ -9,9 +10,7 @@ export interface AcBreakdown {
}
export function calculateAC(character: Character): AcBreakdown {
- const dexMod = getModifier(
- character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
- );
+ const dexMod = getModifier(getEffectiveStat(character, "DEX"));
let base = 10 + dexMod;
let source = "Unarmored";
@@ -36,6 +35,13 @@ export function calculateAC(character: Character): AcBreakdown {
source += " + " + shield.name;
}
+ // Talent AC bonuses (e.g. Shield of Faith)
+ const talentAcBonus = getTalentAcBonus(character);
+ if (talentAcBonus > 0) {
+ base += talentAcBonus;
+ source += " + talents";
+ }
+
const override = (character.overrides?.ac as number | undefined) ?? null;
return {
diff --git a/client/src/utils/derived-attacks.ts b/client/src/utils/derived-attacks.ts
index cbd23cd..8109307 100644
--- a/client/src/utils/derived-attacks.ts
+++ b/client/src/utils/derived-attacks.ts
@@ -1,13 +1,16 @@
import type { Character, AttackLine } from "../types";
import { getModifier, formatModifier } from "./modifiers";
+import {
+ getEffectiveStat,
+ getTalentAttackBonus,
+ getTalentDamageBonus,
+} from "./talent-effects";
export function generateAttacks(character: Character): AttackLine[] {
- const strMod = getModifier(
- character.stats.find((s) => s.stat_name === "STR")?.value ?? 10,
- );
- const dexMod = getModifier(
- character.stats.find((s) => s.stat_name === "DEX")?.value ?? 10,
- );
+ const strMod = getModifier(getEffectiveStat(character, "STR"));
+ const dexMod = getModifier(getEffectiveStat(character, "DEX"));
+ const talentAttackBonus = getTalentAttackBonus(character);
+ const talentDamageBonus = getTalentDamageBonus(character);
const attacks: AttackLine[] = [];
@@ -15,7 +18,7 @@ export function generateAttacks(character: Character): AttackLine[] {
if (gear.type !== "weapon") continue;
const effects = gear.effects;
- const damage = (effects.damage as string) || "1d4";
+ let damage = (effects.damage as string) || "1d4";
const tags: string[] = [];
let mod: number;
@@ -28,8 +31,14 @@ export function generateAttacks(character: Character): AttackLine[] {
mod = strMod;
}
- const bonus = (effects.bonus as number) || 0;
- mod += bonus;
+ // Gear-specific bonus
+ const gearBonus = (effects.bonus as number) || 0;
+ mod += gearBonus + talentAttackBonus;
+
+ // Talent damage bonus — append to damage expression
+ if (talentDamageBonus > 0) {
+ damage = `${damage}+${talentDamageBonus}`;
+ }
if (effects.two_handed) tags.push("2H");
if (effects.thrown) tags.push("T");
diff --git a/client/src/utils/talent-effects.ts b/client/src/utils/talent-effects.ts
new file mode 100644
index 0000000..9d7453c
--- /dev/null
+++ b/client/src/utils/talent-effects.ts
@@ -0,0 +1,86 @@
+import type { Character } from "../types";
+
+/**
+ * Get the effective stat value after talent bonuses are applied.
+ * e.g. if character has base STR 14 and a talent with { stat_bonus: 'STR', stat_bonus_value: 2 },
+ * this returns 16.
+ */
+export function getEffectiveStat(
+ character: Character,
+ statName: string,
+): number {
+ const base =
+ character.stats.find((s) => s.stat_name === statName)?.value ?? 10;
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (talent.effect.stat_bonus === statName) {
+ bonus += (talent.effect.stat_bonus_value as number) || 0;
+ }
+ }
+ return base + bonus;
+}
+
+/**
+ * Get total AC bonus from talents (e.g. Shield of Faith +1 AC).
+ */
+export function getTalentAcBonus(character: Character): number {
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (typeof talent.effect.ac_bonus === "number") {
+ bonus += talent.effect.ac_bonus;
+ }
+ }
+ return bonus;
+}
+
+/**
+ * Get total attack bonus from talents (e.g. Weapon Mastery +1).
+ */
+export function getTalentAttackBonus(character: Character): number {
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (typeof talent.effect.attack_bonus === "number") {
+ bonus += talent.effect.attack_bonus;
+ }
+ }
+ return bonus;
+}
+
+/**
+ * Get total damage bonus from talents (e.g. Weapon Mastery +1).
+ */
+export function getTalentDamageBonus(character: Character): number {
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (typeof talent.effect.damage_bonus === "number") {
+ bonus += talent.effect.damage_bonus;
+ }
+ }
+ return bonus;
+}
+
+/**
+ * Get extra gear slots from talents (e.g. Hauler +2).
+ */
+export function getTalentGearSlotsBonus(character: Character): number {
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (typeof talent.effect.gear_slots_bonus === "number") {
+ bonus += talent.effect.gear_slots_bonus;
+ }
+ }
+ return bonus;
+}
+
+/**
+ * Get HP bonus from talents (e.g. Grit +2).
+ */
+export function getTalentHpBonus(character: Character): number {
+ let bonus = 0;
+ for (const talent of character.talents) {
+ if (typeof talent.effect.hp_bonus === "number") {
+ bonus += talent.effect.hp_bonus;
+ }
+ }
+ return bonus;
+}