obsidian-visualiser/shared/campaign.util.ts

312 lines
19 KiB
TypeScript

import { z } from "zod/v4";
import type { User } from "~/types/auth";
import characterConfig from '#shared/character-config.json';
import type { Campaign } from "~/types/campaign";
import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util";
import { button, foldable, loading, numberpicker, tabgroup, Toaster } from "#shared/components.util";
import { CharacterCompiler, colorByRarity, stateFactory, subnameFactory } from "#shared/character.util";
import { modal, tooltip } from "#shared/floating.util";
import markdown from "#shared/markdown.util";
import { preview } from "#shared/proses";
import { Socket } from "#shared/websocket.util";
import { reactive } from "#shared/reactive";
import type { Character, CharacterConfig } from "~/types/character";
import { MarkdownEditor } from "./editor.util";
import { getText } from "./i18n";
const config = characterConfig as CharacterConfig;
export const CampaignValidation = z.object({
id: z.number(),
name: z.string().nonempty(),
public_notes: z.string(),
dm_notes: z.string(),
settings: z.object(),
});
class CharacterPrinter
{
compiler?: CharacterCompiler;
container: RedrawableHTML;
name: string;
id: number;
constructor(character: number, name: string)
{
this.id = character;
this.name = name;
this.container = div('flex flex-col gap-2 px-1', [ div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small') ]) ]);
useRequestFetch()(`/api/character/${character}`).then((character) => {
if(character)
{
this.compiler = new CharacterCompiler(character);
const compiled = this.compiler.compiled;
const armor = this.compiler.armor;
this.container.replaceChildren(div('flex flex-row justify-between items-center', [
span('text-bold text-xl', compiled.name),
div('flex flex-row gap-2 items-baseline', [ span('text-sm font-bold', 'PV'), span('text-lg', `${(compiled.health ?? 0) - (compiled.variables.health ?? 0)}/${compiled.health ?? 0}`) ]),
]), div('flex flex-row justify-between items-center', [
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Armure'), span('text-lg', armor === undefined ? `-` : `${armor.current}/${armor.max}`) ]),
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Mana'), span('text-lg', `${(compiled.mana ?? 0) - (compiled.variables.mana ?? 0)}/${compiled.mana ?? 0}`) ]),
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Fatigue'), span('text-lg', `${compiled.variables.exhaustion ?? 0}`) ]),
]));
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(span('text-sm italic text-light-red dark:text-dark-red', 'Données indisponible'));
})
}
}
export class CampaignSheet
{
private user: ComputedRef<User | null>;
private campaign?: Campaign;
private characters!: Array<CharacterPrinter>;
container: RedrawableHTML = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
{
this.user = user;
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
this.container.replaceChildren(load);
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
if(campaign)
{
this.campaign = reactive(campaign);
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
/* this.ws = new Socket(`/ws/campaign/${id}`, true);
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => {
if(character.action === 'ADD')
{
this.characters.push(new CharacterPrinter(character.id, character.name));
}
else if(character.action === 'REMOVE')
{
const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id);
idx !== -1 && this.characters.splice(idx, 1);
}
});
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', (player) => {
if(player.action === 'ADD')
{
this.campaign?.members.push({ member: { id: player.id, username: player.name } });
}
else if(player.action === 'REMOVE')
{
const idx = this.campaign?.members.findIndex(e => e.member.id !== player.id);
idx && idx !== -1 && this.characters.splice(idx, 1);
}
});
this.ws.handleMessage<void>('hardsync', () => {
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
this.campaign = reactive(campaign);
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
});
}); */
document.title = `d[any] - Campagne ${campaign.name}`;
this.render();
load.remove();
}
else throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [
span('text-2xl font-bold tracking-wider', 'Campagne introuvable'),
span(undefined, 'Cette campagne n\'existe pas ou est privé.'),
div('flex flex-row gap-4 justify-center items-center', [
button(text('Mes campagnes'), () => useRouter().push({ name: 'campaign' }), 'px-2 py-1'),
button(text('Créer une campagne'), () => useRouter().push({ name: 'campaign-create' }), 'px-2 py-1')
])
]));
});
}
save()
{
if(!this.campaign)
return;
return useRequestFetch()(`/api/campaign/${this.campaign.id}`, {
method: 'POST',
body: {
name: this.campaign.name,
status: this.campaign.status,
public_notes: this.campaign.public_notes,
dm_notes: this.campaign.dm_notes,
},
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
});
}
saveVariables()
{
}
private render()
{
const campaign = this.campaign;
if(!campaign)
return;
const charPicker = this.characterPicker();
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
div('flex flex-row gap-2 items-center py-2', [
div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), campaign.owner.username, 'bottom') ]),
div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
div('flex flex-row gap-1', { list: campaign.members, render: (member) => div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), member.member.username, 'bottom') ]) }),
]),
div('flex flex-1 flex-col items-center justify-center gap-2', [
span('text-2xl font-serif font-bold italic', campaign.name),
span('italic text-light-70 dark:text-dark-70 text-sm', campaign.status === 'PREPARING' ? 'En préparation' : campaign.status === 'PLAYING' ? 'En jeu' : 'Archivé'),
]),
div('flex flex-1 flex-col items-center justify-center', [
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
button(icon(() => 'radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
]),
]),
]),
div('flex flex-row gap-4 flex-1 h-0', [
div('flex flex-col gap-2', [
div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]),
div('flex flex-col gap-2', { list: this.characters, render: (e) => e.container }),
div('px-8 py-4 w-full flex', [
button([
icon('radix-icons:plus-circled', { width: 24, height: 24 }),
span('text-sm', 'Ajouter un personnage'),
], () => charPicker.show(), 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'),
])
]),
div('flex h-full border-l border-light-40 dark:border-dark-40'),
div('flex flex-col', [
tabgroup([
{ id: 'campaign', title: [ text('Campagne') ], content: () => {
const editor = new MarkdownEditor();
editor.content = campaign.public_notes;
editor.onChange = (v) => campaign.public_notes = v;
return [
this.user.value && this.user.value.id === campaign.owner.id ? div('flex flex-col gap-4 p-1', [ div('flex flex-row justify-between items-center', [ span('text-xl font-bold', 'Notes destinées aux joueurs'), div('flex flex-row gap-2', [ tooltip(button(icon('radix-icons:paper-plane', { width: 16, height: 16 }), () => this.save(), 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]) ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [editor.dom])]) : markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }),
];
} },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => this.items() },
{ id: 'settings', title: [ text('Paramètres') ], content: () => [
] },
{ id: 'ressources', title: [ text('Ressources') ], content: () => [
] }
], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto p-2', tabbar: 'gap-4 border-b border-light-30 dark:border-dark-30' } }),
])
]))
}
characterPicker()
{
const current = reactive({
characters: [] as Character[],
loading: true,
});
const _modal = modal([
div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [
span('text-xl font-bold', 'Mes personnages'),
div('grid grid-cols-3 gap-2', { list: () => current.characters, render: (e) => div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [
span('font-bold', e.name),
span('', `Niveau ${e.level}`),
button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(_modal.close)),
]), fallback: () => div('flex justify-center items-center col-span-3', [current.loading ? loading('large') : span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')]) }),
]),
], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' }, open: false });
return { show: () => {
current.loading = true;
useRequestFetch()(`/api/character`).then((list) => {
current.characters = list?.filter(e => !this.characters.find(_e => _e.compiler?.character.id === e.id)) ?? [];
}).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => {
current.loading = false;
});
_modal.open();
}, hide: _modal.close }
}
items()
{
if(!this.campaign)
return [];
const items = this.campaign.items;
const money = {
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
edit: numberpicker({ defaultValue: this.campaign.money, change: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }),
};
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [
div('flex flex-row justify-end items-center gap-8', [
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), this.user.value && this.user.value.id === this.campaign.owner.id ? money.readonly : div('cursor-pointer px-2 py-px flex flex-row gap-1 items-center', [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]) ]),
])
]),
div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: this.campaign.items, render: e => {
const item = config.items[e.id];
if(!item) return;
const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0);
const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]);
const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]);
return foldable(() => [
markdown(getText(item.description)),
div('flex flex-row justify-center gap-1', [
button(text('Offrir'), () => {
}, 'px-2 text-sm h-5 box-content'),
button(icon('radix-icons:minus', { width: 12, height: 12 }), () => {
const idx = items.findIndex(_e => _e === e);
if(idx === -1) return;
items[idx]!.amount--;
if(items[idx]!.amount <= 0) items.splice(idx, 1);
this.saveVariables();
}, 'p-1'),
button(icon('radix-icons:plus', { width: 12, height: 12 }), () => {
const idx = items.findIndex(_e => _e === e);
if(idx === -1) return;
if(item.equippable) items.push(stateFactory(item));
else if(items.find(_e => _e === e)) items.find(_e => _e === e)!.amount++;
else items.push(stateFactory(item));
this.saveVariables();
}, 'p-1')
]) ], [div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [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 divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]),
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'text-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity ?? 0}` : '-') ]),
e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight,
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]),
]),
])], { open: false, class: { icon: 'px-2', container: 'p-1 gap-2', content: 'px-4 pb-1 flex flex-col' } })
}})
])
];
}
settings()
{
}
}