Dice roll parsing and stringifying.

This commit is contained in:
Clément Pons 2026-02-04 17:51:30 +01:00
parent 8335871883
commit 898d95793a
4 changed files with 272 additions and 14249 deletions

View File

@ -133,7 +133,8 @@ type CommonItemConfig = {
equippable: boolean;
consummable: boolean;
craft?: { mineral: number, natural: number, processed: number, magical: number };
}
//variants?: string[];
};
type ArmorConfig = {
category: 'armor';
health: number;

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,7 @@ import type { User } from "~/types/auth";
import { MarkdownEditor } from "#shared/editor";
import { Socket } from "#shared/websocket";
import { raw, reactive, reactivity } from '#shared/reactive';
import { parseDice, stringifyRoll } from "./dice";
const config = characterConfig as CharacterConfig;
@ -129,6 +130,8 @@ const defaultCompiledCharacter = (character: Character) => ({
passive: [],
spells: [],
sickness: [],
dedication: [],
poison: [],
},
aspect: {
id: character.aspect ?? "",
@ -1522,7 +1525,7 @@ export class CharacterSheet
private character?: CharacterCompiler;
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
private tabs?: HTMLElement;
private tab: string = 'actions';
private tab: string = localStorage.getItem('character-tab') ?? 'actions';
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
@ -1606,34 +1609,6 @@ export class CharacterSheet
publicNotes.content = this.character!.character.notes!.public!;
privateNotes.content = this.character!.character.notes!.private!;
/* const validateProperty = (v: string, property: 'health' | 'mana', obj: { edit: HTMLInputElement, readonly: HTMLElement }) => {
character.variables[property] = v.startsWith('-') ? character.variables[property] + parseInt(v.substring(1), 10) : v.startsWith('+') ? character.variables[property] - parseInt(v.substring(1), 10) : character[property] - parseInt(v, 10);
obj.edit.value = (character[property] - character.variables[property]).toString();
obj.edit.replaceWith(obj.readonly);
};
const health = {
readonly: dom("span", {
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
text: () => `${character.health - character.variables.health}`,
listeners: { click: () => { health.readonly.replaceWith(health.edit); health.edit.select(); health.edit.focus(); } },
}),
edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => {
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
}, change: (v) => validateProperty(v, 'health', health), blur: () => validateProperty(health.edit.value, 'health', health), class: 'font-bold px-2 w-20 text-center' }),
};
const mana = {
readonly: dom("span", {
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
text: () => `${character.mana - character.variables.mana}`,
listeners: { click: () => { mana.readonly.replaceWith(mana.edit); mana.edit.select(); mana.edit.focus(); } },
}),
edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => {
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
}, change: (v) => validateProperty(v, 'mana', mana), blur: () => validateProperty(mana.edit.value, 'mana', mana), class: 'font-bold px-2 w-20 text-center' }),
}; */
this.tabs = tabgroup([
{ id: 'actions', title: [ text('Actions') ], content: this.actionsTab(character) },
@ -1659,9 +1634,9 @@ export class CharacterSheet
}),
])
] },
], { focused: this.tab, class: { container: 'flex-1 gap-4 px-4 max-w-[960px] h-full', content: 'overflow-auto h-full' }, switch: v => { this.tab = v; } });
], { focused: this.tab, class: { container: 'flex-1 gap-4 px-4 max-w-[960px] h-full', content: 'overflow-auto h-full' }, switch: v => { this.tab = v; localStorage.setItem('character-tab', v); } });
this.container.replaceChildren(div('flex flex-col justify-start gap-1 h-full', [
this.container.replaceChildren(div('flex flex-col justify-start gap-1 h-full w-full', [
div("flex flex-row gap-4 justify-between", [
div(),
@ -1948,12 +1923,26 @@ export class CharacterSheet
}
abilitiesTab(character: CompiledCharacter)
{
return [
div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
return [ div('flex flex-col gap-4', [
foldable(() => [div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.passive[e]?.name }) ]),
markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }),
]), list: character.lists.passive }),
];
]), list: character.lists.passive })], [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-bold', text: "Aptitudes" }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
], { open: true }),
foldable(() => [div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.dedication[e]?.name }) ]),
markdown(getText(config.features[e]?.description), undefined, { tags: { a: preview } }),
]), list: character.lists.dedication })], [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-bold', text: "Spécialisations" }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
], { open: false }),
]) ];
}
spellTab(character: CompiledCharacter)
{
@ -2185,7 +2174,7 @@ export class CharacterSheet
}, class: { container: '!w-5 !h-5' } }) : undefined,
div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),
item.category === 'armor' ? div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:shoulder-armor', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => `${item.health + ((e.state as ArmorState)?.health ?? 0) - ((e.state as ArmorState)?.loss ?? 0)}/${item.health + ((e.state as ArmorState)?.health ?? 0)} (${[item.absorb.static + ((e.state as ArmorState).absorb?.flat ?? 0) > 0 ? '-' + (item.absorb.static + ((e.state as ArmorState).absorb?.flat ?? 0)) : undefined, item.absorb.percent + ((e.state as ArmorState).absorb?.percent ?? 0) > 0 ? '-' + (item.absorb.percent + ((e.state as ArmorState).absorb?.percent ?? 0)) + '%' : undefined].filter(e => !!e).join('/')})`) ]) :
item.category === 'weapon' ? div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:broadsword', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => `${item.damage.value} ${(e.state as WeaponState)?.attack ? '+' + (e.state as WeaponState).attack : ''}`), proses('a', preview, [ text(damageTypeTexts[item.damage.type].toLowerCase()) ], { href: `regles/le-combat/les-types-de-degats#${damageTypeTexts[item.damage.type]}`, label: damageTypeTexts[item.damage.type], navigate: false }) ]) :
item.category === 'weapon' ? div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:broadsword', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => stringifyRoll(parseDice(`${item.damage.value}${(e.state as WeaponState)?.attack ? '+' + (e.state as WeaponState).attack : ''}`), character.modifier, true)), proses('a', preview, [ text(damageTypeTexts[item.damage.type].toLowerCase()) ], { href: `regles/le-combat/les-types-de-degats#${damageTypeTexts[item.damage.type]}`, label: damageTypeTexts[item.damage.type], navigate: false }) ]) :
undefined
]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [

244
shared/dice.ts Normal file
View File

@ -0,0 +1,244 @@
import type { MainStat } from "~/types/character";
import { MAIN_STATS, mainStatShortTexts } from "./character";
import type { Proxy } from "./reactive";
//[Quantité de dés, Nb de face du dé (1 = pas besoin de roll de dé), explosif]
export type Dice = [number, number | MainStat] | [number, number | MainStat, true];
export type DiceRoll = Dice[];
enum State {
START,
QUANTITY,
D_LETTER,
FACES_NUMBER,
FACES_STAT,
MODIFIER_SIGN,
MODIFIER,
EXPLODING,
END_DICE,
};
export function parseDice(formula: string): DiceRoll
{
const result: DiceRoll = [];
let state = State.START;
let i = 0, length = formula.length;
//Variables utilitaires de la State machine
let buffer = "";
let isExploding = false, isNegative = false, currentQty = 1, currentFaces: number | MainStat = 1;
const reset = () => {
if (state !== State.START && state !== State.END_DICE)
{
const dice: Dice = isExploding ? [currentQty, currentFaces, true] : [currentQty, currentFaces];
if (isNegative)
{
dice[0] = -dice[0];
isNegative = false;
}
result.push(dice);
}
currentQty = 1;
currentFaces = 1;
isExploding = false;
buffer = "";
};
while(i <= length)
{
const char = i < length ? formula[i]?.toLowerCase() ?? '\0' : '\0';
const isDigit = char >= '0' && char <= '9';
const isLetter = char >= 'a' && char <= 'z';
const isEnd = i === length;
switch (state) {
case State.START:
if (isDigit)
{
buffer = char;
state = State.QUANTITY;
}
else if (char === 'd')
{
currentQty = 1;
state = State.D_LETTER;
}
else if (isLetter)
{
buffer = char;
state = State.FACES_STAT;
}
else if (char === '+' || char === '-')
{
isNegative = char === '-';
state = State.MODIFIER_SIGN;
}
else if (!isEnd && char !== ' ' && char !== '\t')
throw new Error(`Caractère inattendu '${char}' à la position ${i}`);
break;
case State.QUANTITY:
if (isDigit)
buffer += char;
else if (char === 'd')
{
currentQty = parseInt(buffer, 10) || 1;
buffer = "";
state = State.D_LETTER;
}
else
{
currentQty = parseInt(buffer, 10);
currentFaces = 1;
reset();
state = State.END_DICE;
i--;
}
break;
case State.D_LETTER:
if (isDigit)
{
buffer = char;
state = State.FACES_NUMBER;
}
else if (isLetter)
{
buffer = 'd' + char;
state = State.FACES_STAT;
}
else
throw new Error(`Attendu nombre ou stat après 'd' à la position ${i}`);
break;
case State.FACES_NUMBER:
if (isDigit)
buffer += char;
else if (char === '!')
{
currentFaces = parseInt(buffer, 10);
state = State.EXPLODING;
}
else
{
currentFaces = parseInt(buffer, 10);
reset();
state = State.END_DICE;
i--;
}
break;
case State.FACES_STAT:
if (isLetter && buffer.length < 3)
buffer += char;
else if (char === '!')
{
const stat = validStat(buffer);
if (!stat)
throw new Error(`Stat invalide '${buffer}' à la position ${i - buffer.length}`);
currentFaces = stat;
state = State.EXPLODING;
}
else
{
const stat = validStat(buffer);
if (!stat)
throw new Error(`Stat invalide '${buffer}' à la position ${i - buffer.length}`);
currentFaces = stat;
reset();
state = State.END_DICE;
i--;
}
break;
case State.EXPLODING:
isExploding = true;
reset();
state = State.END_DICE;
i--;
break;
case State.MODIFIER_SIGN:
if (isDigit)
{
buffer = char;
state = State.MODIFIER;
}
else if (char === 'd')
{
currentQty = 1;
state = State.D_LETTER;
}
else if (isLetter)
{
buffer = char;
state = State.FACES_STAT;
}
else
throw new Error(`Attendu nombre après '${isNegative ? "-" : "+"}' à la position ${i}`);
break;
case State.MODIFIER:
if (isDigit)
buffer += char;
else
{
currentQty = parseInt(buffer, 10);
currentFaces = 1;
if (isNegative) currentQty = -currentQty;
reset();
isNegative = false;
state = State.END_DICE;
i--;
}
break;
case State.END_DICE:
if (char === '+' || char === '-')
{
isNegative = char === '-';
state = State.MODIFIER_SIGN;
}
else if (char === 'd')
{
currentQty = 1;
state = State.D_LETTER;
}
else if (!isEnd && char !== ' ' && char !== '\t')
throw new Error(`Opérateur attendu, trouvé '${char}' à la position ${i}`);
break;
}
i++;
}
return result;
}
export function stringifyRoll(dices: DiceRoll, modifiers?: Proxy<Partial<Record<MainStat, number>>>, explicit?: boolean): string
{
const map = new Map<string, number>();
dices.forEach(e => {
if(e[0] === 0 || e[1] === 0) return;
const target = typeof e[1] === 'string' ? modifiers && e[1] in modifiers ? modifiers[e[1]]! * e[0] : `@${e[1]}` : e[0]; //@ character used to sort the stat dices at the end
const key = typeof e[1] === 'string' ? typeof target === 'string' ? e[1] : '1' : e[2] ? e[1].toString(10) + '!' : e[1].toString(10), value = map.get(key);
if(typeof target === 'string') map.has(target) ? map.set(target, 1) : map.set(target, map.get(target)! + 1);
else value === undefined ? map.set(key, target) : map.set(key, value + target);
});
const stringify = ([face, amount]: [string, number]) => (face.startsWith('@') && MAIN_STATS.includes(face.substring(1) as MainStat)) || face === '1' ? `${amount < 0 ? '-' : '+'}${face.startsWith('@') && MAIN_STATS.includes(face.substring(1) as MainStat) ? mainStatShortTexts[face.substring(1) as MainStat]! : amount}` : `${amount < 0 ? '-' : '+'}${explicit ?? false ? amount : amount !== 1 ? amount : ''}d${face}`;
const text = [...map.entries()].sort((a, b) => b[0].localeCompare(a[0])).reduce((p, v) => p + stringify(v), '');
return text.startsWith('+') ? text.substring(1) : text;
}
function validStat(text: string): MainStat | undefined
{
return Object.entries(mainStatShortTexts).find(e => e[1].toLowerCase() === text.toLowerCase())?.at(0) as MainStat | undefined;
}