Fixes: adv/dis as a global toggle, auto-number duplicate combatants
- Advantage/Disadvantage are now toggle modes (rollStore.mode) applied to d20 rolls everywhere (dice page, sheet skills/saves/attacks), preserving modifiers; applyRollMode() rewrites the first d20 -> 2d20kh1/kl1 - addCombatant auto-numbers duplicate names (Goblin 1, Goblin 2, …); the first duplicate also renames the original. Quantity add relies on this. - Tests for applyRollMode + duplicate numbering Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+6
-3
@@ -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<typeof rollDice>): 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) {
|
||||
|
||||
Reference in New Issue
Block a user