Dice roll parsing and stringifying.
This commit is contained in:
parent
8335871883
commit
898d95793a
|
|
@ -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
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue