Dice roll parsing and stringifying.
This commit is contained in:
parent
8335871883
commit
898d95793a
|
|
@ -133,7 +133,8 @@ type CommonItemConfig = {
|
||||||
equippable: boolean;
|
equippable: boolean;
|
||||||
consummable: boolean;
|
consummable: boolean;
|
||||||
craft?: { mineral: number, natural: number, processed: number, magical: number };
|
craft?: { mineral: number, natural: number, processed: number, magical: number };
|
||||||
}
|
//variants?: string[];
|
||||||
|
};
|
||||||
type ArmorConfig = {
|
type ArmorConfig = {
|
||||||
category: 'armor';
|
category: 'armor';
|
||||||
health: number;
|
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 { MarkdownEditor } from "#shared/editor";
|
||||||
import { Socket } from "#shared/websocket";
|
import { Socket } from "#shared/websocket";
|
||||||
import { raw, reactive, reactivity } from '#shared/reactive';
|
import { raw, reactive, reactivity } from '#shared/reactive';
|
||||||
|
import { parseDice, stringifyRoll } from "./dice";
|
||||||
|
|
||||||
const config = characterConfig as CharacterConfig;
|
const config = characterConfig as CharacterConfig;
|
||||||
|
|
||||||
|
|
@ -129,6 +130,8 @@ const defaultCompiledCharacter = (character: Character) => ({
|
||||||
passive: [],
|
passive: [],
|
||||||
spells: [],
|
spells: [],
|
||||||
sickness: [],
|
sickness: [],
|
||||||
|
dedication: [],
|
||||||
|
poison: [],
|
||||||
},
|
},
|
||||||
aspect: {
|
aspect: {
|
||||||
id: character.aspect ?? "",
|
id: character.aspect ?? "",
|
||||||
|
|
@ -1522,7 +1525,7 @@ export class CharacterSheet
|
||||||
private character?: CharacterCompiler;
|
private character?: CharacterCompiler;
|
||||||
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
|
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
|
||||||
private tabs?: HTMLElement;
|
private tabs?: HTMLElement;
|
||||||
private tab: string = 'actions';
|
private tab: string = localStorage.getItem('character-tab') ?? 'actions';
|
||||||
|
|
||||||
ws?: Socket;
|
ws?: Socket;
|
||||||
constructor(id: string, user: ComputedRef<User | null>)
|
constructor(id: string, user: ComputedRef<User | null>)
|
||||||
|
|
@ -1606,34 +1609,6 @@ export class CharacterSheet
|
||||||
publicNotes.content = this.character!.character.notes!.public!;
|
publicNotes.content = this.character!.character.notes!.public!;
|
||||||
privateNotes.content = this.character!.character.notes!.private!;
|
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([
|
this.tabs = tabgroup([
|
||||||
{ id: 'actions', title: [ text('Actions') ], content: this.actionsTab(character) },
|
{ 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("flex flex-row gap-4 justify-between", [
|
||||||
div(),
|
div(),
|
||||||
|
|
||||||
|
|
@ -1948,12 +1923,26 @@ export class CharacterSheet
|
||||||
}
|
}
|
||||||
abilitiesTab(character: CompiledCharacter)
|
abilitiesTab(character: CompiledCharacter)
|
||||||
{
|
{
|
||||||
return [
|
return [ div('flex flex-col gap-4', [
|
||||||
div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
|
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 }) ]),
|
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 } }),
|
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)
|
spellTab(character: CompiledCharacter)
|
||||||
{
|
{
|
||||||
|
|
@ -2185,7 +2174,7 @@ export class CharacterSheet
|
||||||
}, class: { container: '!w-5 !h-5' } }) : undefined,
|
}, 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))) ]),
|
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 === '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
|
undefined
|
||||||
]),
|
]),
|
||||||
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
|
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