Wire up talent effects: stat bonuses, AC bonus, attack/damage bonuses, gear slot bonuses

This commit is contained in:
Aaron Wood 2026-04-09 13:53:06 -04:00
parent 859ac64868
commit 0bd83e5f92
7 changed files with 148 additions and 17 deletions

View file

@ -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 (
<div className={styles.panel}>
@ -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)}

View file

@ -47,6 +47,11 @@
margin-top: 0.15rem;
}
.bonus {
color: #4caf50;
font-size: 0.65rem;
}
.btn {
width: 22px;
height: 22px;

View file

@ -7,6 +7,7 @@ const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
interface StatBlockProps {
stats: Stat[];
statBonuses?: Map<string, number>;
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 (
<div className={styles.statGrid}>
{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 (
<div key={name} className={styles.stat}>
@ -38,7 +42,7 @@ export default function StatBlock({
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, value - 1)}
onClick={() => onStatChange(name, baseValue - 1)}
>
</button>
@ -58,13 +62,16 @@ export default function StatBlock({
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, value + 1)}
onClick={() => onStatChange(name, baseValue + 1)}
>
+
</button>
)}
</div>
<span className={styles.score}>{value}</span>
<span className={styles.score}>
{value}
{bonus > 0 && <span className={styles.bonus}> (+{bonus})</span>}
</span>
</div>
);
})}

View file

@ -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<string, number>();
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 (
<div className={styles.panel}>
<div className={styles.sectionTitle}>Ability Scores</div>
<StatBlock
stats={character.stats}
statBonuses={statBonuses.size > 0 ? statBonuses : undefined}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}

View file

@ -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 {

View file

@ -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");

View file

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