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 } from "#shared/dom"; import { button, foldable, loading, numberpicker, tabgroup, Toaster } from "#shared/components"; import { CharacterCompiler, colorByRarity, stateFactory, subnameFactory } from "#shared/character"; import { modal, tooltip } from "#shared/floating"; import markdown from "#shared/markdown"; import { preview } from "#shared/proses"; import { Socket } from "#shared/websocket"; import { reactive } from "#shared/reactive"; import type { Character, CharacterConfig } from "~/types/character"; import { MarkdownEditor } from "./editor"; 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: HTMLElement; 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; private campaign?: Campaign; private characters!: Array; container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6'); ws?: Socket; constructor(id: string, user: ComputedRef) { 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('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, _c) => _c ?? 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, _c) => _c ?? 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, _c) => _c ?? 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, _c) => { if(_c) return _c; 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() { } }