diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index c019638..e9936c7 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -202,7 +202,7 @@ function AddCombatantBar({ for (let i = 0; i < n; i++) { onAdd({ id: newId(), - name: n > 1 ? `${name.trim()} ${i + 1}` : name.trim(), + name: name.trim(), // engine auto-numbers duplicates kind: 'monster', initiative: init, initBonus: 0, diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx index aa391e9..98d255f 100644 --- a/src/features/dice/DicePage.tsx +++ b/src/features/dice/DicePage.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation'; +import { rollDice, applyRollMode, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { diceRepo } from '@/lib/db/repositories'; import { useUiStore } from '@/stores/uiStore'; import { useMacroStore } from '@/stores/macroStore'; +import { useRollStore } from '@/stores/rollStore'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -26,16 +27,21 @@ export function DicePage() { const addMacro = useMacroStore((s) => s.add); const removeMacro = useMacroStore((s) => s.remove); + const mode = useRollStore((s) => s.mode); + const toggleMode = useRollStore((s) => s.toggleMode); + const roll = async (expression: string, label = '') => { try { - const result = rollDice(expression, createRng()); + const finalExpr = applyRollMode(expression, mode); + const result = rollDice(finalExpr, createRng()); setLast(result); setRollCount((n) => n + 1); setError(null); + const modeTag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : ''; await diceRepo.add({ ...(campaignId ? { campaignId } : {}), - label, - expression, + label: label + modeTag, + expression: finalExpr, total: result.total, breakdown: result.breakdown, }); @@ -81,13 +87,30 @@ export function DicePage() { {d} ))} - - + {mode !== 'normal' && ( +

+ {mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on — d20 rolls use 2d20{mode === 'advantage' ? 'kh1' : 'kl1'}. +

+ )} {/* Saved rolls (macros) */}
diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts index 020f6e0..5d7a6b8 100644 --- a/src/lib/combat/engine.test.ts +++ b/src/lib/combat/engine.test.ts @@ -89,6 +89,23 @@ describe('mutating mid-combat keeps the turn anchored (C16-C19 regressions)', () expect(e.round).toBe(2); }); + it('auto-numbers duplicate names (Goblin -> Goblin 1, Goblin 2, …)', () => { + let e = encounter([]); + e = addCombatant(e, mk('g1', 10)); + e = { ...e, combatants: e.combatants.map((c) => ({ ...c, name: 'Goblin' })) }; // first is "Goblin" + e = addCombatant(e, { ...mk('g2', 10), name: 'Goblin' }); + expect(e.combatants.map((c) => c.name).sort()).toEqual(['Goblin 1', 'Goblin 2']); + e = addCombatant(e, { ...mk('g3', 10), name: 'Goblin' }); + expect(e.combatants.find((c) => c.id === 'g3')?.name).toBe('Goblin 3'); + }); + + it('does not number unique names', () => { + let e = encounter([]); + e = addCombatant(e, { ...mk('a', 10), name: 'Aragorn' }); + e = addCombatant(e, { ...mk('b', 10), name: 'Legolas' }); + expect(e.combatants.map((c) => c.name)).toEqual(['Aragorn', 'Legolas']); + }); + it('adding a combatant mid-combat inserts by initiative without changing whose turn it is', () => { let e = startEncounter(encounter([mk('a', 30), mk('b', 10)])); e = nextTurn(e); // current = b diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index e0cde29..7bf0855 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -106,17 +106,51 @@ export function previousTurn(enc: Encounter): Encounter { }; } +/** Strip a trailing " 3" style number so "Goblin 2" and "Goblin" share a base. */ +function baseName(name: string): string { + return name.replace(/\s+\d+$/, '').trim(); +} + /** - * Insert a combatant. When the encounter is active, the new combatant is placed - * by initiative and the turn pointer is corrected so it still points at the - * same combatant whose turn it is. + * Disambiguate a new combatant's name against existing ones: the first duplicate + * renames the original to "Name 1" and the newcomer becomes "Name 2", etc. + * Returns the (possibly renamed) existing list and the new combatant's name. + */ +function disambiguate(existing: Combatant[], name: string): { combatants: Combatant[]; name: string } { + const base = baseName(name); + const sameBase = existing.filter((c) => baseName(c.name) === base); + if (sameBase.length === 0) return { combatants: existing, name }; + + let combatants = existing; + // If there's exactly one and it's unnumbered, number it "1" so the group reads 1,2,… + if (sameBase.length === 1 && !/\s+\d+$/.test(sameBase[0]!.name)) { + const id = sameBase[0]!.id; + combatants = existing.map((c) => (c.id === id ? { ...c, name: `${base} 1` } : c)); + } + const maxNum = Math.max( + 1, + ...sameBase.map((c) => { + const m = /\s+(\d+)$/.exec(c.name); + return m ? Number(m[1]) : 0; + }), + ); + return { combatants, name: `${base} ${maxNum + 1}` }; +} + +/** + * Insert a combatant. Auto-numbers duplicate names (Goblin 1, Goblin 2, …). + * When the encounter is active, the new combatant is placed by initiative and + * the turn pointer is corrected so it still points at whose turn it is. */ export function addCombatant(enc: Encounter, combatant: Combatant): Encounter { + const { combatants: renamed, name } = disambiguate(enc.combatants, combatant.name); + const incoming = name === combatant.name ? combatant : { ...combatant, name }; + if (enc.status !== 'active') { - return { ...enc, combatants: [...enc.combatants, combatant] }; + return { ...enc, combatants: [...renamed, incoming] }; } const currentId = currentCombatant(enc)?.id; - const combatants = sortByInitiative([...enc.combatants, combatant]); + const combatants = sortByInitiative([...renamed, incoming]); const turnIndex = Math.max(0, indexOfId(combatants, currentId)); return { ...enc, combatants, turnIndex }; } diff --git a/src/lib/dice/notation.test.ts b/src/lib/dice/notation.test.ts index 2686008..a6b9d32 100644 --- a/src/lib/dice/notation.test.ts +++ b/src/lib/dice/notation.test.ts @@ -1,7 +1,21 @@ import { describe, it, expect } from 'vitest'; -import { parseDice, rollDice, DiceParseError } from './notation'; +import { parseDice, rollDice, applyRollMode, DiceParseError } from './notation'; import { createRng } from '@/lib/rng'; +describe('applyRollMode', () => { + it('turns a d20 into 2d20kh1 for advantage, keeping modifiers', () => { + expect(applyRollMode('1d20+5', 'advantage')).toBe('2d20kh1+5'); + expect(applyRollMode('d20', 'disadvantage')).toBe('2d20kl1'); + }); + it('leaves non-d20 and normal rolls unchanged', () => { + expect(applyRollMode('2d6+3', 'advantage')).toBe('2d6+3'); + expect(applyRollMode('1d20+5', 'normal')).toBe('1d20+5'); + }); + it('only transforms the first d20 term', () => { + expect(applyRollMode('1d20+1d20', 'advantage')).toBe('2d20kh1+1d20'); + }); +}); + describe('parseDice', () => { it('parses a simple die group', () => { expect(parseDice('3d6')).toEqual([{ kind: 'dice', sign: 1, count: 3, sides: 6 }]); diff --git a/src/lib/dice/notation.ts b/src/lib/dice/notation.ts index 0682e9c..09e455b 100644 --- a/src/lib/dice/notation.ts +++ b/src/lib/dice/notation.ts @@ -48,6 +48,24 @@ export interface RollResult { export class DiceParseError extends Error {} +export type RollMode = 'normal' | 'advantage' | 'disadvantage'; + +/** + * Apply advantage/disadvantage to a d20 expression by turning the first d20 term + * into 2d20kh1 / 2d20kl1. Non-d20 expressions are returned unchanged, so the + * toggle only affects d20 checks (and keeps any modifiers, e.g. 1d20+5). + */ +export function applyRollMode(expression: string, mode: RollMode): string { + if (mode === 'normal') return expression; + const keep = mode === 'advantage' ? 'kh1' : 'kl1'; + let replaced = false; + return expression.replace(/(^|[+\-\s])\d*d20\b/i, (m, pre: string) => { + if (replaced) return m; + replaced = true; + return `${pre}2d20${keep}`; + }); +} + const MAX_DICE = 1000; const MAX_SIDES = 1000; diff --git a/src/lib/useRoll.ts b/src/lib/useRoll.ts index c7616af..38f0cf2 100644 --- a/src/lib/useRoll.ts +++ b/src/lib/useRoll.ts @@ -1,4 +1,4 @@ -import { rollDice } from '@/lib/dice/notation'; +import { rollDice, applyRollMode } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { degreeOfSuccess, type Degree } from '@/lib/dice/check'; import { diceRepo } from '@/lib/db/repositories'; @@ -30,9 +30,12 @@ function naturalD20(result: ReturnType): number | undefined { * Returns the result + degree. Used by the dice page, sheets, and statblocks. */ export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree } { - const result = rollDice(opts.expression, createRng()); + const mode = useRollStore.getState().mode; + const expression = applyRollMode(opts.expression, mode); + const result = rollDice(expression, createRng()); const campaignId = useUiStore.getState().activeCampaignId ?? undefined; - const label = opts.label ?? ''; + const modeTag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : ''; + const label = (opts.label ?? '') + modeTag; let degree: Degree | undefined; if (opts.dc !== undefined && opts.system) { diff --git a/src/stores/rollStore.ts b/src/stores/rollStore.ts index dc7a1f2..56ddbf1 100644 --- a/src/stores/rollStore.ts +++ b/src/stores/rollStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { RollResult } from '@/lib/dice/notation'; +import type { RollResult, RollMode } from '@/lib/dice/notation'; import type { Degree } from '@/lib/dice/check'; export interface TrayRoll { @@ -13,13 +13,21 @@ export interface TrayRoll { interface RollState { last: TrayRoll | null; + /** advantage/disadvantage toggle applied to d20 rolls everywhere */ + mode: RollMode; push: (roll: Omit) => void; dismiss: () => void; + setMode: (mode: RollMode) => void; + /** click the same mode again to turn it off */ + toggleMode: (mode: Exclude) => void; } let seq = 0; -export const useRollStore = create((set) => ({ +export const useRollStore = create((set, get) => ({ last: null, + mode: 'normal', push: (roll) => set({ last: { ...roll, seq: ++seq } }), dismiss: () => set({ last: null }), + setMode: (mode) => set({ mode }), + toggleMode: (mode) => set({ mode: get().mode === mode ? 'normal' : mode }), }));