From 972f6efa1836903c35d1d6551828027fe104ebec Mon Sep 17 00:00:00 2001 From: nandobfer Date: Wed, 3 Jun 2026 03:22:08 +0200 Subject: [PATCH 1/3] terminado aba dos npcs --- aicontext/modules/dice-roller.md | 4 +- aicontext/modules/owlbear.md | 10 + .../rooms/[roomId]/npcs/[npcId]/route.ts | 12 + .../api/owlbear/rooms/[roomId]/npcs/route.ts | 12 + .../rooms/[roomId]/npcs/user-npcs/route.ts | 7 + .../components/sheet-header.tsx | 20 +- .../dice-roller/components/hp-dice-panel.tsx | 42 ++ src/features/dice-roller/utils/hp-dice.ts | 61 ++ src/features/owlbear/gm-npcs-tab.tsx | 650 ++++++++++++++++++ .../owlbear/models/owlbear-room-npc.ts | 37 + src/features/owlbear/owlbear-shell.tsx | 21 +- src/features/owlbear/room-npcs-api.ts | 91 +++ .../owlbear/server/room-npc-routes.ts | 198 ++++++ .../owlbear/server/session-service.ts | 4 + src/features/owlbear/use-owlbear-session.ts | 3 +- src/features/owlbear/use-room-npcs.ts | 81 +++ src/lib/config/version.ts | 2 +- tests/frontend/dice-roller/hp-dice.test.ts | 27 + tests/owlbear/backend/room-npc-routes.test.ts | 163 +++++ tests/owlbear/frontend/gm-npcs-tab.test.tsx | 251 +++++++ tests/owlbear/frontend/owlbear-shell.test.tsx | 10 + .../frontend/use-owlbear-session.test.tsx | 78 +++ 22 files changed, 1757 insertions(+), 27 deletions(-) create mode 100644 src/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route.ts create mode 100644 src/app/api/owlbear/rooms/[roomId]/npcs/route.ts create mode 100644 src/app/api/owlbear/rooms/[roomId]/npcs/user-npcs/route.ts create mode 100644 src/features/dice-roller/components/hp-dice-panel.tsx create mode 100644 src/features/dice-roller/utils/hp-dice.ts create mode 100644 src/features/owlbear/gm-npcs-tab.tsx create mode 100644 src/features/owlbear/models/owlbear-room-npc.ts create mode 100644 src/features/owlbear/room-npcs-api.ts create mode 100644 src/features/owlbear/server/room-npc-routes.ts create mode 100644 src/features/owlbear/use-room-npcs.ts create mode 100644 tests/frontend/dice-roller/hp-dice.test.ts create mode 100644 tests/owlbear/backend/room-npc-routes.test.ts create mode 100644 tests/owlbear/frontend/gm-npcs-tab.test.tsx create mode 100644 tests/owlbear/frontend/use-owlbear-session.test.tsx diff --git a/aicontext/modules/dice-roller.md b/aicontext/modules/dice-roller.md index 275883a0..9a814531 100644 --- a/aicontext/modules/dice-roller.md +++ b/aicontext/modules/dice-roller.md @@ -44,6 +44,9 @@ Quando o roller roda dentro da action do Owlbear, a request pode enviar `owlbear ### Owlbear embedded panel reuse `src/features/dice-roller/components/dice-roller-panel.tsx` pode ser usado tanto no modal do site quanto embutido em outras superficies, aceitando contexto opcional de rolagem Owlbear, callback de sucesso e replay de resultado remoto. Isso permite que a mesma UI base alimente a nova aba compartilhada sem duplicar a logica principal do roller. +### HP dice panel reuse +`src/features/dice-roller/components/hp-dice-panel.tsx` encapsula o `DiceRollerPanel` com preset travado e sem controles de configuração para rolagens de pontos de vida. `src/features/dice-roller/utils/hp-dice.ts` parseia fórmulas simples como `2d8 + 2`, agrega termos por dado e separa modificador fixo. O modal de subir nível da ficha e a aba `NPCs` do Owlbear usam esse componente para evitar duas implementações de rolagem de PV. + ### Dice panel responsive controls layout O painel usa uma grade de 4 colunas em `Adicionar dados`. Em superficies largas, `Combinação` e `Modificador` compartilham uma linha responsiva; as linhas de combinacao seguem o padrao do controle numerico, com remover/adicionar nas pontas e o valor centralizado. O botao principal de rolagem usa o label `JOGAR` quando esta disponivel e preserva `Rolando...` durante a execucao. @@ -67,4 +70,3 @@ Para eliminar o atraso percebido ao abrir o painel de dados, a aplicação reali - diff --git a/aicontext/modules/owlbear.md b/aicontext/modules/owlbear.md index 6daa6227..e1b3b1f5 100644 --- a/aicontext/modules/owlbear.md +++ b/aicontext/modules/owlbear.md @@ -17,6 +17,16 @@ Cada entrada do histórico compartilhado inclui nome exibido, fórmula ao lado d ### Modal de desvincular ficha do GM O modal de confirmação da aba `Fichas` renderiza campos ricos da ficha, como classe com mentions HTML, usando `MentionContent` para evitar HTML bruto no resumo da ficha a desvincular. +### Aba NPCs do GM +A aba `NPCs` do GM substitui o placeholder por uma lista real de NPCs vinculados à sala. O GM precisa estar logado no Dndicas; sessões GM anônimas do Owlbear não podem criar ou vincular NPCs de usuário. A sala persiste apenas vínculos em `owlbear_room_npcs` (`roomId`, `userId`, `sourceKind`, `sourceId`, `hpCurrent`, `hpMax`), apontando para `UserNpc` ou `Monster`, sem duplicar stat blocks. + +O topo da aba usa `SearchInput` e busca Fuse.js local nos NPCs já vinculados. `Adicionar NPC` abre opções para criar NPC com `NpcFormModal`, selecionar de `Meus NPCs` via `useInfiniteNpcs`, ou selecionar do `Catálogo de Monstros` via `useInfiniteMonsters`; as listas externas usam a busca Fuse.js dos endpoints existentes. A tabela mostra foto, nome, PV atual/máximo, barra de progresso, input textual de delta de PV com suporte a `-`, e lixeira com confirmação. A barra de PV interpola cor continuamente de vermelho escuro em 0%, passando por amarelo em 50%, até verde em 100%. Clicar na linha expande `NpcPreview` com `AnimatePresence`/`motion` e também anima o fechamento. + +As rotas Owlbear-aware de sala são `GET/POST /api/owlbear/rooms/[roomId]/npcs`, `PATCH/DELETE /api/owlbear/rooms/[roomId]/npcs/[npcId]` e `POST /api/owlbear/rooms/[roomId]/npcs/user-npcs`. Todas exigem Bearer token da sessão Owlbear, papel `GM`, `roomId` correspondente à sessão e usuário real do Clerk. Remover pela lixeira desvincula apenas a instância da sala; não apaga o NPC do usuário nem o monstro do catálogo. + +### Sessão Owlbear e transição de login +`useOwlbearSession` inclui o estado Clerk (`auth`/`anon`) na identidade interna usada para reaproveitar sessão backend. Quando um usuário entra no Dndicas sem recarregar a action, o hook invalida a sessão Owlbear anterior e abre uma nova sessão com a autenticação atual. Isso evita que abas autenticadas, como `Ficha` do jogador e `NPCs` do GM, continuem usando token anônimo depois do login, enquanto a aba `Fichas` do GM ainda pode funcionar antes do login. + ### Log de mapeamento `playerId` Quando a action do Owlbear inicializa o contexto do roller, ela combina o jogador atual de `sdk.player` com `sdk.party.getPlayers()`, deduplica por `id` e emite um log com `{ name, id, role }` no console para facilitar overrides manuais por `playerId` sem depender do nome exibido. diff --git a/src/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route.ts b/src/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route.ts new file mode 100644 index 00000000..0dbc979a --- /dev/null +++ b/src/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server" +import { deleteOwlbearRoomNpc, patchOwlbearRoomNpc } from "@/features/owlbear/server/room-npc-routes" + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ roomId: string; npcId: string }> }) { + const { roomId, npcId } = await params + return patchOwlbearRoomNpc(req, roomId, npcId) +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ roomId: string; npcId: string }> }) { + const { roomId, npcId } = await params + return deleteOwlbearRoomNpc(req, roomId, npcId) +} diff --git a/src/app/api/owlbear/rooms/[roomId]/npcs/route.ts b/src/app/api/owlbear/rooms/[roomId]/npcs/route.ts new file mode 100644 index 00000000..d9093302 --- /dev/null +++ b/src/app/api/owlbear/rooms/[roomId]/npcs/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server" +import { getOwlbearRoomNpcs, postOwlbearRoomNpc } from "@/features/owlbear/server/room-npc-routes" + +export async function GET(req: NextRequest, { params }: { params: Promise<{ roomId: string }> }) { + const { roomId } = await params + return getOwlbearRoomNpcs(req, roomId) +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ roomId: string }> }) { + const { roomId } = await params + return postOwlbearRoomNpc(req, roomId) +} diff --git a/src/app/api/owlbear/rooms/[roomId]/npcs/user-npcs/route.ts b/src/app/api/owlbear/rooms/[roomId]/npcs/user-npcs/route.ts new file mode 100644 index 00000000..1475ce4e --- /dev/null +++ b/src/app/api/owlbear/rooms/[roomId]/npcs/user-npcs/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server" +import { postOwlbearRoomUserNpc } from "@/features/owlbear/server/room-npc-routes" + +export async function POST(req: NextRequest, { params }: { params: Promise<{ roomId: string }> }) { + const { roomId } = await params + return postOwlbearRoomUserNpc(req, roomId) +} diff --git a/src/features/character-sheets/components/sheet-header.tsx b/src/features/character-sheets/components/sheet-header.tsx index 8946e463..e406607c 100644 --- a/src/features/character-sheets/components/sheet-header.tsx +++ b/src/features/character-sheets/components/sheet-header.tsx @@ -27,7 +27,7 @@ import { colors, diceColors, type DiceType, type EntityType } from "@/lib/config import { useClass } from "@/features/classes/api/classes-queries" import { ClassProgressionTable } from "@/features/classes/components/class-progression-table" import { useRace } from "@/features/races/api/races-queries" -import { DiceRollerPanel } from "@/features/dice-roller/components/dice-roller-panel" +import { HpDicePanel } from "@/features/dice-roller/components/hp-dice-panel" import { TraitPreview } from "@/features/rules/components/entity-preview-tooltip" import { fetchTraitById } from "@/features/traits/api/traits-api" import type { Trait } from "@/features/traits/types/traits.types" @@ -1076,17 +1076,13 @@ export function useSheetHeaderSections({ sheet, form, items = [], isReadOnly = f className="overflow-hidden" >
- { - setRolledHpGain(result.total) + { + setRolledHpGain(total) setIsHpRollerVisible(false) }} /> diff --git a/src/features/dice-roller/components/hp-dice-panel.tsx b/src/features/dice-roller/components/hp-dice-panel.tsx new file mode 100644 index 00000000..a9b994da --- /dev/null +++ b/src/features/dice-roller/components/hp-dice-panel.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { DiceRollerPanel } from "./dice-roller-panel" +import type { DiceRollResponse, DiceTerm } from "../types" + +export interface HpDicePanelProps { + label: string + terms: DiceTerm[] + modifier?: number + sourceRef?: { + sheetId?: string + fieldId?: string + owlbearPlayerId?: string + roomId?: string + } + onRollResolved: (total: number, result: DiceRollResponse) => void +} + +export function HpDicePanel({ + label, + terms, + modifier = 0, + sourceRef, + onRollResolved, +}: HpDicePanelProps) { + const preset = React.useMemo(() => ({ + label, + terms, + modifier, + source: "sheet" as const, + sourceRef, + }), [label, modifier, sourceRef, terms]) + + return ( + onRollResolved(result.total, result)} + /> + ) +} diff --git a/src/features/dice-roller/utils/hp-dice.ts b/src/features/dice-roller/utils/hp-dice.ts new file mode 100644 index 00000000..18e56763 --- /dev/null +++ b/src/features/dice-roller/utils/hp-dice.ts @@ -0,0 +1,61 @@ +import { DICE_TYPES, type DiceTerm, type DiceType } from "../types" + +const HP_DICE_TOKEN_REGEX = /^(\d*)d(\d+)$/i +const PURE_NUMBER_REGEX = /^\d+$/ +const SIMPLE_HP_EXPRESSION_REGEX = /^[\dd+\-\s]+$/i + +function toDiceType(faces: string): DiceType | null { + const dice = `d${faces}` as DiceType + return DICE_TYPES.includes(dice) ? dice : null +} + +export interface ParsedHpDiceFormula { + terms: DiceTerm[] + modifier: number +} + +export function parseStaticHpValue(formula: string): number | null { + const normalized = formula.trim() + if (!PURE_NUMBER_REGEX.test(normalized)) return null + + const value = Number(normalized) + return Number.isSafeInteger(value) && value >= 0 ? value : null +} + +export function parseHpDiceFormula(formula: string): ParsedHpDiceFormula | null { + const normalized = formula.trim().toLowerCase() + if (!normalized || !normalized.includes("d") || !SIMPLE_HP_EXPRESSION_REGEX.test(normalized)) { + return null + } + + const parts = normalized.replace(/\s+/g, "").match(/[+-]?[^+-]+/g) + if (!parts?.length) return null + + const termsByDice = new Map() + let modifier = 0 + + for (const part of parts) { + const sign = part.startsWith("-") ? -1 : 1 + const token = part.startsWith("+") || part.startsWith("-") ? part.slice(1) : part + if (!token) return null + + if (PURE_NUMBER_REGEX.test(token)) { + modifier += sign * Number(token) + continue + } + + const diceMatch = token.match(HP_DICE_TOKEN_REGEX) + if (!diceMatch) return null + + const quantity = diceMatch[1] ? Number(diceMatch[1]) : 1 + const dice = toDiceType(diceMatch[2]) + if (!dice || !Number.isSafeInteger(quantity) || quantity <= 0 || sign < 0) { + return null + } + + termsByDice.set(dice, (termsByDice.get(dice) ?? 0) + quantity) + } + + const terms = Array.from(termsByDice.entries()).map(([dice, quantity]) => ({ dice, quantity })) + return terms.length > 0 ? { terms, modifier } : null +} diff --git a/src/features/owlbear/gm-npcs-tab.tsx b/src/features/owlbear/gm-npcs-tab.tsx new file mode 100644 index 00000000..c4b4058b --- /dev/null +++ b/src/features/owlbear/gm-npcs-tab.tsx @@ -0,0 +1,650 @@ +"use client" + +import * as React from "react" +import Fuse from "fuse.js" +import { AnimatePresence, motion } from "framer-motion" +import { BookOpen, ChevronRight, Loader2, Plus, Shield, Skull, Trash2, UserRound } from "lucide-react" +import { toast } from "sonner" +import { cn } from "@/core/utils" +import { GlassButton } from "@/components/ui/glass-button" +import { + GlassDropdownMenu, + GlassDropdownMenuContent, + GlassDropdownMenuItem, + GlassDropdownMenuTrigger, +} from "@/components/ui/glass-dropdown-menu" +import { GlassImage } from "@/components/ui/glass-image" +import { GlassModal, GlassModalContent, GlassModalDescription, GlassModalFooter, GlassModalHeader, GlassModalTitle } from "@/components/ui/glass-modal" +import { InfiniteScrollSentinel } from "@/components/ui/infinite-scroll-sentinel" +import { SearchInput } from "@/components/ui/search-input" +import { MySheetsContent } from "@/app/(dashboard)/my-sheets/_components/my-sheets-content" +import { HpDicePanel } from "@/features/dice-roller/components/hp-dice-panel" +import { parseHpDiceFormula, parseStaticHpValue } from "@/features/dice-roller/utils/hp-dice" +import { useInfiniteMonsters } from "@/features/monsters/api/monsters-queries" +import { useInfiniteNpcs } from "@/features/monsters/api/npcs-queries" +import { NpcFormModal } from "@/features/monsters/components/npc-form-modal" +import { NpcPreview } from "@/features/monsters/components/npc-preview" +import type { CreateMonsterSchema } from "@/features/monsters/api/validation" +import type { Monster } from "@/features/monsters/types/monsters.types" +import { getMonsterHitPointAverage } from "@/features/monsters/utils/monster-calculations" +import { createOwlbearUserNpc, type OwlbearRoomNpc, type OwlbearRoomNpcSourceKind } from "./room-npcs-api" +import type { OwlbearRuntimeState, OwlbearSessionState } from "./types" +import { useRoomNpcs } from "./use-room-npcs" + +type PickerMode = "userNpc" | "monster" + +function InlineStatus({ tone = "neutral", message }: { tone?: "neutral" | "error"; message: string }) { + return ( +
+ {message} +
+ ) +} + +function hpPercent(current: number, max: number) { + if (max <= 0) return 0 + return Math.max(0, Math.min(100, (current / max) * 100)) +} + +function interpolateColor(from: [number, number, number], to: [number, number, number], amount: number) { + const clamped = Math.max(0, Math.min(1, amount)) + const [r1, g1, b1] = from + const [r2, g2, b2] = to + const r = Math.round(r1 + (r2 - r1) * clamped) + const g = Math.round(g1 + (g2 - g1) * clamped) + const b = Math.round(b1 + (b2 - b1) * clamped) + return `rgb(${r}, ${g}, ${b})` +} + +export function getNpcHpBarColor(current: number, max: number) { + const percent = hpPercent(current, max) / 100 + const red: [number, number, number] = [88, 0, 0] + const yellow: [number, number, number] = [234, 179, 8] + const green: [number, number, number] = [52, 211, 153] + + if (percent <= 0.5) { + return interpolateColor(red, yellow, percent / 0.5) + } + + return interpolateColor(yellow, green, (percent - 0.5) / 0.5) +} + +function getSourceLabel(sourceKind: OwlbearRoomNpcSourceKind) { + return sourceKind === "userNpc" ? "NPC" : "Monstro" +} + +function NpcAvatar({ monster }: { monster: Monster | null }) { + if (monster?.image) { + return + } + return ( +
+ +
+ ) +} + +function HpAdjustmentInput({ + npc, + onApply, +}: { + npc: OwlbearRoomNpc + onApply: (npc: OwlbearRoomNpc, delta: number) => void +}) { + const [value, setValue] = React.useState("") + + return ( + event.stopPropagation()} + onChange={(event) => { + const next = event.target.value.replace(/[^\d-]/g, "").replace(/(?!^)-/g, "") + setValue(next) + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return + event.preventDefault() + event.stopPropagation() + if (!/^-?\d+$/.test(value)) return + onApply(npc, Number(value)) + setValue("") + }} + className="h-9 w-20 rounded-lg border border-white/10 bg-black/20 px-2 text-center text-sm font-semibold text-white outline-none [appearance:textfield] placeholder:text-white/25 focus:border-emerald-300/40" + /> + ) +} + +function RoomNpcRow({ + npc, + isExpanded, + isPending, + onToggle, + onApplyHpDelta, + onRequestRemove, +}: { + npc: OwlbearRoomNpc + isExpanded: boolean + isPending: boolean + onToggle: () => void + onApplyHpDelta: (npc: OwlbearRoomNpc, delta: number) => void + onRequestRemove: (npc: OwlbearRoomNpc) => void +}) { + const source = npc.source + const percent = hpPercent(npc.hpCurrent, npc.hpMax) + const width = `${percent}%` + const hpColor = getNpcHpBarColor(npc.hpCurrent, npc.hpMax) + + return ( +
+ + + + + {isExpanded && ( + +
+ {source ? ( + + ) : ( + + )} +
+
+ )} +
+
+ ) +} + +function MonsterPickerModal({ + mode, + isOpen, + onClose, + onSelect, +}: { + mode: PickerMode | null + isOpen: boolean + onClose: () => void + onSelect: (monster: Monster, sourceKind: OwlbearRoomNpcSourceKind) => void +}) { + const [search, setSearch] = React.useState("") + const sourceKind: OwlbearRoomNpcSourceKind = mode === "monster" ? "monster" : "userNpc" + const title = mode === "monster" ? "Catálogo de Monstros" : "Meus NPCs" + const description = mode === "monster" + ? "Selecione um monstro do catálogo para adicioná-lo à sala." + : "Selecione um NPC da sua conta para adicioná-lo à sala." + const filters = React.useMemo(() => ({ search, status: "active" as const }), [search]) + const npcsQuery = useInfiniteNpcs(filters, { enabled: isOpen && mode === "userNpc", limit: 12 }) + const monstersQuery = useInfiniteMonsters(filters, { enabled: isOpen && mode === "monster", limit: 12 }) + const query = mode === "monster" ? monstersQuery : npcsQuery + const items = query.data?.pages.flatMap((page) => page.items) ?? [] + + React.useEffect(() => { + if (!isOpen) setSearch("") + }, [isOpen]) + + return ( + !open && onClose()}> + + + {title} + {description} + + +
+ +
+ {query.isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ Nenhum resultado encontrado. +
+ ) : ( + items.map((monster) => ( + + )) + )} + {items.length > 0 && ( + void query.fetchNextPage()} + /> + )} +
+
+
+
+ ) +} + +function InitialHpModal({ + monster, + sourceKind, + isOpen, + isPending, + onClose, + onConfirm, +}: { + monster: Monster | null + sourceKind: OwlbearRoomNpcSourceKind + isOpen: boolean + isPending: boolean + onClose: () => void + onConfirm: (sourceKind: OwlbearRoomNpcSourceKind, monster: Monster, hp: number) => Promise +}) { + const average = React.useMemo(() => monster ? getMonsterHitPointAverage(monster.hitPointsFormula) : null, [monster]) + const parsedDice = React.useMemo(() => monster ? parseHpDiceFormula(monster.hitPointsFormula) : null, [monster]) + + if (!monster) return null + + return ( + !open && onClose()}> + + + Definir PV inicial + + Escolha a média ou role os dados de vida para adicionar {monster.name} à sala. + + + +
+
+

{monster.name}

+

Fórmula de PV: {monster.hitPointsFormula}

+
+ + {average !== null && ( + + )} + + {parsedDice ? ( +
+ { + void onConfirm(sourceKind, monster, Math.max(0, total)) + }} + /> +
+ ) : ( + average === null && + )} +
+
+
+ ) +} + +function RemoveNpcDialog({ + npc, + isPending, + onClose, + onConfirm, +}: { + npc: OwlbearRoomNpc | null + isPending: boolean + onClose: () => void + onConfirm: () => void +}) { + return ( + !open && onClose()}> + + + Remover NPC da sala + + Esta ação remove apenas o vínculo com a sala atual do Owlbear. + + +
+ {npc?.source?.name ?? "NPC indisponível"} +
+ + Cancelar + + {isPending && } + Remover + + +
+
+ ) +} + +export function OwlbearGmNpcsTab({ + runtime, + session, + isAuthenticated, + isAuthLoaded, +}: { + runtime: OwlbearRuntimeState + session: OwlbearSessionState + isAuthenticated: boolean + isAuthLoaded: boolean +}) { + const roomId = runtime.roomId + const { items, isLoading, errorMessage, linkNpc, updateNpc, removeNpc } = useRoomNpcs( + roomId, + session.sessionToken, + runtime.status === "ready" && session.sessionStatus === "ready" && isAuthenticated, + ) + const [search, setSearch] = React.useState("") + const [expandedId, setExpandedId] = React.useState(null) + const [pickerMode, setPickerMode] = React.useState(null) + const [isFormOpen, setIsFormOpen] = React.useState(false) + const [initialHpTarget, setInitialHpTarget] = React.useState<{ sourceKind: OwlbearRoomNpcSourceKind; monster: Monster } | null>(null) + const [pendingId, setPendingId] = React.useState(null) + const [npcToRemove, setNpcToRemove] = React.useState(null) + const [isCreatingNpc, setIsCreatingNpc] = React.useState(false) + const [isLinking, setIsLinking] = React.useState(false) + + const filteredItems = React.useMemo(() => { + const available = items.filter((item) => item.source) + if (!search.trim()) return items + const fuse = new Fuse(available, { + keys: ["source.name", "source.originalName", "source.source"], + threshold: 0.35, + ignoreLocation: true, + }) + return fuse.search(search).map((result) => result.item) + }, [items, search]) + + const handleSelectMonster = React.useCallback((monster: Monster, sourceKind: OwlbearRoomNpcSourceKind) => { + setPickerMode(null) + const staticHp = parseStaticHpValue(monster.hitPointsFormula) + if (staticHp !== null) { + void (async () => { + setIsLinking(true) + try { + await linkNpc({ sourceKind, sourceId: monster._id, hpCurrent: staticHp, hpMax: staticHp }) + toast.success(`${monster.name} adicionado à sala.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Não foi possível adicionar NPC à sala.") + } finally { + setIsLinking(false) + } + })() + return + } + + setInitialHpTarget({ sourceKind, monster }) + }, [linkNpc]) + + const handleConfirmInitialHp = React.useCallback(async (sourceKind: OwlbearRoomNpcSourceKind, monster: Monster, hp: number) => { + setIsLinking(true) + try { + await linkNpc({ sourceKind, sourceId: monster._id, hpCurrent: hp, hpMax: hp }) + setInitialHpTarget(null) + toast.success(`${monster.name} adicionado à sala.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Não foi possível adicionar NPC à sala.") + } finally { + setIsLinking(false) + } + }, [linkNpc]) + + const handleCreateNpc = React.useCallback(async (data: CreateMonsterSchema) => { + if (!roomId || !session.sessionToken) return + + setIsCreatingNpc(true) + try { + const npc = await createOwlbearUserNpc(roomId, session.sessionToken, data) + setIsFormOpen(false) + handleSelectMonster(npc, "userNpc") + } finally { + setIsCreatingNpc(false) + } + }, [handleSelectMonster, roomId, session.sessionToken]) + + const handleApplyHpDelta = React.useCallback((npc: OwlbearRoomNpc, delta: number) => { + const nextHp = Math.max(0, Math.min(npc.hpMax, npc.hpCurrent + delta)) + setPendingId(npc.id) + void updateNpc(npc.id, { hpCurrent: nextHp }) + .catch((error) => { + toast.error(error instanceof Error ? error.message : "Não foi possível atualizar PV.") + }) + .finally(() => setPendingId(null)) + }, [updateNpc]) + + const handleRemove = React.useCallback(() => { + if (!npcToRemove) return + const npcId = npcToRemove.id + setPendingId(npcId) + void removeNpc(npcId) + .then(() => { + setNpcToRemove(null) + setExpandedId((current) => current === npcId ? null : current) + }) + .catch((error) => { + toast.error(error instanceof Error ? error.message : "Não foi possível remover NPC da sala.") + }) + .finally(() => setPendingId(null)) + }, [npcToRemove, removeNpc]) + + if (!isAuthLoaded) { + return ( +
+ +
+ ) + } + + if (!isAuthenticated) { + return ( +
+ +
+ ) + } + + if (session.sessionStatus === "error" || !session.sessionToken || !roomId) { + return + } + + if (session.sessionStatus === "loading" || isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+ +
+ + + + + Adicionar NPC + + + + setIsFormOpen(true)}> + + Criar NPC + + setPickerMode("userNpc")}> + + Meus NPCs + + setPickerMode("monster")}> + + Catálogo de Monstros + + + +
+ + {errorMessage && } + + {filteredItems.length === 0 ? ( +
+
+ +

Nenhum NPC vinculado

+

+ Adicione NPCs ou monstros à sala para controlar PV e consultar a ficha durante a sessão. +

+
+
+ ) : ( +
+ {filteredItems.map((npc) => ( + setExpandedId((current) => current === npc.id ? null : npc.id)} + onApplyHpDelta={handleApplyHpDelta} + onRequestRemove={setNpcToRemove} + /> + ))} +
+ )} +
+ + { + if (!isCreatingNpc) setIsFormOpen(false) + }} + onSave={handleCreateNpc} + isSubmitting={isCreatingNpc} + title="Criar NPC" + subtitle="Crie um NPC na sua conta e adicione-o à sala do Owlbear." + entityLabel="NPC" + sourceDefault="Homebrew" + /> + + setPickerMode(null)} + onSelect={handleSelectMonster} + /> + + { + if (!isLinking) setInitialHpTarget(null) + }} + onConfirm={handleConfirmInitialHp} + /> + + { + if (!pendingId) setNpcToRemove(null) + }} + onConfirm={handleRemove} + /> +
+ ) +} diff --git a/src/features/owlbear/models/owlbear-room-npc.ts b/src/features/owlbear/models/owlbear-room-npc.ts new file mode 100644 index 00000000..70f01a97 --- /dev/null +++ b/src/features/owlbear/models/owlbear-room-npc.ts @@ -0,0 +1,37 @@ +import mongoose, { Schema, Document, Model } from "mongoose" + +export type OwlbearRoomNpcSourceKind = "userNpc" | "monster" + +export interface IOwlbearRoomNpc extends Document { + _id: mongoose.Types.ObjectId + roomId: string + userId: string + sourceKind: OwlbearRoomNpcSourceKind + sourceId: string + hpCurrent: number + hpMax: number + createdAt: Date + updatedAt: Date +} + +const OwlbearRoomNpcSchema = new Schema( + { + roomId: { type: String, required: true, index: true, trim: true }, + userId: { type: String, required: true, index: true }, + sourceKind: { type: String, required: true, enum: ["userNpc", "monster"] }, + sourceId: { type: String, required: true, trim: true }, + hpCurrent: { type: Number, required: true, min: 0, default: 0 }, + hpMax: { type: Number, required: true, min: 0, default: 0 }, + }, + { + timestamps: true, + collection: "owlbear_room_npcs", + }, +) + +OwlbearRoomNpcSchema.index({ roomId: 1, userId: 1, updatedAt: -1 }) +OwlbearRoomNpcSchema.index({ roomId: 1, sourceKind: 1, sourceId: 1 }) + +export const OwlbearRoomNpc: Model = + (mongoose.models.OwlbearRoomNpc as Model) + || mongoose.model("OwlbearRoomNpc", OwlbearRoomNpcSchema) diff --git a/src/features/owlbear/owlbear-shell.tsx b/src/features/owlbear/owlbear-shell.tsx index b5d677ef..96dffca0 100644 --- a/src/features/owlbear/owlbear-shell.tsx +++ b/src/features/owlbear/owlbear-shell.tsx @@ -12,6 +12,7 @@ import { CatalogDashboardFrame } from "./catalog-dashboard-frame" import { OwlbearDiceTab } from "./owlbear-dice-tab" import { OwlbearPlayerSheetTab } from "./player-sheet-tab" import { OwlbearGmSheetsTab } from "./gm-sheets-tab" +import { OwlbearGmNpcsTab } from "./gm-npcs-tab" import { OwlbearGmSceneController } from "./gm-scene-controller" import type { OwlbearRole, OwlbearTabId } from "./types" @@ -48,17 +49,6 @@ function getTabsForRole(role: OwlbearRole | null) { }] satisfies TabDefinition[] } -function PlaceholderPanel({ title, description }: { title: string; description: string }) { - return ( -
-
-

{title}

-

{description}

-
-
- ) -} - function RuntimeBanner({ status, }: { @@ -148,7 +138,14 @@ export function OwlbearShell() { )} {tabs.some((tab) => tab.id === "npcs") && (
- + {activeTab === "npcs" && ( + + )}
)}
diff --git a/src/features/owlbear/room-npcs-api.ts b/src/features/owlbear/room-npcs-api.ts new file mode 100644 index 00000000..97ab9c4e --- /dev/null +++ b/src/features/owlbear/room-npcs-api.ts @@ -0,0 +1,91 @@ +import type { CreateMonsterInput, Monster } from "@/features/monsters/types/monsters.types" + +export type OwlbearRoomNpcSourceKind = "userNpc" | "monster" + +export interface OwlbearRoomNpc { + id: string + _id: string + roomId: string + sourceKind: OwlbearRoomNpcSourceKind + sourceId: string + hpCurrent: number + hpMax: number + createdAt: string + updatedAt: string + source: Monster | null +} + +function authHeaders(sessionToken: string) { + return { + Authorization: `Bearer ${sessionToken}`, + } +} + +async function parseJsonError(response: Response, fallback: string) { + const error = await response.json().catch(() => ({})) + return new Error(error.error || error.message || fallback) +} + +export async function fetchOwlbearRoomNpcs(roomId: string, sessionToken: string): Promise { + const response = await fetch(`/api/owlbear/rooms/${encodeURIComponent(roomId)}/npcs`, { + headers: authHeaders(sessionToken), + }) + if (!response.ok) throw await parseJsonError(response, "Erro ao buscar NPCs da sala") + const data = await response.json() + return data.items ?? [] +} + +export async function linkOwlbearRoomNpc(roomId: string, sessionToken: string, input: { + sourceKind: OwlbearRoomNpcSourceKind + sourceId: string + hpCurrent: number + hpMax: number +}): Promise { + const response = await fetch(`/api/owlbear/rooms/${encodeURIComponent(roomId)}/npcs`, { + method: "POST", + headers: { + ...authHeaders(sessionToken), + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }) + if (!response.ok) throw await parseJsonError(response, "Erro ao vincular NPC à sala") + return response.json() +} + +export async function createOwlbearUserNpc(roomId: string, sessionToken: string, input: CreateMonsterInput): Promise { + const response = await fetch(`/api/owlbear/rooms/${encodeURIComponent(roomId)}/npcs/user-npcs`, { + method: "POST", + headers: { + ...authHeaders(sessionToken), + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }) + if (!response.ok) throw await parseJsonError(response, "Erro ao criar NPC") + return response.json() +} + +export async function patchOwlbearRoomNpc(roomId: string, sessionToken: string, npcId: string, input: { + hpCurrent?: number + hpMax?: number +}): Promise { + const response = await fetch(`/api/owlbear/rooms/${encodeURIComponent(roomId)}/npcs/${encodeURIComponent(npcId)}`, { + method: "PATCH", + headers: { + ...authHeaders(sessionToken), + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }) + if (!response.ok) throw await parseJsonError(response, "Erro ao atualizar NPC da sala") + return response.json() +} + +export async function deleteOwlbearRoomNpc(roomId: string, sessionToken: string, npcId: string): Promise { + const response = await fetch(`/api/owlbear/rooms/${encodeURIComponent(roomId)}/npcs/${encodeURIComponent(npcId)}`, { + method: "DELETE", + headers: authHeaders(sessionToken), + }) + if (!response.ok) throw await parseJsonError(response, "Erro ao remover NPC da sala") +} diff --git a/src/features/owlbear/server/room-npc-routes.ts b/src/features/owlbear/server/room-npc-routes.ts new file mode 100644 index 00000000..c547f5a8 --- /dev/null +++ b/src/features/owlbear/server/room-npc-routes.ts @@ -0,0 +1,198 @@ +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" +import dbConnect from "@/core/database/db" +import { createMonsterSchema } from "@/features/monsters/api/validation" +import { MonsterModel } from "@/features/monsters/models/monster" +import { UserNpcModel } from "@/features/monsters/models/user-npc" +import { getMonsterXp } from "@/features/monsters/utils/monster-calculations" +import { OwlbearRoomNpc, type OwlbearRoomNpcSourceKind } from "../models/owlbear-room-npc" +import { OwlbearHttpError, owlbearErrorResponse, requireOwlbearSession } from "./auth" +import { isAnonymousGmSessionUserId } from "./session-service" + +const LinkRoomNpcSchema = z.object({ + sourceKind: z.enum(["userNpc", "monster"]), + sourceId: z.string().trim().min(1), + hpCurrent: z.coerce.number().int().min(0), + hpMax: z.coerce.number().int().min(0), +}) + +const PatchRoomNpcSchema = z.object({ + hpCurrent: z.coerce.number().int().min(0).optional(), + hpMax: z.coerce.number().int().min(0).optional(), +}) + +function serializeMonsterLike(value: { toObject?: () => Record } | Record) { + const base = typeof value.toObject === "function" ? value.toObject() : value + return { + ...base, + id: String(base._id), + _id: String(base._id), + savingThrows: base.savingThrows instanceof Map ? Object.fromEntries(base.savingThrows) : base.savingThrows || {}, + skills: base.skills instanceof Map ? Object.fromEntries(base.skills) : base.skills || {}, + } +} + +function serializeRoomNpc(entry: { toObject?: () => Record } | Record, source: Record | null) { + const base = typeof entry.toObject === "function" ? entry.toObject() : entry + return { + id: String(base._id), + _id: String(base._id), + roomId: String(base.roomId), + sourceKind: base.sourceKind as OwlbearRoomNpcSourceKind, + sourceId: String(base.sourceId), + hpCurrent: Number(base.hpCurrent ?? 0), + hpMax: Number(base.hpMax ?? 0), + createdAt: base.createdAt instanceof Date ? base.createdAt.toISOString() : String(base.createdAt ?? ""), + updatedAt: base.updatedAt instanceof Date ? base.updatedAt.toISOString() : String(base.updatedAt ?? ""), + source, + } +} + +async function requireRoomNpcGm(req: NextRequest, roomId: string) { + const session = await requireOwlbearSession(req) + if (session.owlbearRole !== "GM") { + throw new OwlbearHttpError(403, "Apenas o mestre pode gerenciar NPCs da sala") + } + if (session.roomId !== roomId) { + throw new OwlbearHttpError(403, "Sessão Owlbear não corresponde a esta sala") + } + if (isAnonymousGmSessionUserId(session.userId)) { + throw new OwlbearHttpError(401, "Faça login no Dndicas para gerenciar NPCs da sala") + } + return session +} + +async function findSource(sourceKind: OwlbearRoomNpcSourceKind, sourceId: string, userId: string) { + if (sourceKind === "userNpc") { + return UserNpcModel.findOne({ _id: sourceId, userId }) + } + return MonsterModel.findById(sourceId) +} + +async function loadSources(entries: Array<{ sourceKind: OwlbearRoomNpcSourceKind; sourceId: string }>, userId: string) { + const userNpcIds = entries.filter((entry) => entry.sourceKind === "userNpc").map((entry) => entry.sourceId) + const monsterIds = entries.filter((entry) => entry.sourceKind === "monster").map((entry) => entry.sourceId) + const [userNpcs, monsters] = await Promise.all([ + userNpcIds.length > 0 ? UserNpcModel.find({ _id: { $in: userNpcIds }, userId }) : Promise.resolve([]), + monsterIds.length > 0 ? MonsterModel.find({ _id: { $in: monsterIds } }) : Promise.resolve([]), + ]) + + const sources = new Map>() + userNpcs.forEach((source) => sources.set(`userNpc:${String(source._id)}`, serializeMonsterLike(source))) + monsters.forEach((source) => sources.set(`monster:${String(source._id)}`, serializeMonsterLike(source))) + return sources +} + +export async function getOwlbearRoomNpcs(req: NextRequest, roomId: string) { + try { + const session = await requireRoomNpcGm(req, roomId) + await dbConnect() + + const entries = await OwlbearRoomNpc.find({ roomId, userId: session.userId }).sort({ updatedAt: -1 }) + const sources = await loadSources(entries.map((entry) => ({ + sourceKind: entry.sourceKind, + sourceId: entry.sourceId, + })), session.userId) + + return NextResponse.json({ + items: entries.map((entry) => serializeRoomNpc(entry, sources.get(`${entry.sourceKind}:${entry.sourceId}`) ?? null)), + }) + } catch (error) { + return owlbearErrorResponse(error, "[API] GET /api/owlbear/rooms/[roomId]/npcs error:") + } +} + +export async function postOwlbearRoomNpc(req: NextRequest, roomId: string) { + try { + const session = await requireRoomNpcGm(req, roomId) + const body = await req.json() + const parsed = LinkRoomNpcSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Dados inválidos", details: parsed.error.flatten() }, { status: 400 }) + } + + await dbConnect() + const source = await findSource(parsed.data.sourceKind, parsed.data.sourceId, session.userId) + if (!source) { + return NextResponse.json({ error: "NPC ou monstro não encontrado" }, { status: 404 }) + } + + const hpMax = parsed.data.hpMax + const hpCurrent = Math.max(0, Math.min(hpMax, parsed.data.hpCurrent)) + const entry = await OwlbearRoomNpc.create({ + roomId, + userId: session.userId, + sourceKind: parsed.data.sourceKind, + sourceId: parsed.data.sourceId, + hpCurrent, + hpMax, + }) + + return NextResponse.json(serializeRoomNpc(entry, serializeMonsterLike(source)), { status: 201 }) + } catch (error) { + return owlbearErrorResponse(error, "[API] POST /api/owlbear/rooms/[roomId]/npcs error:") + } +} + +export async function postOwlbearRoomUserNpc(req: NextRequest, roomId: string) { + try { + const session = await requireRoomNpcGm(req, roomId) + const body = await req.json() + const parsed = createMonsterSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Dados inválidos", details: parsed.error.flatten() }, { status: 400 }) + } + + await dbConnect() + const existing = await UserNpcModel.findOne({ userId: session.userId, name: parsed.data.name }) + if (existing) return NextResponse.json({ error: "Você já tem um NPC com este nome." }, { status: 409 }) + + const npc = await UserNpcModel.create({ + ...parsed.data, + userId: session.userId, + experience: getMonsterXp(parsed.data.challengeRating, parsed.data.experienceOverride), + }) + + return NextResponse.json(serializeMonsterLike(npc), { status: 201 }) + } catch (error) { + return owlbearErrorResponse(error, "[API] POST /api/owlbear/rooms/[roomId]/npcs/user-npcs error:") + } +} + +export async function patchOwlbearRoomNpc(req: NextRequest, roomId: string, npcId: string) { + try { + const session = await requireRoomNpcGm(req, roomId) + const body = await req.json() + const parsed = PatchRoomNpcSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Dados inválidos", details: parsed.error.flatten() }, { status: 400 }) + } + + await dbConnect() + const current = await OwlbearRoomNpc.findOne({ _id: npcId, roomId, userId: session.userId }) + if (!current) return NextResponse.json({ error: "NPC da sala não encontrado" }, { status: 404 }) + + const hpMax = parsed.data.hpMax ?? current.hpMax + const hpCurrentInput = parsed.data.hpCurrent ?? current.hpCurrent + current.hpMax = Math.max(0, hpMax) + current.hpCurrent = Math.max(0, Math.min(current.hpMax, hpCurrentInput)) + await current.save() + + const source = await findSource(current.sourceKind, current.sourceId, session.userId) + return NextResponse.json(serializeRoomNpc(current, source ? serializeMonsterLike(source) : null)) + } catch (error) { + return owlbearErrorResponse(error, "[API] PATCH /api/owlbear/rooms/[roomId]/npcs/[npcId] error:") + } +} + +export async function deleteOwlbearRoomNpc(req: NextRequest, roomId: string, npcId: string) { + try { + const session = await requireRoomNpcGm(req, roomId) + await dbConnect() + const deleted = await OwlbearRoomNpc.findOneAndDelete({ _id: npcId, roomId, userId: session.userId }) + if (!deleted) return NextResponse.json({ error: "NPC da sala não encontrado" }, { status: 404 }) + return NextResponse.json({ success: true }) + } catch (error) { + return owlbearErrorResponse(error, "[API] DELETE /api/owlbear/rooms/[roomId]/npcs/[npcId] error:") + } +} diff --git a/src/features/owlbear/server/session-service.ts b/src/features/owlbear/server/session-service.ts index 6777bdfb..02b333ef 100644 --- a/src/features/owlbear/server/session-service.ts +++ b/src/features/owlbear/server/session-service.ts @@ -13,6 +13,10 @@ export function buildAnonymousGmSessionUserId(input: { return `owlbear-gm:${input.roomId}:${input.owlbearPlayerId}` } +export function isAnonymousGmSessionUserId(userId: string) { + return userId.startsWith("owlbear-gm:") +} + export interface OwlbearSessionRecord { id: string userId: string diff --git a/src/features/owlbear/use-owlbear-session.ts b/src/features/owlbear/use-owlbear-session.ts index e25af780..a4af1236 100644 --- a/src/features/owlbear/use-owlbear-session.ts +++ b/src/features/owlbear/use-owlbear-session.ts @@ -37,7 +37,8 @@ export function useOwlbearSession(runtime: OwlbearRuntimeState) { const roomId = runtime.roomId const owlbearPlayerId = runtime.playerId const owlbearRole = runtime.role - const runtimeIdentity = `${roomId}:${owlbearPlayerId}:${owlbearRole}` + const authIdentity = isSignedIn ? "auth" : "anon" + const runtimeIdentity = `${roomId}:${owlbearPlayerId}:${owlbearRole}:${authIdentity}` const identityChanged = lastRuntimeIdentityRef.current !== null && lastRuntimeIdentityRef.current !== runtimeIdentity lastRuntimeIdentityRef.current = runtimeIdentity diff --git a/src/features/owlbear/use-room-npcs.ts b/src/features/owlbear/use-room-npcs.ts new file mode 100644 index 00000000..f77f925c --- /dev/null +++ b/src/features/owlbear/use-room-npcs.ts @@ -0,0 +1,81 @@ +"use client" + +import * as React from "react" +import { + deleteOwlbearRoomNpc, + fetchOwlbearRoomNpcs, + linkOwlbearRoomNpc, + patchOwlbearRoomNpc, + type OwlbearRoomNpc, + type OwlbearRoomNpcSourceKind, +} from "./room-npcs-api" + +interface RoomNpcsState { + items: OwlbearRoomNpc[] + isLoading: boolean + errorMessage: string | null + reload: () => Promise + linkNpc: (input: { sourceKind: OwlbearRoomNpcSourceKind; sourceId: string; hpCurrent: number; hpMax: number }) => Promise + updateNpc: (npcId: string, input: { hpCurrent?: number; hpMax?: number }) => Promise + removeNpc: (npcId: string) => Promise +} + +export function useRoomNpcs(roomId: string | null, sessionToken: string | null, enabled = true): RoomNpcsState { + const [items, setItems] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState(null) + + const load = React.useCallback(async () => { + if (!enabled || !roomId || !sessionToken) { + setItems([]) + setIsLoading(false) + setErrorMessage(null) + return + } + + setIsLoading(true) + setErrorMessage(null) + try { + setItems(await fetchOwlbearRoomNpcs(roomId, sessionToken)) + } catch (error) { + console.error("Failed to load Owlbear room NPCs", error) + setErrorMessage(error instanceof Error ? error.message : "Não foi possível carregar NPCs da sala.") + } finally { + setIsLoading(false) + } + }, [enabled, roomId, sessionToken]) + + React.useEffect(() => { + void load() + }, [load]) + + const linkNpc = React.useCallback(async (input: { sourceKind: OwlbearRoomNpcSourceKind; sourceId: string; hpCurrent: number; hpMax: number }) => { + if (!roomId || !sessionToken) throw new Error("Sessão Owlbear indisponível") + const created = await linkOwlbearRoomNpc(roomId, sessionToken, input) + setItems((current) => [created, ...current]) + return created + }, [roomId, sessionToken]) + + const updateNpc = React.useCallback(async (npcId: string, input: { hpCurrent?: number; hpMax?: number }) => { + if (!roomId || !sessionToken) throw new Error("Sessão Owlbear indisponível") + const updated = await patchOwlbearRoomNpc(roomId, sessionToken, npcId, input) + setItems((current) => current.map((item) => item.id === npcId ? updated : item)) + return updated + }, [roomId, sessionToken]) + + const removeNpc = React.useCallback(async (npcId: string) => { + if (!roomId || !sessionToken) throw new Error("Sessão Owlbear indisponível") + await deleteOwlbearRoomNpc(roomId, sessionToken, npcId) + setItems((current) => current.filter((item) => item.id !== npcId)) + }, [roomId, sessionToken]) + + return { + items, + isLoading, + errorMessage, + reload: load, + linkNpc, + updateNpc, + removeNpc, + } +} diff --git a/src/lib/config/version.ts b/src/lib/config/version.ts index 1c483fc6..e80e725f 100644 --- a/src/lib/config/version.ts +++ b/src/lib/config/version.ts @@ -1 +1 @@ -export const APP_VERSION = "v3.3.4" +export const APP_VERSION = "v3.3.5" diff --git a/tests/frontend/dice-roller/hp-dice.test.ts b/tests/frontend/dice-roller/hp-dice.test.ts new file mode 100644 index 00000000..49dc6e1e --- /dev/null +++ b/tests/frontend/dice-roller/hp-dice.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" +import { parseHpDiceFormula, parseStaticHpValue } from "@/features/dice-roller/utils/hp-dice" + +describe("hp dice utilities", () => { + it("parses static HP values", () => { + expect(parseStaticHpValue("42")).toBe(42) + expect(parseStaticHpValue(" 7 ")).toBe(7) + expect(parseStaticHpValue("2d8 + 2")).toBeNull() + }) + + it("parses dice terms and fixed modifiers", () => { + expect(parseHpDiceFormula("2d8 + 2")).toEqual({ + terms: [{ dice: "d8", quantity: 2 }], + modifier: 2, + }) + expect(parseHpDiceFormula("d6-1")).toEqual({ + terms: [{ dice: "d6", quantity: 1 }], + modifier: -1, + }) + }) + + it("rejects unsupported or subtractive dice expressions", () => { + expect(parseHpDiceFormula("2d3 + 1")).toBeNull() + expect(parseHpDiceFormula("2d8 - 1d6")).toBeNull() + expect(parseHpDiceFormula("especial")).toBeNull() + }) +}) diff --git a/tests/owlbear/backend/room-npc-routes.test.ts b/tests/owlbear/backend/room-npc-routes.test.ts new file mode 100644 index 00000000..c9370250 --- /dev/null +++ b/tests/owlbear/backend/room-npc-routes.test.ts @@ -0,0 +1,163 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { describe, expect, it, vi } from "vitest" +import { makeJsonRequest, readJson } from "../../backend/helpers/http" +import { importFresh } from "../../backend/helpers/module" + +const sessionMock = vi.hoisted(() => ({ + value: { + sessionId: "session-1", + userId: "user-1", + roomId: "room-1", + owlbearPlayerId: "player-1", + owlbearRole: "GM" as "GM" | "PLAYER", + }, +})) + +class TestOwlbearHttpError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +vi.mock("@/features/owlbear/server/auth", () => ({ + OwlbearHttpError: TestOwlbearHttpError, + requireOwlbearSession: vi.fn(() => Promise.resolve(sessionMock.value)), + owlbearErrorResponse: (error: unknown) => { + if (error instanceof TestOwlbearHttpError) { + return Response.json({ error: error.message }, { status: error.status }) + } + return Response.json({ error: "Erro interno do servidor" }, { status: 500 }) + }, +})) + +function mockDb() { + vi.doMock("@/core/database/db", () => ({ default: vi.fn().mockResolvedValue(undefined) })) +} + +function mockSessionService() { + vi.doMock("@/features/owlbear/server/session-service", () => ({ + isAnonymousGmSessionUserId: (userId: string) => userId.startsWith("owlbear-gm:"), + })) +} + +describe("Owlbear room NPC routes", () => { + it("blocks PLAYER sessions", async () => { + sessionMock.value = { ...sessionMock.value, owlbearRole: "PLAYER" } + mockDb() + mockSessionService() + + const mod = await importFresh("@/app/api/owlbear/rooms/[roomId]/npcs/route") + const response = await mod.GET(new Request("http://localhost/api/owlbear/rooms/room-1/npcs") as any, { params: Promise.resolve({ roomId: "room-1" }) }) + + expect(response.status).toBe(403) + }) + + it("blocks sessions from another room", async () => { + sessionMock.value = { ...sessionMock.value, owlbearRole: "GM", roomId: "other-room" } + mockDb() + mockSessionService() + + const mod = await importFresh("@/app/api/owlbear/rooms/[roomId]/npcs/route") + const response = await mod.GET(new Request("http://localhost/api/owlbear/rooms/room-1/npcs") as any, { params: Promise.resolve({ roomId: "room-1" }) }) + + expect(response.status).toBe(403) + }) + + it("blocks anonymous GM sessions", async () => { + sessionMock.value = { ...sessionMock.value, owlbearRole: "GM", roomId: "room-1", userId: "owlbear-gm:room-1:player-1" } + mockDb() + mockSessionService() + + const mod = await importFresh("@/app/api/owlbear/rooms/[roomId]/npcs/route") + const response = await mod.GET(new Request("http://localhost/api/owlbear/rooms/room-1/npcs") as any, { params: Promise.resolve({ roomId: "room-1" }) }) + + expect(response.status).toBe(401) + }) + + it("creates a room link and clamps hpCurrent to hpMax", async () => { + sessionMock.value = { ...sessionMock.value, owlbearRole: "GM", roomId: "room-1", userId: "user-1" } + const source = { _id: "monster-1", name: "Lobo", savingThrows: {}, skills: {} } + const created = { + _id: "room-npc-1", + roomId: "room-1", + sourceKind: "monster", + sourceId: "monster-1", + hpCurrent: 10, + hpMax: 10, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + } + const create = vi.fn().mockResolvedValue(created) + + mockDb() + mockSessionService() + vi.doMock("@/features/monsters/models/monster", () => ({ MonsterModel: { findById: vi.fn().mockResolvedValue(source), find: vi.fn() } })) + vi.doMock("@/features/monsters/models/user-npc", () => ({ UserNpcModel: { findOne: vi.fn(), find: vi.fn(), create: vi.fn() } })) + vi.doMock("@/features/owlbear/models/owlbear-room-npc", () => ({ OwlbearRoomNpc: { create } })) + + const mod = await importFresh("@/app/api/owlbear/rooms/[roomId]/npcs/route") + const response = await mod.POST(makeJsonRequest("http://localhost/api/owlbear/rooms/room-1/npcs", { + method: "POST", + body: JSON.stringify({ sourceKind: "monster", sourceId: "monster-1", hpCurrent: 99, hpMax: 10 }), + }) as any, { params: Promise.resolve({ roomId: "room-1" }) }) + const payload = await readJson<{ hpCurrent: number; hpMax: number; source: { name: string } }>(response) + + expect(response.status).toBe(201) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ hpCurrent: 10, hpMax: 10 })) + expect(payload.hpCurrent).toBe(10) + expect(payload.source.name).toBe("Lobo") + }) + + it("patches HP with server-side clamping", async () => { + sessionMock.value = { ...sessionMock.value, owlbearRole: "GM", roomId: "room-1", userId: "user-1" } + const current = { + _id: "room-npc-1", + roomId: "room-1", + sourceKind: "monster", + sourceId: "monster-1", + hpCurrent: 8, + hpMax: 12, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + save: vi.fn().mockResolvedValue(undefined), + } + + mockDb() + mockSessionService() + vi.doMock("@/features/monsters/models/monster", () => ({ MonsterModel: { findById: vi.fn().mockResolvedValue({ _id: "monster-1", name: "Lobo" }), find: vi.fn() } })) + vi.doMock("@/features/monsters/models/user-npc", () => ({ UserNpcModel: { findOne: vi.fn(), find: vi.fn(), create: vi.fn() } })) + vi.doMock("@/features/owlbear/models/owlbear-room-npc", () => ({ OwlbearRoomNpc: { findOne: vi.fn().mockResolvedValue(current) } })) + + const mod = await importFresh("@/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route") + const response = await mod.PATCH(makeJsonRequest("http://localhost/api/owlbear/rooms/room-1/npcs/room-npc-1", { + method: "PATCH", + body: JSON.stringify({ hpCurrent: 99 }), + }) as any, { params: Promise.resolve({ roomId: "room-1", npcId: "room-npc-1" }) }) + const payload = await readJson<{ hpCurrent: number; hpMax: number }>(response) + + expect(response.status).toBe(200) + expect(current.save).toHaveBeenCalled() + expect(payload.hpCurrent).toBe(12) + expect(payload.hpMax).toBe(12) + }) + + it("deletes only the room link", async () => { + sessionMock.value = { ...sessionMock.value, owlbearRole: "GM", roomId: "room-1", userId: "user-1" } + const findOneAndDelete = vi.fn().mockResolvedValue({ _id: "room-npc-1" }) + + mockDb() + mockSessionService() + vi.doMock("@/features/monsters/models/monster", () => ({ MonsterModel: { findById: vi.fn(), find: vi.fn() } })) + vi.doMock("@/features/monsters/models/user-npc", () => ({ UserNpcModel: { findOne: vi.fn(), find: vi.fn(), create: vi.fn() } })) + vi.doMock("@/features/owlbear/models/owlbear-room-npc", () => ({ OwlbearRoomNpc: { findOneAndDelete } })) + + const mod = await importFresh("@/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route") + const response = await mod.DELETE(new Request("http://localhost/api/owlbear/rooms/room-1/npcs/room-npc-1") as any, { params: Promise.resolve({ roomId: "room-1", npcId: "room-npc-1" }) }) + + expect(response.status).toBe(200) + expect(findOneAndDelete).toHaveBeenCalledWith({ _id: "room-npc-1", roomId: "room-1", userId: "user-1" }) + }) +}) diff --git a/tests/owlbear/frontend/gm-npcs-tab.test.tsx b/tests/owlbear/frontend/gm-npcs-tab.test.tsx new file mode 100644 index 00000000..72b774f4 --- /dev/null +++ b/tests/owlbear/frontend/gm-npcs-tab.test.tsx @@ -0,0 +1,251 @@ +import * as React from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { getNpcHpBarColor, OwlbearGmNpcsTab } from "@/features/owlbear/gm-npcs-tab" +import type { OwlbearRoomNpc } from "@/features/owlbear/room-npcs-api" +import type { Monster } from "@/features/monsters/types/monsters.types" + +const useRoomNpcsMock = vi.hoisted(() => vi.fn()) +const useInfiniteNpcsMock = vi.hoisted(() => vi.fn()) +const useInfiniteMonstersMock = vi.hoisted(() => vi.fn()) + +vi.mock("@/features/owlbear/use-room-npcs", () => ({ + useRoomNpcs: (...args: unknown[]) => useRoomNpcsMock(...args), +})) + +vi.mock("@/features/monsters/api/npcs-queries", () => ({ + useInfiniteNpcs: (...args: unknown[]) => useInfiniteNpcsMock(...args), +})) + +vi.mock("@/features/monsters/api/monsters-queries", () => ({ + useInfiniteMonsters: (...args: unknown[]) => useInfiniteMonstersMock(...args), +})) + +vi.mock("@/components/ui/search-input", () => ({ + SearchInput: ({ value, onChange, placeholder }: { value: string; onChange: (value: string) => void; placeholder?: string }) => ( + onChange(event.target.value)} /> + ), +})) + +vi.mock("@/components/ui/glass-modal", () => ({ + GlassModal: ({ open, children }: { open: boolean; children: React.ReactNode }) => open ?
{children}
: null, + GlassModalContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassModalDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + GlassModalFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassModalHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassModalTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +vi.mock("@/components/ui/glass-dropdown-menu", () => ({ + GlassDropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassDropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + GlassDropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassDropdownMenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: () => void }) => ( + + ), +})) + +vi.mock("@/components/ui/glass-image", () => ({ + GlassImage: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +vi.mock("@/features/monsters/components/npc-preview", () => ({ + NpcPreview: ({ monster }: { monster: Monster }) =>
{monster.name}
, +})) + +vi.mock("@/features/monsters/components/npc-form-modal", () => ({ + NpcFormModal: () => null, +})) + +vi.mock("@/app/(dashboard)/my-sheets/_components/my-sheets-content", () => ({ + MySheetsContent: () =>
Login View
, +})) + +vi.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + motion: { + div: ({ children, initial: _initial, animate: _animate, exit: _exit, transition: _transition, ...props }: React.HTMLAttributes & Record) =>
{children}
, + span: ({ children, initial: _initial, animate: _animate, exit: _exit, transition: _transition, ...props }: React.HTMLAttributes & Record) => {children}, + }, +})) + +const runtime = { + status: "ready" as const, + role: "GM" as const, + roomId: "room-1", + playerId: "player-1", + themeMode: "dark" as const, + sceneReady: true, +} + +const session = { + sessionStatus: "ready" as const, + sessionToken: "token-1", + sessionExpiresAt: "2099-01-01T00:00:00.000Z", +} + +const monster: Monster = { + _id: "monster-1", + id: "monster-1", + name: "Lobo", + originalName: "Wolf", + source: "LDM", + description: "Um lobo cinzento.", + image: "", + status: "active", + type: "beast", + size: "M", + alignment: "unaligned", + armorClass: 13, + hitPointsFormula: "2d8 + 2", + speed: "12m", + attributes: { strength: 12, dexterity: 15, constitution: 12, intelligence: 3, wisdom: 12, charisma: 6 }, + savingThrows: {}, + skills: {}, + senses: {}, + sensesAndLanguages: [], + challengeRating: "1/4", + languages: "—", + damageVulnerabilities: [], + damageResistances: [], + damageImmunities: [], + conditionImmunities: [], + traits: [], + actions: [], + bonusActions: [], + reactions: [], + legendaryActions: [], + lairActions: [], + regionalEffects: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +} + +const bandit = { + ...monster, + _id: "monster-2", + id: "monster-2", + name: "Bandido", + originalName: "Bandit", +} + +function roomNpc(overrides: Partial = {}): OwlbearRoomNpc { + return { + id: "room-npc-1", + _id: "room-npc-1", + roomId: "room-1", + sourceKind: "monster", + sourceId: "monster-1", + hpCurrent: 12, + hpMax: 20, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + source: monster, + ...overrides, + } +} + +function renderTab(props: { isAuthenticated?: boolean; items?: OwlbearRoomNpc[]; updateNpc?: ReturnType; removeNpc?: ReturnType } = {}) { + const updateNpc = props.updateNpc ?? vi.fn().mockResolvedValue(null) + const removeNpc = props.removeNpc ?? vi.fn().mockResolvedValue(undefined) + useRoomNpcsMock.mockReturnValue({ + items: props.items ?? [roomNpc(), roomNpc({ id: "room-npc-2", _id: "room-npc-2", sourceId: "monster-2", hpCurrent: 7, hpMax: 11, source: bandit })], + isLoading: false, + errorMessage: null, + linkNpc: vi.fn(), + updateNpc, + removeNpc, + }) + + render( + + ) + + return { updateNpc, removeNpc } +} + +describe("OwlbearGmNpcsTab", () => { + beforeEach(() => { + vi.clearAllMocks() + useInfiniteNpcsMock.mockReturnValue({ data: { pages: [{ items: [] }] }, isLoading: false, isFetching: false, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn() }) + useInfiniteMonstersMock.mockReturnValue({ data: { pages: [{ items: [] }] }, isLoading: false, isFetching: false, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn() }) + }) + + it("asks the GM to login before using the NPC tab", () => { + renderTab({ isAuthenticated: false }) + + expect(screen.getByTestId("login-view")).toBeInTheDocument() + expect(useRoomNpcsMock).toHaveBeenCalledWith("room-1", "token-1", false) + }) + + it("renders room NPCs with HP and expands the preview", () => { + renderTab() + + expect(screen.getByTestId("gm-room-npcs-table")).toBeInTheDocument() + expect(screen.getByText("Lobo")).toBeInTheDocument() + expect(screen.getByText("12/20 PV")).toBeInTheDocument() + + fireEvent.click(screen.getAllByText("Lobo")[0]) + + expect(screen.getByTestId("npc-preview")).toHaveTextContent("Lobo") + + fireEvent.click(screen.getAllByText("Lobo")[0]) + + expect(screen.queryByTestId("npc-preview")).not.toBeInTheDocument() + }) + + it("maps NPC HP to a continuous red-yellow-green bar color", () => { + expect(getNpcHpBarColor(0, 20)).toBe("rgb(88, 0, 0)") + expect(getNpcHpBarColor(10, 20)).toBe("rgb(234, 179, 8)") + expect(getNpcHpBarColor(20, 20)).toBe("rgb(52, 211, 153)") + }) + + it("renders the calculated HP bar color", () => { + renderTab({ items: [roomNpc({ hpCurrent: 10, hpMax: 20 })] }) + + expect(screen.getByTestId("npc-hp-bar-room-npc-1")).toHaveStyle({ + width: "50%", + backgroundColor: "rgb(234, 179, 8)", + }) + }) + + it("filters linked NPCs with local fuzzy search", () => { + renderTab() + + fireEvent.change(screen.getByLabelText("Buscar NPCs da sala..."), { target: { value: "band" } }) + + expect(screen.getByText("Bandido")).toBeInTheDocument() + expect(screen.queryByText("Lobo")).not.toBeInTheDocument() + }) + + it("applies positive and negative HP deltas with clamping", async () => { + const updateNpc = vi.fn().mockResolvedValue(null) + renderTab({ items: [roomNpc()], updateNpc }) + + const input = screen.getByLabelText("Ajustar vida de Lobo") + fireEvent.change(input, { target: { value: "20" } }) + fireEvent.keyDown(input, { key: "Enter" }) + + await waitFor(() => expect(updateNpc).toHaveBeenCalledWith("room-npc-1", { hpCurrent: 20 })) + + fireEvent.change(input, { target: { value: "-99" } }) + fireEvent.keyDown(input, { key: "Enter" }) + + await waitFor(() => expect(updateNpc).toHaveBeenCalledWith("room-npc-1", { hpCurrent: 0 })) + }) + + it("confirms before removing the NPC from the room", async () => { + const removeNpc = vi.fn().mockResolvedValue(undefined) + renderTab({ items: [roomNpc()], removeNpc }) + + fireEvent.click(screen.getByLabelText("Remover Lobo")) + fireEvent.click(screen.getByRole("button", { name: "Remover" })) + + await waitFor(() => expect(removeNpc).toHaveBeenCalledWith("room-npc-1")) + }) +}) diff --git a/tests/owlbear/frontend/owlbear-shell.test.tsx b/tests/owlbear/frontend/owlbear-shell.test.tsx index 1963d554..390e4807 100644 --- a/tests/owlbear/frontend/owlbear-shell.test.tsx +++ b/tests/owlbear/frontend/owlbear-shell.test.tsx @@ -6,6 +6,7 @@ import { OwlbearShell } from "@/features/owlbear/owlbear-shell" const useSheetListMock = vi.hoisted(() => vi.fn()) const useCreateSheetMock = vi.hoisted(() => vi.fn()) +const useCreateAssistedSheetMock = vi.hoisted(() => vi.fn()) const useSheetMock = vi.hoisted(() => vi.fn()) const useRoomLinkedSheetsMock = vi.hoisted(() => vi.fn()) const clerkState = vi.hoisted(() => ({ @@ -135,6 +136,7 @@ vi.mock("@/features/character-sheets/hooks/use-sheet-list", () => ({ })) vi.mock("@/features/character-sheets/api/character-sheets-queries", () => ({ + useCreateAssistedSheet: () => useCreateAssistedSheetMock(), useCreateSheet: () => useCreateSheetMock(), useSheet: (id: string | null) => useSheetMock(id), })) @@ -194,6 +196,10 @@ vi.mock("@/features/character-sheets/components/sheet-form", () => ({ ), })) +vi.mock("@/app/(dashboard)/my-sheets/_components/assisted-sheet-creation-modal", () => ({ + AssistedSheetCreationModal: () => null, +})) + describe("OwlbearShell", () => { beforeEach(() => { vi.clearAllMocks() @@ -235,6 +241,10 @@ describe("OwlbearShell", () => { mutateAsync: vi.fn(), isPending: false, }) + useCreateAssistedSheetMock.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }) useSheetMock.mockReturnValue({ data: null, isLoading: false, diff --git a/tests/owlbear/frontend/use-owlbear-session.test.tsx b/tests/owlbear/frontend/use-owlbear-session.test.tsx new file mode 100644 index 00000000..65eb2141 --- /dev/null +++ b/tests/owlbear/frontend/use-owlbear-session.test.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { renderHook, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useOwlbearSession } from "@/features/owlbear/use-owlbear-session" +import type { OwlbearRuntimeState } from "@/features/owlbear/types" + +const authState = vi.hoisted(() => ({ + isLoaded: true, + isSignedIn: false, +})) + +const openOwlbearBackendSessionMock = vi.hoisted(() => vi.fn()) + +vi.mock("@/core/hooks/useAuth", () => ({ + useAuth: () => ({ + isLoaded: authState.isLoaded, + isSignedIn: authState.isSignedIn, + }), +})) + +vi.mock("@/features/owlbear/sdk", () => ({ + openOwlbearBackendSession: (...args: unknown[]) => openOwlbearBackendSessionMock(...args), +})) + +const gmRuntime: OwlbearRuntimeState = { + status: "ready", + role: "GM", + roomId: "room-1", + playerId: "player-1", + themeMode: "dark", + sceneReady: true, +} + +const playerRuntime: OwlbearRuntimeState = { + ...gmRuntime, + role: "PLAYER", +} + +describe("useOwlbearSession", () => { + beforeEach(() => { + vi.clearAllMocks() + authState.isLoaded = true + authState.isSignedIn = false + openOwlbearBackendSessionMock + .mockResolvedValueOnce({ token: "token-anon", expiresAt: "2099-01-01T00:00:00.000Z" }) + .mockResolvedValueOnce({ token: "token-auth", expiresAt: "2099-01-01T00:00:00.000Z" }) + }) + + it("reopens a GM Owlbear session when Clerk changes from anonymous to authenticated", async () => { + const { result, rerender } = renderHook(() => useOwlbearSession(gmRuntime)) + + await waitFor(() => expect(result.current.session.sessionToken).toBe("token-anon")) + + authState.isSignedIn = true + rerender() + + await waitFor(() => expect(result.current.session.sessionToken).toBe("token-auth")) + expect(openOwlbearBackendSessionMock).toHaveBeenCalledTimes(2) + expect(openOwlbearBackendSessionMock).toHaveBeenLastCalledWith({ + roomId: "room-1", + owlbearPlayerId: "player-1", + owlbearRole: "GM", + }) + }) + + it("keeps PLAYER sessions idle while anonymous and opens after login", async () => { + const { result, rerender } = renderHook(() => useOwlbearSession(playerRuntime)) + + expect(result.current.session.sessionStatus).toBe("idle") + expect(openOwlbearBackendSessionMock).not.toHaveBeenCalled() + + authState.isSignedIn = true + rerender() + + await waitFor(() => expect(result.current.session.sessionToken).toBe("token-anon")) + expect(openOwlbearBackendSessionMock).toHaveBeenCalledTimes(1) + }) +}) From 5da5f0b0379f7e30f188c4625ad795891afb24b8 Mon Sep 17 00:00:00 2001 From: nandobfer Date: Sat, 6 Jun 2026 02:18:41 +0200 Subject: [PATCH 2/3] implementado vinculo de fichas e overlay de vida --- aicontext/modules/owlbear.md | 9 + public/owlbear-context-menu.svg | 6 + src/app/api/upload/route.ts | 12 +- .../components/sheet-form.tsx | 7 +- .../hooks/use-sheet-auto-save.ts | 7 + src/features/owlbear/gm-npcs-tab.tsx | 48 +- src/features/owlbear/gm-scene-controller.tsx | 598 ++++++++++++--- src/features/owlbear/gm-sheets-tab.tsx | 31 + src/features/owlbear/hp-bar-utils.ts | 43 ++ src/features/owlbear/overlay-sync-events.ts | 22 + src/features/owlbear/sdk.ts | 61 +- src/features/owlbear/types.ts | 19 +- .../frontend/gm-scene-controller.test.tsx | 726 ++++++++++++++++++ tests/owlbear/frontend/hp-bar-utils.test.ts | 76 ++ tests/owlbear/frontend/owlbear-shell.test.tsx | 10 +- 15 files changed, 1530 insertions(+), 145 deletions(-) create mode 100644 public/owlbear-context-menu.svg create mode 100644 src/features/owlbear/hp-bar-utils.ts create mode 100644 src/features/owlbear/overlay-sync-events.ts create mode 100644 tests/owlbear/frontend/gm-scene-controller.test.tsx create mode 100644 tests/owlbear/frontend/hp-bar-utils.test.ts diff --git a/aicontext/modules/owlbear.md b/aicontext/modules/owlbear.md index e1b3b1f5..4eca82fc 100644 --- a/aicontext/modules/owlbear.md +++ b/aicontext/modules/owlbear.md @@ -32,3 +32,12 @@ Quando a action do Owlbear inicializa o contexto do roller, ela combina o jogado ### Carregamento contextual do SDK O SDK do Owlbear deve ser carregado apenas em superfícies Owlbear reais, como `/owlbear/*`, embeds conhecidos ou iframes ativos da integração. O site normal mantém o roller local sem importar o SDK, evitando dependências indevidas de chunks Owlbear fora desse contexto. + +### Context menu de vínculo de token com personagem ou NPC +O `OwlbearGmSceneController` registra context menus para GM com `Vincular a personagem`, `Vincular a NPC` e `Desvincular`. O registro dos menus depende apenas do runtime Owlbear pronto e papel `GM`, não da sessão backend pronta, para evitar que falhas ou delays de sessão escondam o menu. Os filtros do SDK ficam mínimos (`min/max` e `roles`) e a validação de item elegível acontece no `onClick`, porque filtros de `layer`/permissão podem divergir do shape real do item no Owlbear e esconder completamente o botão. Ao clicar em vincular, o controller chama `OBR.action.open()` para abrir a action e mostrar o modal de seleção mesmo quando o painel do Dndicas está fechado, e chama `OBR.player.deselect([tokenId])` para fechar o context menu nativo. Depois que o usuário seleciona personagem ou NPC, o vínculo é sincronizado e a action é fechada com `OBR.action.close()`. + +O vínculo do token fica em `item.metadata["com.dndicas.owlbear/token"]` com `kind: "player" | "npc"`, `refId`, `tokenId`, `overlayIds` e `linkedAt`. Para personagens, `refId` aponta para a `CharacterSheet`; para NPCs, aponta para o `OwlbearRoomNpc` da sala. A seleção de personagem usa a mesma fonte da aba `Fichas` (`useRoomLinkedSheets`) e a seleção de NPC usa a mesma fonte da aba `NPCs` (`useRoomNpcs`). + +O overlay de HP foi simplificado para uma barra visual sem texto, formada por `backdrop` e `bar` em itens de cena anexados ao token. A barra usa `hpCurrent`/`hpMax` da ficha ou NPC vinculado, compartilha `hpPercent` e `getHpBarColor` de `src/features/owlbear/hp-bar-utils.ts`, e interpola vermelho escuro em 0%, amarelo em 50% e verde em 100%. O sync de overlays é debounced, tem trava de reentrância, faz no-op quando posição/largura/cor já estão corretas e recria overlays legados que ainda tenham `role: "label"`, evitando loops de `scene.items.onChange` e rate limit do SDK. Mudanças de HP em `gm-sheets-tab.tsx` e `gm-npcs-tab.tsx` disparam `notifyOwlbearOverlaySync`, que atualiza um cache local no controller e chama sync imediato, reduzindo a dependência de polling ou movimento manual do token. O SVG do context menu é `public/owlbear-context-menu.svg`. + +O modal de seleção de personagem renderiza campos potencialmente ricos (`class`, `race`) com `MentionContent`, evitando HTML bruto em fichas que salvam mentions ou tags geradas pelo editor rico. diff --git a/public/owlbear-context-menu.svg b/public/owlbear-context-menu.svg new file mode 100644 index 00000000..4e1ae919 --- /dev/null +++ b/public/owlbear-context-menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 15cd3b22..1f9fe8ff 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -10,11 +10,11 @@ const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] export const dynamic = "force-dynamic" export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const key = searchParams.get("key") + try { // We allow GET without auth to support public rules viewing images - const { searchParams } = new URL(req.url) - const key = searchParams.get("key") - if (!key) { return NextResponse.json({ error: "Missing key" }, { status: 400 }) } @@ -28,7 +28,11 @@ export async function GET(req: NextRequest) { }, }) } catch (error) { - console.error("Download error:", error) + console.error("Download error:", { + key, + url: req.url, + error, + }) return NextResponse.json({ error: "File not found" }, { status: 404 }) } } diff --git a/src/features/character-sheets/components/sheet-form.tsx b/src/features/character-sheets/components/sheet-form.tsx index 79d1580e..ac240cdc 100644 --- a/src/features/character-sheets/components/sheet-form.tsx +++ b/src/features/character-sheets/components/sheet-form.tsx @@ -18,14 +18,16 @@ import { useSheetAutoSave } from "../hooks/use-sheet-auto-save" import { useCharacterSheetRealtime } from "../hooks/use-character-sheet-realtime" import { useSheetMentionSync } from "../hooks/use-sheet-mention-sync" import { useItems } from "../api/character-sheets-queries" -import type { CharacterSheetFull } from "../types/character-sheet.types" +import type { CharacterSheet, CharacterSheetFull } from "../types/character-sheet.types" import { useMediaQuery } from "@/core/hooks/useMediaQuery" +import type { PatchSheetBody } from "../types/character-sheet.types" interface SheetFormProps { sheet: CharacterSheetFull layoutMode?: "responsive" | "desktop" editMode?: "auto" | "editable" | "read-only" onSlugChange?: (newSlug: string) => void + onFieldPatch?: (field: keyof PatchSheetBody, value: unknown, updated?: CharacterSheet) => void navigateOnSlugChange?: boolean runtimeContext?: "default" | "owlbear" } @@ -35,6 +37,7 @@ export function SheetForm({ layoutMode = "responsive", editMode = "auto", onSlugChange, + onFieldPatch, navigateOnSlugChange = true, runtimeContext = "default", }: SheetFormProps) { @@ -62,7 +65,7 @@ export function SheetForm({ router.replace(`/sheets/${newSlug}`) }, [navigateOnSlugChange, onSlugChange, router]) - const form = useSheetAutoSave(sheet, { onSlugChange: handleSlugChange, disabled: isReadOnly }) + const form = useSheetAutoSave(sheet, { onSlugChange: handleSlugChange, disabled: isReadOnly, onFieldPatch }) const { data: items = [] } = useItems(sheet._id) const headerSections = useSheetHeaderSections({ sheet, form, items, isReadOnly }) const leftSections = useSheetAttributesLeftSections({ sheet, form, isReadOnly }) diff --git a/src/features/character-sheets/hooks/use-sheet-auto-save.ts b/src/features/character-sheets/hooks/use-sheet-auto-save.ts index f2943ad4..ac6451aa 100644 --- a/src/features/character-sheets/hooks/use-sheet-auto-save.ts +++ b/src/features/character-sheets/hooks/use-sheet-auto-save.ts @@ -8,6 +8,7 @@ import type { CharacterSheet, PatchSheetBody } from "../types/character-sheet.ty interface UseSheetAutoSaveOptions { onSlugChange?: (newSlug: string) => void disabled?: boolean + onFieldPatch?: (field: keyof PatchSheetBody, value: unknown, updated?: CharacterSheet) => void } @@ -47,12 +48,14 @@ export function useSheetAutoSave(sheet: CharacterSheet, options?: UseSheetAutoSa // Update local form state immediately setValue(field, value as PatchSheetBody[typeof field], { shouldDirty: true, shouldTouch: true }) + options?.onFieldPatch?.(field, value) // Only patch if we have an ID if (sheet?._id) { const body: PatchSheetBody = { [field]: value as PatchSheetBody[typeof field] } patch(body, { onSuccess: (updated) => { + options?.onFieldPatch?.(field, value, updated) if (field === "name" && updated?.slug && updated.slug !== sheet.slug) { options?.onSlugChange?.(updated.slug) } @@ -77,11 +80,15 @@ export function useSheetAutoSave(sheet: CharacterSheet, options?: UseSheetAutoSa for (const [field, value] of entries) { setValue(field, value, { shouldDirty: true, shouldTouch: true }) + options?.onFieldPatch?.(field, value) } if (sheet?._id) { patch(values, { onSuccess: (updated) => { + for (const [field, value] of entries) { + options?.onFieldPatch?.(field, value, updated) + } if (values.name && updated?.slug && updated.slug !== sheet.slug) { options?.onSlugChange?.(updated.slug) } diff --git a/src/features/owlbear/gm-npcs-tab.tsx b/src/features/owlbear/gm-npcs-tab.tsx index c4b4058b..10313319 100644 --- a/src/features/owlbear/gm-npcs-tab.tsx +++ b/src/features/owlbear/gm-npcs-tab.tsx @@ -27,6 +27,8 @@ import { NpcPreview } from "@/features/monsters/components/npc-preview" import type { CreateMonsterSchema } from "@/features/monsters/api/validation" import type { Monster } from "@/features/monsters/types/monsters.types" import { getMonsterHitPointAverage } from "@/features/monsters/utils/monster-calculations" +import { getHpBarColor, hpPercent } from "./hp-bar-utils" +import { notifyOwlbearOverlaySync } from "./overlay-sync-events" import { createOwlbearUserNpc, type OwlbearRoomNpc, type OwlbearRoomNpcSourceKind } from "./room-npcs-api" import type { OwlbearRuntimeState, OwlbearSessionState } from "./types" import { useRoomNpcs } from "./use-room-npcs" @@ -48,32 +50,12 @@ function InlineStatus({ tone = "neutral", message }: { tone?: "neutral" | "error ) } -function hpPercent(current: number, max: number) { - if (max <= 0) return 0 - return Math.max(0, Math.min(100, (current / max) * 100)) -} - -function interpolateColor(from: [number, number, number], to: [number, number, number], amount: number) { - const clamped = Math.max(0, Math.min(1, amount)) - const [r1, g1, b1] = from - const [r2, g2, b2] = to - const r = Math.round(r1 + (r2 - r1) * clamped) - const g = Math.round(g1 + (g2 - g1) * clamped) - const b = Math.round(b1 + (b2 - b1) * clamped) - return `rgb(${r}, ${g}, ${b})` -} - +/** + * @deprecated Use `getHpBarColor` from `./hp-bar-utils` directly. + * Mantido para compatibilidade com testes existentes. + */ export function getNpcHpBarColor(current: number, max: number) { - const percent = hpPercent(current, max) / 100 - const red: [number, number, number] = [88, 0, 0] - const yellow: [number, number, number] = [234, 179, 8] - const green: [number, number, number] = [52, 211, 153] - - if (percent <= 0.5) { - return interpolateColor(red, yellow, percent / 0.5) - } - - return interpolateColor(yellow, green, (percent - 0.5) / 0.5) + return getHpBarColor(current, max) } function getSourceLabel(sourceKind: OwlbearRoomNpcSourceKind) { @@ -495,7 +477,23 @@ export function OwlbearGmNpcsTab({ const handleApplyHpDelta = React.useCallback((npc: OwlbearRoomNpc, delta: number) => { const nextHp = Math.max(0, Math.min(npc.hpMax, npc.hpCurrent + delta)) setPendingId(npc.id) + notifyOwlbearOverlaySync({ + kind: "npc", + refId: npc.id, + hpCurrent: nextHp, + hpMax: npc.hpMax, + name: npc.source?.name ?? "NPC", + }) void updateNpc(npc.id, { hpCurrent: nextHp }) + .then((updated) => { + notifyOwlbearOverlaySync({ + kind: "npc", + refId: updated.id, + hpCurrent: updated.hpCurrent, + hpMax: updated.hpMax, + name: updated.source?.name ?? npc.source?.name ?? "NPC", + }) + }) .catch((error) => { toast.error(error instanceof Error ? error.message : "Não foi possível atualizar PV.") }) diff --git a/src/features/owlbear/gm-scene-controller.tsx b/src/features/owlbear/gm-scene-controller.tsx index ab23c0a1..fa3609bf 100644 --- a/src/features/owlbear/gm-scene-controller.tsx +++ b/src/features/owlbear/gm-scene-controller.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Loader2, Link2, Unlink2 } from "lucide-react" +import { Loader2, Link2, Skull } from "lucide-react" import { GlassModal, GlassModalContent, @@ -10,22 +10,47 @@ import { GlassModalTitle, } from "@/components/ui/glass-modal" import type { CharacterSheetFull } from "@/features/character-sheets/types/character-sheet.types" +import { MentionContent } from "@/features/rules/components/mention-badge" +import { getHpBarColor, hpPercent } from "./hp-bar-utils" import { clearTokenSheetLink, + fetchOwlbearRoomNpcById, fetchOwlbearSheetById, getOverlayLinkFromItem, getTokenLinkFromItem, loadOwlbearSdk, loadOwlbearSdkModule, + setTokenNpcLink, setTokenSheetLink, updateTokenOverlayIds, } from "./sdk" import { OWLBEAR_TOKEN_METADATA_KEY } from "./config" +import { subscribeOwlbearOverlaySync, type OwlbearOverlaySyncEvent } from "./overlay-sync-events" +import type { OwlbearRoomNpc } from "./room-npcs-api" import type { OwlbearRuntimeState, OwlbearSceneItem, OwlbearSessionState } from "./types" import { useRoomLinkedSheets } from "./use-room-linked-sheets" +import { useRoomNpcs } from "./use-room-npcs" -const LINK_CONTEXT_MENU_ID = "com.dndicas.owlbear.link-sheet" +const LINK_PLAYER_CONTEXT_MENU_ID = "com.dndicas.owlbear.link-player" +const LINK_NPC_CONTEXT_MENU_ID = "com.dndicas.owlbear.link-npc" const UNLINK_CONTEXT_MENU_ID = "com.dndicas.owlbear.unlink-sheet" +const CONTEXT_MENU_ICON = "/owlbear-context-menu.svg" + +/** Largura fixa da barra de HP em pixels na cena Owlbear. */ +const OVERLAY_BAR_WIDTH = 152 +const OVERLAY_BAR_HEIGHT = 14 +const SYNC_DEBOUNCE_MS = 400 +const SYNC_FALLBACK_INTERVAL_MS = 15000 + +type OverlayHpSnapshot = { + hpCurrent: number + hpMax: number + name: string +} + +function getOverlaySyncCacheKey(kind: "player" | "npc", refId: string) { + return `${kind}:${refId}` +} function isTokenEligible(item: OwlbearSceneItem | null | undefined) { if (!item) return false @@ -40,44 +65,67 @@ export function canManageGmScene(runtime: OwlbearRuntimeState, session: OwlbearS function getOverlayPosition(token: OwlbearSceneItem) { const scaleMagnitude = Math.max(Math.abs(token.scale.x || 1), Math.abs(token.scale.y || 1), 1) return { - x: token.position.x, - y: token.position.y - 60 - (scaleMagnitude - 1) * 18, + x: token.position.x - OVERLAY_BAR_WIDTH / 2, + y: token.position.y - 52 - (scaleMagnitude - 1) * 18, } } -function getOverlayLabel(sheet: CharacterSheetFull) { - const current = sheet.hpCurrent ?? 0 - const max = sheet.hpMax ?? 0 - const temp = sheet.hpTemp ?? 0 - return temp > 0 ? `HP ${current}/${max} | THP +${temp}` : `HP ${current}/${max}` +function samePosition(a: { x: number; y: number } | undefined, b: { x: number; y: number }) { + if (!a) return false + return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) +} + +function getShapeFillColor(item: OwlbearSceneItem) { + const candidate = item as OwlbearSceneItem & { + style?: { fillColor?: string } + fillColor?: string + } + return candidate.style?.fillColor ?? candidate.fillColor +} + +function isHtmlContent(value: string) { + return value.includes("<") +} + +function RichFieldValue({ value }: { value: string }) { + if (isHtmlContent(value)) { + return + } + return {value} } -async function buildOverlayItems(token: OwlbearSceneItem, sheet: CharacterSheetFull) { +/** Cria os 2 itens de overlay de barra de HP: backdrop + barra colorida. */ +async function buildOverlayItems( + token: OwlbearSceneItem, + hpCurrent: number, + hpMax: number, + name: string, +) { const sdkModule = await loadOwlbearSdkModule() if (!sdkModule) { throw new Error("Owlbear SDK indisponível para criar overlay") } const position = getOverlayPosition(token) - const overlayMetadataBase = { - tokenId: token.id, - version: 1, - } + const overlayMetadataBase = { tokenId: token.id, version: 1 } + const percent = hpPercent(hpCurrent, hpMax) + const barWidth = Math.max(1, Math.round((percent / 100) * OVERLAY_BAR_WIDTH)) + const barColor = getHpBarColor(hpCurrent, hpMax) const backdrop = sdkModule.buildShape() - .name(`Dndicas HP Backdrop - ${sheet.name}`) + .name(`Dndicas HP Backdrop - ${name}`) .attachedTo(token.id) .layer("TEXT") .disableHit(true) .position(position) - .width(152) - .height(30) + .width(OVERLAY_BAR_WIDTH) + .height(OVERLAY_BAR_HEIGHT) .shapeType("RECTANGLE") .fillColor("#040712") - .fillOpacity(0.82) - .strokeColor("#60a5fa") - .strokeOpacity(0.4) - .strokeWidth(2) + .fillOpacity(0.85) + .strokeColor("#1e293b") + .strokeOpacity(0.6) + .strokeWidth(1) .metadata({ "com.dndicas.owlbear/overlay": { ...overlayMetadataBase, @@ -86,68 +134,100 @@ async function buildOverlayItems(token: OwlbearSceneItem, sheet: CharacterSheetF }) .build() - const label = sdkModule.buildText() - .name(`Dndicas HP Label - ${sheet.name}`) + const bar = sdkModule.buildShape() + .name(`Dndicas HP Bar - ${name}`) .attachedTo(token.id) .layer("TEXT") .disableHit(true) .position(position) - .plainText(getOverlayLabel(sheet)) - .fontSize(16) - .fontWeight(700) - .padding(6) - .textAlign("CENTER") - .textAlignVertical("MIDDLE") - .fillColor("#f8fafc") + .width(barWidth) + .height(OVERLAY_BAR_HEIGHT) + .shapeType("RECTANGLE") + .fillColor(barColor) .fillOpacity(1) - .strokeColor("#020617") - .strokeOpacity(0.75) - .strokeWidth(3) - .width(152) - .height(30) + .strokeWidth(0) + .strokeOpacity(0) .metadata({ "com.dndicas.owlbear/overlay": { ...overlayMetadataBase, - role: "label", + role: "bar", + barWidth, + barColor, }, }) .build() - return [backdrop, label] + return [backdrop, bar] } -async function syncTokenOverlay(token: OwlbearSceneItem, sheet: CharacterSheetFull) { - const sdk = await loadOwlbearSdk() +async function syncTokenOverlay( + sdk: Awaited>, + itemsById: Map, + token: OwlbearSceneItem, + hpCurrent: number, + hpMax: number, + name: string, +) { if (!sdk || !sdk.isAvailable || !sdk.isReady) return const tokenLink = getTokenLinkFromItem(token) if (!tokenLink) return - const currentItems = await sdk.scene.items.getItems() - const itemsById = new Map(currentItems.map((item) => [item.id, item])) const linkedOverlayItems = tokenLink.overlayIds .map((overlayId) => itemsById.get(overlayId)) .filter((item): item is OwlbearSceneItem => Boolean(item)) + const overlayRoles = new Set(linkedOverlayItems.map((item) => getOverlayLinkFromItem(item)?.role)) + const hasRequiredOverlayShape = overlayRoles.has("backdrop") && overlayRoles.has("bar") && linkedOverlayItems.length === 2 - if (linkedOverlayItems.length < 2) { + if (!hasRequiredOverlayShape) { if (linkedOverlayItems.length > 0) { await sdk.scene.items.deleteItems(linkedOverlayItems.map((item) => item.id)) } - const createdOverlays = await buildOverlayItems(token, sheet) + const createdOverlays = await buildOverlayItems(token, hpCurrent, hpMax, name) await sdk.scene.items.addItems(createdOverlays) await updateTokenOverlayIds(token.id, createdOverlays.map((item) => item.id)) return } const position = getOverlayPosition(token) + const percent = hpPercent(hpCurrent, hpMax) + const barWidth = Math.max(1, Math.round((percent / 100) * OVERLAY_BAR_WIDTH)) + const barColor = getHpBarColor(hpCurrent, hpMax) + const needsUpdate = linkedOverlayItems.some((item) => { + const overlayMeta = getOverlayLinkFromItem(item) + if (!overlayMeta) return false + if (item.attachedTo !== token.id || !samePosition(item.position, position)) return true + if (overlayMeta.role !== "bar") return false + if (overlayMeta.barWidth !== barWidth || overlayMeta.barColor !== barColor) return true + if ((item as OwlbearSceneItem & { width?: number }).width !== barWidth) return true + const currentFillColor = getShapeFillColor(item) + return Boolean(currentFillColor) && currentFillColor !== barColor + }) + + if (!needsUpdate) return + await sdk.scene.items.updateItems(linkedOverlayItems, (draft) => { for (const item of draft) { item.attachedTo = token.id item.position = position - if (item.type === "TEXT") { - ;(item as OwlbearSceneItem & { text?: { plainText?: string } }).text = { - ...(item as OwlbearSceneItem & { text?: Record }).text as Record, - plainText: getOverlayLabel(sheet), + + const overlayMeta = (item.metadata["com.dndicas.owlbear/overlay"] as { role?: string } | undefined) + if (overlayMeta?.role === "bar") { + ;(item as OwlbearSceneItem & { width?: number }).width = barWidth + ;(item as OwlbearSceneItem & { style?: Record }).style = { + ...(item as OwlbearSceneItem & { style?: Record }).style, + fillColor: barColor, + } + item.metadata = { + ...item.metadata, + "com.dndicas.owlbear/overlay": { + ...overlayMeta, + tokenId: token.id, + version: 1, + role: "bar", + barWidth, + barColor, + }, } } } @@ -179,7 +259,11 @@ async function cleanupOrphanOverlays(items: OwlbearSceneItem[]) { } } -function TokenLinkDialog({ +// --------------------------------------------------------------------------- +// Dialog: Vincular a personagem +// --------------------------------------------------------------------------- + +function PlayerLinkDialog({ isOpen, sheets, tokenName, @@ -198,7 +282,7 @@ function TokenLinkDialog({ !open && onClose()}> - Vincular ficha ao token + Vincular a personagem {tokenName ? `Selecione qual ficha de jogador vinculada à sala deve controlar o token "${tokenName}".` @@ -223,7 +307,18 @@ function TokenLinkDialog({
{sheet.name}
- Nível {sheet.level} · {sheet.class || "Sem classe"} {sheet.race ? `· ${sheet.race}` : ""} + Nível {sheet.level} + · + + {sheet.race ? ( + <> + · + + + ) : null} +
+
+ {sheet.hpCurrent ?? 0}/{sheet.hpMax ?? 0} PV
{linkingSheetId === sheet._id ? ( @@ -240,6 +335,90 @@ function TokenLinkDialog({ ) } +// --------------------------------------------------------------------------- +// Dialog: Vincular a NPC +// --------------------------------------------------------------------------- + +function NpcLinkDialog({ + isOpen, + npcs, + isLoadingNpcs, + tokenName, + linkingNpcId, + onClose, + onLink, +}: { + isOpen: boolean + npcs: OwlbearRoomNpc[] + isLoadingNpcs: boolean + tokenName: string | null + linkingNpcId: string | null + onClose: () => void + onLink: (npc: OwlbearRoomNpc) => void +}) { + return ( + !open && onClose()}> + + + Vincular a NPC + + {tokenName + ? `Selecione qual NPC da sala deve controlar o token "${tokenName}".` + : "Selecione um NPC da sala para este token."} + + + +
+ {isLoadingNpcs ? ( +
+ +
+ ) : npcs.length === 0 ? ( +
+ Nenhum NPC adicionado à sala. Adicione NPCs pela aba NPCs antes de vincular. +
+ ) : ( + npcs.map((npc) => ( + + )) + )} +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Controller principal +// --------------------------------------------------------------------------- + +type PendingLink = { kind: "player"; token: OwlbearSceneItem } | { kind: "npc"; token: OwlbearSceneItem } + export function OwlbearGmSceneController({ runtime, session, @@ -248,25 +427,43 @@ export function OwlbearGmSceneController({ session: OwlbearSessionState }) { const canManageScene = canManageGmScene(runtime, session) + const canRegisterContextMenus = runtime.status === "ready" && runtime.role === "GM" const { sheets } = useRoomLinkedSheets(session.sessionToken, canManageScene) - const [selectedToken, setSelectedToken] = React.useState(null) - const [linkingSheetId, setLinkingSheetId] = React.useState(null) + const { items: npcs, isLoading: isLoadingNpcs } = useRoomNpcs( + runtime.roomId, + session.sessionToken, + canManageScene, + ) + + const [pendingLink, setPendingLink] = React.useState(null) + const [linkingId, setLinkingId] = React.useState(null) + const overlayHpCacheRef = React.useRef(new Map()) const syncScene = React.useCallback(async () => { - if (runtime.role !== "GM" || session.sessionStatus !== "ready" || !session.sessionToken) return + if (runtime.role !== "GM" || session.sessionStatus !== "ready" || !session.sessionToken || !runtime.roomId) return try { const sdk = await loadOwlbearSdk() if (!sdk || !sdk.isAvailable || !sdk.isReady) return const items = await sdk.scene.items.getItems() + const itemsById = new Map(items.map((item) => [item.id, item])) await cleanupOrphanOverlays(items) const linkedTokens = items.filter((item) => isTokenEligible(item) && getTokenLinkFromItem(item)) - const uniqueSheetIds = Array.from(new Set(linkedTokens.map((item) => getTokenLinkFromItem(item)?.refId).filter((id): id is string => Boolean(id)))) - const sheetMap = new Map() - await Promise.all(uniqueSheetIds.map(async (sheetId) => { + // Agrupa tokens por tipo de vínculo para carregar dados em paralelo + const playerTokens = linkedTokens.filter((t) => getTokenLinkFromItem(t)?.kind === "player") + const npcTokens = linkedTokens.filter((t) => getTokenLinkFromItem(t)?.kind === "npc") + + // Carrega fichas de personagem + const uniqueSheetIds = Array.from(new Set( + playerTokens.map((item) => getTokenLinkFromItem(item)?.refId).filter((id): id is string => Boolean(id)) + )) + const sheetMap = new Map() + await Promise.all(uniqueSheetIds + .filter((sheetId) => !overlayHpCacheRef.current.has(getOverlaySyncCacheKey("player", sheetId))) + .map(async (sheetId) => { try { const sheet = await fetchOwlbearSheetById(sheetId, session.sessionToken!) sheetMap.set(sheetId, sheet) @@ -275,19 +472,109 @@ export function OwlbearGmSceneController({ } })) - for (const token of linkedTokens) { + // Carrega NPCs da sala (uma única chamada para todos) + const uniqueNpcIds = Array.from(new Set( + npcTokens.map((item) => getTokenLinkFromItem(item)?.refId).filter((id): id is string => Boolean(id)) + )) + const npcMap = new Map() + await Promise.all(uniqueNpcIds + .filter((npcId) => !overlayHpCacheRef.current.has(getOverlaySyncCacheKey("npc", npcId))) + .map(async (npcId) => { + try { + const npc = await fetchOwlbearRoomNpcById(runtime.roomId!, npcId, session.sessionToken!) + if (npc) npcMap.set(npcId, npc) + } catch (error) { + console.error("Failed to fetch Owlbear room NPC for overlay sync", error) + } + })) + + // Sincroniza overlays de personagem + for (const token of playerTokens) { const tokenLink = getTokenLinkFromItem(token) if (!tokenLink) continue - + const cached = overlayHpCacheRef.current.get(getOverlaySyncCacheKey("player", tokenLink.refId)) + if (cached) { + await syncTokenOverlay(sdk, itemsById, token, cached.hpCurrent, cached.hpMax, cached.name) + continue + } const sheet = sheetMap.get(tokenLink.refId) if (!sheet) continue - await syncTokenOverlay(token, sheet) + await syncTokenOverlay(sdk, itemsById, token, sheet.hpCurrent ?? 0, sheet.hpMax ?? 0, sheet.name) + } + + // Sincroniza overlays de NPC + for (const token of npcTokens) { + const tokenLink = getTokenLinkFromItem(token) + if (!tokenLink) continue + const cached = overlayHpCacheRef.current.get(getOverlaySyncCacheKey("npc", tokenLink.refId)) + if (cached) { + await syncTokenOverlay(sdk, itemsById, token, cached.hpCurrent, cached.hpMax, cached.name) + continue + } + const npc = npcMap.get(tokenLink.refId) + if (!npc) continue + await syncTokenOverlay(sdk, itemsById, token, npc.hpCurrent, npc.hpMax, npc.name) } } catch (error) { console.error("Failed to sync Owlbear token overlays", error) } - }, [runtime.role, session.sessionStatus, session.sessionToken]) + }, [runtime.role, runtime.roomId, session.sessionStatus, session.sessionToken]) + + const syncSceneRef = React.useRef(syncScene) + const syncTimerRef = React.useRef(null) + const isSyncingRef = React.useRef(false) + const hasPendingSyncRef = React.useRef(false) + React.useEffect(() => { + syncSceneRef.current = syncScene + }, [syncScene]) + + const runQueuedSync = React.useCallback(async () => { + if (isSyncingRef.current) { + hasPendingSyncRef.current = true + return + } + + isSyncingRef.current = true + try { + await syncSceneRef.current() + } finally { + isSyncingRef.current = false + if (hasPendingSyncRef.current) { + hasPendingSyncRef.current = false + if (syncTimerRef.current !== null) { + window.clearTimeout(syncTimerRef.current) + } + syncTimerRef.current = window.setTimeout(() => { + syncTimerRef.current = null + void runQueuedSync() + }, SYNC_DEBOUNCE_MS) + } + } + }, []) + + const requestSyncScene = React.useCallback((delayMs = SYNC_DEBOUNCE_MS) => { + if (syncTimerRef.current !== null) { + window.clearTimeout(syncTimerRef.current) + } + syncTimerRef.current = window.setTimeout(() => { + syncTimerRef.current = null + void runQueuedSync() + }, delayMs) + }, [runQueuedSync]) + + React.useEffect(() => subscribeOwlbearOverlaySync((event: OwlbearOverlaySyncEvent) => { + const cacheKey = getOverlaySyncCacheKey(event.kind, event.refId) + const previous = overlayHpCacheRef.current.get(cacheKey) + overlayHpCacheRef.current.set(cacheKey, { + hpCurrent: event.hpCurrent, + hpMax: event.hpMax, + name: event.name ?? previous?.name ?? (event.kind === "npc" ? "NPC" : "Personagem"), + }) + requestSyncScene(0) + }), [requestSyncScene]) + + // Registra o listener de cena e o polling de sincronização React.useEffect(() => { if (!canManageScene) return @@ -299,25 +586,30 @@ export function OwlbearGmSceneController({ if (!sdk || !sdk.isAvailable || !sdk.isReady || !active) return unsubscribeItems = sdk.scene.items.onChange(() => { - void syncScene() + requestSyncScene() }) || (() => undefined) })() const intervalId = window.setInterval(() => { - void syncScene() - }, 2000) + requestSyncScene(0) + }, SYNC_FALLBACK_INTERVAL_MS) - void syncScene() + requestSyncScene(0) return () => { active = false window.clearInterval(intervalId) + if (syncTimerRef.current !== null) { + window.clearTimeout(syncTimerRef.current) + syncTimerRef.current = null + } unsubscribeItems?.() } - }, [canManageScene, syncScene]) + }, [canManageScene, requestSyncScene]) + // Registra os itens de context menu React.useEffect(() => { - if (!canManageScene) return + if (!canRegisterContextMenus) return let active = true @@ -325,47 +617,77 @@ export function OwlbearGmSceneController({ const sdk = await loadOwlbearSdk() if (!sdk || !sdk.isAvailable || !sdk.isReady || !active) return + console.info("[Dndicas Owlbear] Registrando context menus de vínculo", { + role: runtime.role, + roomId: runtime.roomId, + sceneReady: runtime.sceneReady, + }) + + // Mantemos o filtro propositalmente mínimo. Filtros de `layer`/`permissions` + // variam conforme o item real do Owlbear e, quando falham, escondem o menu + // inteiro. A validação de layer/overlay acontece no onClick com isTokenEligible. + // "Vincular a personagem" — aparece para GM com um item selecionado. await sdk.contextMenu.create({ - id: LINK_CONTEXT_MENU_ID, + id: LINK_PLAYER_CONTEXT_MENU_ID, icons: [{ - icon: "/icon-96.png", - label: "Vincular ficha", + icon: CONTEXT_MENU_ICON, + label: "Vincular a personagem", filter: { min: 1, max: 1, - permissions: ["UPDATE"], roles: ["GM"], - some: [ - { key: "layer", value: "CHARACTER" }, - { key: "layer", value: "MOUNT" }, - { key: "layer", value: "PROP" }, - ], }, }], onClick: (context) => { const token = context.items.find((item) => isTokenEligible(item)) if (!token) return - setSelectedToken(token) + setPendingLink({ kind: "player", token }) + void (async () => { + await sdk.action.open() + await sdk.player.deselect([token.id]) + })().catch((error) => { + console.error("Failed to open Owlbear action for player link", error) + }) }, }) + // "Vincular a NPC" — aparece para GM com um item selecionado. + await sdk.contextMenu.create({ + id: LINK_NPC_CONTEXT_MENU_ID, + icons: [{ + icon: CONTEXT_MENU_ICON, + label: "Vincular a NPC", + filter: { + min: 1, + max: 1, + roles: ["GM"], + }, + }], + onClick: (context) => { + const token = context.items.find((item) => isTokenEligible(item)) + if (!token) return + setPendingLink({ kind: "npc", token }) + void (async () => { + await sdk.action.open() + await sdk.player.deselect([token.id]) + })().catch((error) => { + console.error("Failed to open Owlbear action for NPC link", error) + }) + }, + }) + + // "Desvincular" — também usa filtro mínimo; o onClick ignora itens sem vínculo. await sdk.contextMenu.create({ id: UNLINK_CONTEXT_MENU_ID, icons: [{ - icon: "/icon-96.png", - label: "Desvincular ficha", + icon: CONTEXT_MENU_ICON, + label: "Desvincular", filter: { min: 1, max: 1, - permissions: ["UPDATE"], roles: ["GM"], every: [ - { key: ["metadata", OWLBEAR_TOKEN_METADATA_KEY, "kind"], value: "player" }, - ], - some: [ - { key: "layer", value: "CHARACTER" }, - { key: "layer", value: "MOUNT" }, - { key: "layer", value: "PROP" }, + { key: ["metadata", OWLBEAR_TOKEN_METADATA_KEY, "tokenId"], value: undefined, operator: "!=" }, ], }, }], @@ -384,6 +706,12 @@ export function OwlbearGmSceneController({ })() }, }) + + console.info("[Dndicas Owlbear] Context menus de vínculo registrados", { + playerMenuId: LINK_PLAYER_CONTEXT_MENU_ID, + npcMenuId: LINK_NPC_CONTEXT_MENU_ID, + unlinkMenuId: UNLINK_CONTEXT_MENU_ID, + }) })().catch((error) => { console.error("Failed to register Owlbear context menus", error) }) @@ -394,49 +722,103 @@ export function OwlbearGmSceneController({ const sdk = await loadOwlbearSdk() if (!sdk || !sdk.isAvailable || !sdk.isReady) return await Promise.allSettled([ - sdk.contextMenu.remove(LINK_CONTEXT_MENU_ID), + sdk.contextMenu.remove(LINK_PLAYER_CONTEXT_MENU_ID), + sdk.contextMenu.remove(LINK_NPC_CONTEXT_MENU_ID), sdk.contextMenu.remove(UNLINK_CONTEXT_MENU_ID), ]) })() } - }, [canManageScene]) + }, [canRegisterContextMenus, runtime.role, runtime.roomId, runtime.sceneReady]) - const handleLinkSheet = React.useCallback(async (sheet: CharacterSheetFull) => { - if (!selectedToken) return + // Vincula um personagem ao token selecionado + const handleLinkPlayer = React.useCallback(async (sheet: CharacterSheetFull) => { + if (!pendingLink || pendingLink.kind !== "player") return - setLinkingSheetId(sheet._id) + const token = pendingLink.token + setLinkingId(sheet._id) try { const sdk = await loadOwlbearSdk() if (!sdk || !sdk.isAvailable || !sdk.isReady) return - const currentLink = getTokenLinkFromItem(selectedToken) + const currentLink = getTokenLinkFromItem(token) if (currentLink?.overlayIds.length) { await sdk.scene.items.deleteItems(currentLink.overlayIds) } - await setTokenSheetLink(selectedToken.id, sheet._id, []) - await syncScene() - setSelectedToken(null) + await setTokenSheetLink(token.id, sheet._id, []) + overlayHpCacheRef.current.set(getOverlaySyncCacheKey("player", sheet._id), { + hpCurrent: sheet.hpCurrent ?? 0, + hpMax: sheet.hpMax ?? 0, + name: sheet.name, + }) + await runQueuedSync() + setPendingLink(null) + await sdk.action.close() + } catch (error) { + console.error("Failed to link token to player sheet", error) + } finally { + setLinkingId(null) + } + }, [pendingLink, runQueuedSync]) + + // Vincula um NPC ao token selecionado + const handleLinkNpc = React.useCallback(async (npc: OwlbearRoomNpc) => { + if (!pendingLink || pendingLink.kind !== "npc") return + + const token = pendingLink.token + setLinkingId(npc.id) + try { + const sdk = await loadOwlbearSdk() + if (!sdk || !sdk.isAvailable || !sdk.isReady) return + + const currentLink = getTokenLinkFromItem(token) + if (currentLink?.overlayIds.length) { + await sdk.scene.items.deleteItems(currentLink.overlayIds) + } + + await setTokenNpcLink(token.id, npc.id, []) + overlayHpCacheRef.current.set(getOverlaySyncCacheKey("npc", npc.id), { + hpCurrent: npc.hpCurrent, + hpMax: npc.hpMax, + name: npc.source?.name ?? "NPC", + }) + await runQueuedSync() + setPendingLink(null) + await sdk.action.close() } catch (error) { - console.error("Failed to link token to sheet", error) + console.error("Failed to link token to NPC", error) } finally { - setLinkingSheetId(null) + setLinkingId(null) } - }, [selectedToken, syncScene]) + }, [pendingLink, runQueuedSync]) if (runtime.role !== "GM") return null return ( - { - if (linkingSheetId) return - setSelectedToken(null) - }} - onLink={(sheet) => void handleLinkSheet(sheet)} - /> + <> + { + if (linkingId) return + setPendingLink(null) + }} + onLink={(sheet) => void handleLinkPlayer(sheet)} + /> + { + if (linkingId) return + setPendingLink(null) + }} + onLink={(npc) => void handleLinkNpc(npc)} + /> + ) } diff --git a/src/features/owlbear/gm-sheets-tab.tsx b/src/features/owlbear/gm-sheets-tab.tsx index 5daaec51..e624dd21 100644 --- a/src/features/owlbear/gm-sheets-tab.tsx +++ b/src/features/owlbear/gm-sheets-tab.tsx @@ -11,6 +11,7 @@ import { useCharacterSheetRealtime } from "@/features/character-sheets/hooks/use import { CharacterSheetClientProvider } from "@/features/character-sheets/api/character-sheet-client-config" import type { CharacterSheet } from "@/features/character-sheets/types/character-sheet.types" import { MentionContent } from "@/features/rules/components/mention-badge" +import { notifyOwlbearOverlaySync } from "./overlay-sync-events" import type { OwlbearSessionState } from "./types" import { useRoomLinkedSheets } from "./use-room-linked-sheets" @@ -76,6 +77,17 @@ function RealtimeLinkedSheetCard({ navigateOnSlugChange: false, }) + React.useEffect(() => { + if (!liveSheet) return + notifyOwlbearOverlaySync({ + kind: "player", + refId: sheetId, + hpCurrent: liveSheet.hpCurrent ?? 0, + hpMax: liveSheet.hpMax ?? 0, + name: liveSheet.name, + }) + }, [liveSheet?.hpCurrent, liveSheet?.hpMax, liveSheet?.name, sheetId]) + if (!liveSheet) { return (
@@ -244,6 +256,24 @@ function GmSheetsTabContent({ } }, [sheetToUnlink, unlinkSheet]) + const handleSelectedSheetFieldPatch = React.useCallback((field: string, value: unknown, updated?: CharacterSheet) => { + if (!selectedSheet || (field !== "hpCurrent" && field !== "hpMax")) return + const nextHpCurrent = field === "hpCurrent" + ? Number(value) || 0 + : updated?.hpCurrent ?? selectedSheet.hpCurrent ?? 0 + const nextHpMax = field === "hpMax" + ? Number(value) || 0 + : updated?.hpMax ?? selectedSheet.hpMax ?? 0 + + notifyOwlbearOverlaySync({ + kind: "player", + refId: selectedSheet._id, + hpCurrent: nextHpCurrent, + hpMax: nextHpMax, + name: updated?.name ?? selectedSheet.name, + }) + }, [selectedSheet]) + if (session.sessionStatus === "error") { return (
@@ -327,6 +357,7 @@ function GmSheetsTabContent({ sheet={selectedSheet} layoutMode="desktop" editMode="editable" + onFieldPatch={handleSelectedSheetFieldPatch} navigateOnSlugChange={false} onSlugChange={() => undefined} runtimeContext="owlbear" diff --git a/src/features/owlbear/hp-bar-utils.ts b/src/features/owlbear/hp-bar-utils.ts new file mode 100644 index 00000000..f1c15588 --- /dev/null +++ b/src/features/owlbear/hp-bar-utils.ts @@ -0,0 +1,43 @@ +/** + * Utilitários de cálculo e cor para barras de HP. + * Compartilhado entre gm-npcs-tab.tsx e gm-scene-controller.tsx. + */ + +/** + * Calcula o percentual de HP entre 0 e 100, clampeado. + */ +export function hpPercent(current: number, max: number): number { + if (max <= 0) return 0 + return Math.max(0, Math.min(100, (current / max) * 100)) +} + +function interpolateColor( + from: [number, number, number], + to: [number, number, number], + amount: number, +): string { + const clamped = Math.max(0, Math.min(1, amount)) + const [r1, g1, b1] = from + const [r2, g2, b2] = to + const r = Math.round(r1 + (r2 - r1) * clamped) + const g = Math.round(g1 + (g2 - g1) * clamped) + const b = Math.round(b1 + (b2 - b1) * clamped) + return `rgb(${r}, ${g}, ${b})` +} + +/** + * Retorna a cor da barra de HP em função do percentual atual/máximo. + * 0% → vermelho escuro, 50% → amarelo, 100% → verde. + */ +export function getHpBarColor(current: number, max: number): string { + const percent = hpPercent(current, max) / 100 + const red: [number, number, number] = [88, 0, 0] + const yellow: [number, number, number] = [234, 179, 8] + const green: [number, number, number] = [52, 211, 153] + + if (percent <= 0.5) { + return interpolateColor(red, yellow, percent / 0.5) + } + + return interpolateColor(yellow, green, (percent - 0.5) / 0.5) +} diff --git a/src/features/owlbear/overlay-sync-events.ts b/src/features/owlbear/overlay-sync-events.ts new file mode 100644 index 00000000..992f5a43 --- /dev/null +++ b/src/features/owlbear/overlay-sync-events.ts @@ -0,0 +1,22 @@ +export type OwlbearOverlaySyncEvent = { + kind: "player" | "npc" + refId: string + hpCurrent: number + hpMax: number + name?: string +} + +const listeners = new Set<(event: OwlbearOverlaySyncEvent) => void>() + +export function notifyOwlbearOverlaySync(event: OwlbearOverlaySyncEvent) { + for (const listener of listeners) { + listener(event) + } +} + +export function subscribeOwlbearOverlaySync(callback: (event: OwlbearOverlaySyncEvent) => void) { + listeners.add(callback) + return () => { + listeners.delete(callback) + } +} diff --git a/src/features/owlbear/sdk.ts b/src/features/owlbear/sdk.ts index 5e7bd881..ea1b7b1a 100644 --- a/src/features/owlbear/sdk.ts +++ b/src/features/owlbear/sdk.ts @@ -319,13 +319,13 @@ export function parseTokenLinkMetadata(metadata: Record | null } const parsed = value as Partial - if (parsed.kind !== "player" || typeof parsed.refId !== "string" || typeof parsed.tokenId !== "string") { + if ((parsed.kind !== "player" && parsed.kind !== "npc") || typeof parsed.refId !== "string" || typeof parsed.tokenId !== "string") { return null } return { version: typeof parsed.version === "number" ? parsed.version : OWLBEAR_TOKEN_METADATA_VERSION, - kind: "player", + kind: parsed.kind, refId: parsed.refId, tokenId: parsed.tokenId, overlayIds: Array.isArray(parsed.overlayIds) ? parsed.overlayIds.filter((id): id is string => typeof id === "string") : [], @@ -340,7 +340,7 @@ export function parseOverlayMetadata(metadata: Record | null | } const parsed = value as Partial - if (typeof parsed.tokenId !== "string" || (parsed.role !== "backdrop" && parsed.role !== "label")) { + if (typeof parsed.tokenId !== "string" || (parsed.role !== "backdrop" && parsed.role !== "bar" && parsed.role !== "label")) { return null } @@ -348,6 +348,8 @@ export function parseOverlayMetadata(metadata: Record | null | version: typeof parsed.version === "number" ? parsed.version : OWLBEAR_OVERLAY_METADATA_VERSION, tokenId: parsed.tokenId, role: parsed.role, + barWidth: typeof parsed.barWidth === "number" ? parsed.barWidth : undefined, + barColor: typeof parsed.barColor === "string" ? parsed.barColor : undefined, } } @@ -432,3 +434,56 @@ export async function fetchOwlbearSheetById(sheetId: string, sessionToken: strin return response.json() } + +/** + * Busca um NPC específico da sala pelo seu id. + * Internamente lista todos os NPCs da sala e filtra pelo id, + * pois a API atual não tem rota de detalhe individual. + */ +export async function fetchOwlbearRoomNpcById( + roomId: string, + npcId: string, + sessionToken: string, +): Promise<{ hpCurrent: number; hpMax: number; name: string } | null> { + const response = await fetch(`/api/owlbear/rooms/${encodeURIComponent(roomId)}/npcs`, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }) + if (!response.ok) return null + const data = await response.json() + const items: Array<{ id: string; hpCurrent: number; hpMax: number; source: { name: string } | null }> = data.items ?? [] + const found = items.find((item) => item.id === npcId) + if (!found) return null + return { + hpCurrent: found.hpCurrent, + hpMax: found.hpMax, + name: found.source?.name ?? "NPC", + } +} + +/** + * Vincula um token de cena a um NPC da sala (kind="npc"). + * O fluxo futuro de sincronização de HP fará o mesmo que o de personagem, + * mas usando os dados de hpCurrent/hpMax do OwlbearRoomNpc em vez da CharacterSheet. + */ +export async function setTokenNpcLink(tokenId: string, roomNpcId: string, overlayIds: string[] = []) { + const sdk = await loadOwlbearSdk() + if (!sdk || !sdk.isAvailable || !sdk.isReady) { + throw new Error("Owlbear SDK indisponível para vincular token a NPC") + } + + await sdk.scene.items.updateItems([tokenId], (draft) => { + const item = draft[0] + if (!item) return + item.metadata = { + ...item.metadata, + [OWLBEAR_TOKEN_METADATA_KEY]: { + version: OWLBEAR_TOKEN_METADATA_VERSION, + kind: "npc", + refId: roomNpcId, + tokenId, + overlayIds, + linkedAt: new Date().toISOString(), + } satisfies OwlbearTokenLinkMetadata, + } + }) +} diff --git a/src/features/owlbear/types.ts b/src/features/owlbear/types.ts index b30e03d8..7a3e7ec9 100644 --- a/src/features/owlbear/types.ts +++ b/src/features/owlbear/types.ts @@ -36,7 +36,14 @@ export interface OwlbearSessionState { export interface OwlbearTokenLinkMetadata { version: number - kind: "player" + /** + * "player" → vínculo com ficha de personagem (CharacterSheet). + * "npc" → vínculo com NPC/monstro da sala (OwlbearRoomNpc). + * No futuro, o fluxo "npc" repetirá o de "player" mas sincronizando + * com a ficha local do NPC em vez de uma CharacterSheet. + */ + kind: "player" | "npc" + /** _id da CharacterSheet (kind=player) ou id do OwlbearRoomNpc (kind=npc). */ refId: string tokenId: string overlayIds: string[] @@ -46,7 +53,10 @@ export interface OwlbearTokenLinkMetadata { export interface OwlbearOverlayMetadata { version: number tokenId: string - role: "backdrop" | "label" + /** "label" é legado do overlay textual antigo e deve ser removido no próximo sync. */ + role: "backdrop" | "bar" | "label" + barWidth?: number + barColor?: string } export interface OwlbearRuntimeState { @@ -103,10 +113,15 @@ export interface OwlbearSdkLike { onReady: (callback: () => void) => void | (() => void) readonly isReady: boolean isAvailable: boolean + action: { + open: () => Promise + close: () => Promise + } player: { getId: () => Promise getName: () => Promise getRole: () => Promise + deselect: (items?: string[]) => Promise } party: { getPlayers: () => Promise diff --git a/tests/owlbear/frontend/gm-scene-controller.test.tsx b/tests/owlbear/frontend/gm-scene-controller.test.tsx new file mode 100644 index 00000000..007fa101 --- /dev/null +++ b/tests/owlbear/frontend/gm-scene-controller.test.tsx @@ -0,0 +1,726 @@ +import * as React from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { canManageGmScene, OwlbearGmSceneController } from "@/features/owlbear/gm-scene-controller" + +// ───────────────────────────────────────────── +// Hoisted mocks +// ───────────────────────────────────────────── + +const useRoomLinkedSheetsMock = vi.hoisted(() => vi.fn()) +const useRoomNpcsMock = vi.hoisted(() => vi.fn()) + +const sdkMock = vi.hoisted(() => { + const callbacks: Array<() => void> = [] + return { + callbacks, + onReady: vi.fn((callback: () => void) => { + callbacks.push(callback) + return () => undefined + }), + action: { + open: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }, + player: { + getId: vi.fn().mockResolvedValue("player-1"), + getName: vi.fn().mockResolvedValue("Mestre"), + getRole: vi.fn<() => Promise<"GM" | "PLAYER">>(), + deselect: vi.fn().mockResolvedValue(undefined), + }, + party: { + getPlayers: vi.fn().mockResolvedValue([]), + onChange: vi.fn(() => () => undefined), + }, + room: { + id: "room-1", + getMetadata: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockResolvedValue(undefined), + onMetadataChange: vi.fn(() => () => undefined), + }, + contextMenu: { + create: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }, + scene: { + isReady: vi.fn().mockResolvedValue(true), + onReadyChange: vi.fn(() => () => undefined), + items: { + getItems: vi.fn().mockResolvedValue([]), + updateItems: vi.fn().mockResolvedValue(undefined), + addItems: vi.fn().mockResolvedValue(undefined), + deleteItems: vi.fn().mockResolvedValue(undefined), + onChange: vi.fn(() => () => undefined), + }, + }, + theme: { + getTheme: vi.fn().mockResolvedValue({ mode: "DARK" as const }), + onChange: vi.fn(() => () => undefined), + }, + isAvailable: true, + isReady: true, + } +}) + +vi.mock("@owlbear-rodeo/sdk", () => ({ + default: sdkMock, + buildShape: vi.fn(() => ({ + name: vi.fn().mockReturnThis(), + attachedTo: vi.fn().mockReturnThis(), + layer: vi.fn().mockReturnThis(), + disableHit: vi.fn().mockReturnThis(), + position: vi.fn().mockReturnThis(), + width: vi.fn().mockReturnThis(), + height: vi.fn().mockReturnThis(), + shapeType: vi.fn().mockReturnThis(), + fillColor: vi.fn().mockReturnThis(), + fillOpacity: vi.fn().mockReturnThis(), + strokeColor: vi.fn().mockReturnThis(), + strokeOpacity: vi.fn().mockReturnThis(), + strokeWidth: vi.fn().mockReturnThis(), + metadata: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ id: "overlay-mock-" + Math.random(), type: "SHAPE", metadata: {} }), + })), +})) + +vi.mock("@/features/owlbear/sdk", async () => { + const actual = await vi.importActual("@/features/owlbear/sdk") + return { + ...actual, + loadOwlbearSdk: vi.fn(async () => sdkMock), + loadOwlbearSdkModule: vi.fn(async () => ({ + buildShape: vi.fn(() => ({ + name: vi.fn().mockReturnThis(), + attachedTo: vi.fn().mockReturnThis(), + layer: vi.fn().mockReturnThis(), + disableHit: vi.fn().mockReturnThis(), + position: vi.fn().mockReturnThis(), + width: vi.fn().mockReturnThis(), + height: vi.fn().mockReturnThis(), + shapeType: vi.fn().mockReturnThis(), + fillColor: vi.fn().mockReturnThis(), + fillOpacity: vi.fn().mockReturnThis(), + strokeColor: vi.fn().mockReturnThis(), + strokeOpacity: vi.fn().mockReturnThis(), + strokeWidth: vi.fn().mockReturnThis(), + metadata: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ id: "overlay-mock", type: "SHAPE", metadata: {} }), + })), + })), + fetchOwlbearSheetById: vi.fn(async () => ({ + _id: "sheet-1", + name: "Kael", + hpCurrent: 38, + hpMax: 45, + })), + fetchOwlbearRoomNpcById: vi.fn(async () => ({ + name: "Goblin", + hpCurrent: 7, + hpMax: 7, + })), + setTokenSheetLink: vi.fn(async (tokenId: string, sheetId: string, overlayIds: string[] = []) => { + await sdkMock.scene.items.updateItems([tokenId], (draft: Array<{ metadata: Record }>) => { + const item = draft[0] + if (!item) return + item.metadata = { + ...item.metadata, + "com.dndicas.owlbear/token": { + version: 1, + kind: "player", + refId: sheetId, + tokenId, + overlayIds, + linkedAt: "2026-01-01T00:00:00.000Z", + }, + } + }) + }), + setTokenNpcLink: vi.fn(async (tokenId: string, npcId: string, overlayIds: string[] = []) => { + await sdkMock.scene.items.updateItems([tokenId], (draft: Array<{ metadata: Record }>) => { + const item = draft[0] + if (!item) return + item.metadata = { + ...item.metadata, + "com.dndicas.owlbear/token": { + version: 1, + kind: "npc", + refId: npcId, + tokenId, + overlayIds, + linkedAt: "2026-01-01T00:00:00.000Z", + }, + } + }) + }), + updateTokenOverlayIds: vi.fn(async () => undefined), + clearTokenSheetLink: vi.fn(async () => undefined), + } +}) + +vi.mock("@/features/owlbear/use-room-linked-sheets", () => ({ + useRoomLinkedSheets: (...args: unknown[]) => useRoomLinkedSheetsMock(...args), +})) + +vi.mock("@/features/owlbear/use-room-npcs", () => ({ + useRoomNpcs: (...args: unknown[]) => useRoomNpcsMock(...args), +})) + +vi.mock("@/features/rules/components/mention-badge", () => ({ + MentionContent: ({ html }: { html: string }) => ( + {html.replace(/<[^>]*>/g, "")} + ), +})) + +vi.mock("@/components/ui/glass-modal", () => ({ + GlassModal: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + GlassModalContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassModalDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + GlassModalHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + GlassModalTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +// ───────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────── + +const readyGmRuntime = { + status: "ready" as const, + role: "GM" as const, + roomId: "room-1", + playerId: "player-1", + themeMode: "dark" as const, + sceneReady: true, +} + +const readyPlayerRuntime = { ...readyGmRuntime, role: "PLAYER" as const } + +const readySession = { + sessionStatus: "ready" as const, + sessionToken: "token-1", + sessionExpiresAt: "2099-04-20T10:15:00.000Z", +} + +const kaelSheet = { + _id: "sheet-1", + name: "Kael", + level: 5, + class: "Guerreiro", + race: "Humano", + slug: "kael", + userId: "user-1", + hpCurrent: 38, + hpMax: 45, +} + +const goblinNpc = { + id: "npc-1", + _id: "npc-1", + roomId: "room-1", + sourceKind: "monster" as const, + sourceId: "monster-goblin", + hpCurrent: 7, + hpMax: 7, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + source: { name: "Goblin" } as never, +} + +type RegisteredContextMenu = { + id: string + icons: Array<{ label: string }> + onClick?: (context: { items: Array> }) => void +} + +function getRegisteredContextMenus() { + return sdkMock.contextMenu.create.mock.calls.map((call) => call[0] as RegisteredContextMenu) +} + +function getRegisteredContextMenu(id: string) { + return getRegisteredContextMenus().find((menu) => menu.id === id) +} + +// ───────────────────────────────────────────── +// Setup +// ───────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + sdkMock.isAvailable = true + sdkMock.isReady = true + sdkMock.room.id = "room-1" + sdkMock.room.getMetadata.mockResolvedValue({}) + sdkMock.scene.items.getItems.mockResolvedValue([]) + sdkMock.scene.items.updateItems.mockResolvedValue(undefined) + sdkMock.scene.items.addItems.mockResolvedValue(undefined) + sdkMock.scene.items.deleteItems.mockResolvedValue(undefined) + sdkMock.scene.items.onChange.mockReturnValue(() => undefined) + sdkMock.contextMenu.create.mockResolvedValue(undefined) + sdkMock.contextMenu.remove.mockResolvedValue(undefined) + sdkMock.action.open.mockResolvedValue(undefined) + sdkMock.action.close.mockResolvedValue(undefined) + sdkMock.player.deselect.mockResolvedValue(undefined) + + useRoomLinkedSheetsMock.mockReturnValue({ + entries: [], + sheets: [], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + unlinkSheet: vi.fn(), + }) + + useRoomNpcsMock.mockReturnValue({ + items: [], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + linkNpc: vi.fn(), + updateNpc: vi.fn(), + removeNpc: vi.fn(), + }) + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ token: "token-1", expiresAt: "2099-04-20T10:15:00Z" }), { + status: 201, + headers: { "content-type": "application/json" }, + }) + )) +}) + +// ───────────────────────────────────────────── +// Testes +// ───────────────────────────────────────────── + +describe("canManageGmScene", () => { + it("retorna true para GM com sessão pronta", () => { + expect(canManageGmScene(readyGmRuntime, readySession)).toBe(true) + }) + + it("retorna false para PLAYER mesmo com sessão pronta", () => { + expect(canManageGmScene(readyPlayerRuntime, readySession)).toBe(false) + }) + + it("retorna false para GM sem token de sessão", () => { + expect(canManageGmScene(readyGmRuntime, { ...readySession, sessionToken: null })).toBe(false) + }) + + it("retorna false para GM com sessão em loading", () => { + expect(canManageGmScene(readyGmRuntime, { ...readySession, sessionStatus: "loading" })).toBe(false) + }) +}) + +describe("OwlbearGmSceneController — context menus", () => { + it("registra os dois itens de context menu para o GM", async () => { + render() + + await waitFor(() => { + expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3) + }) + + const ids = getRegisteredContextMenus().map((menu) => menu.id) + expect(ids).toContain("com.dndicas.owlbear.link-player") + expect(ids).toContain("com.dndicas.owlbear.link-npc") + expect(ids).toContain("com.dndicas.owlbear.unlink-sheet") + }) + + it("usa o label 'Vincular a personagem' no menu de player", async () => { + render() + + await waitFor(() => { + expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3) + }) + + const playerMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-player") + expect(playerMenu?.icons[0].label).toBe("Vincular a personagem") + }) + + it("usa o label 'Vincular a NPC' no menu de NPC", async () => { + render() + + await waitFor(() => { + expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3) + }) + + const npcMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-npc") + expect(npcMenu?.icons[0].label).toBe("Vincular a NPC") + }) + + it("não registra context menus para PLAYER", async () => { + render() + + await waitFor(() => { + expect(sdkMock.contextMenu.create).not.toHaveBeenCalled() + }) + }) + + it("remove os context menus ao desmontar", async () => { + const { unmount } = render( + + ) + + await waitFor(() => { + expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3) + }) + + unmount() + + await waitFor(() => { + expect(sdkMock.contextMenu.remove).toHaveBeenCalledWith("com.dndicas.owlbear.link-player") + expect(sdkMock.contextMenu.remove).toHaveBeenCalledWith("com.dndicas.owlbear.link-npc") + expect(sdkMock.contextMenu.remove).toHaveBeenCalledWith("com.dndicas.owlbear.unlink-sheet") + }) + }) +}) + +describe("OwlbearGmSceneController — dialog de personagem", () => { + it("exibe o dialog de personagem com lista de fichas da sala", async () => { + useRoomLinkedSheetsMock.mockReturnValue({ + entries: [{ playerId: "p1", sheetId: "sheet-1" }], + sheets: [kaelSheet], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + unlinkSheet: vi.fn(), + }) + + render() + + // Aguarda o registro dos menus e dispara o onClick do menu de player manualmente + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const playerMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-player") + const token = { + id: "token-1", + name: "Goblin", + layer: "CHARACTER", + type: "IMAGE", + visible: true, + locked: false, + createdUserId: "u1", + zIndex: 1, + lastModified: "", + lastModifiedUserId: "u1", + position: { x: 0, y: 0 }, + rotation: 0, + scale: { x: 1, y: 1 }, + metadata: {}, + } + playerMenu?.onClick?.({ items: [token] }) + + expect(await screen.findByRole("heading", { name: "Vincular a personagem" })).toBeInTheDocument() + await waitFor(() => { + expect(sdkMock.action.open).toHaveBeenCalled() + expect(sdkMock.player.deselect).toHaveBeenCalledWith(["token-1"]) + }) + expect(screen.getByText("Kael")).toBeInTheDocument() + expect(screen.getByText("38/45 PV")).toBeInTheDocument() + }) + + it("renderiza campos HTML da ficha com MentionContent", async () => { + useRoomLinkedSheetsMock.mockReturnValue({ + entries: [{ playerId: "p1", sheetId: "sheet-1" }], + sheets: [{ ...kaelSheet, class: "Guerreiro" }], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + unlinkSheet: vi.fn(), + }) + + render() + + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const playerMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-player") + playerMenu?.onClick?.({ + items: [{ + id: "token-1", name: "Token", layer: "CHARACTER", type: "IMAGE", + visible: true, locked: false, createdUserId: "u1", zIndex: 1, + lastModified: "", lastModifiedUserId: "u1", + position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, metadata: {}, + }], + }) + + expect(await screen.findByTestId("mention-content")).toHaveTextContent("Guerreiro") + }) + + it("exibe estado vazio quando não há fichas vinculadas à sala", async () => { + useRoomLinkedSheetsMock.mockReturnValue({ + entries: [], + sheets: [], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + unlinkSheet: vi.fn(), + }) + + render() + + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const playerMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-player") + const token = { + id: "token-1", name: "Goblin", layer: "CHARACTER", type: "IMAGE", + visible: true, locked: false, createdUserId: "u1", zIndex: 1, + lastModified: "", lastModifiedUserId: "u1", + position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, metadata: {}, + } + playerMenu?.onClick?.({ items: [token] }) + + expect(await screen.findByText("Nenhuma ficha de jogador está vinculada a esta sala no momento.")).toBeInTheDocument() + }) + + it("salva kind='player' no metadata ao vincular personagem", async () => { + useRoomLinkedSheetsMock.mockReturnValue({ + entries: [{ playerId: "p1", sheetId: "sheet-1" }], + sheets: [kaelSheet], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + unlinkSheet: vi.fn(), + }) + + vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.includes("character-sheets")) { + return Promise.resolve(new Response(JSON.stringify({ + ...kaelSheet, hpCurrent: 38, hpMax: 45, + }), { status: 200, headers: { "content-type": "application/json" } })) + } + return Promise.resolve(new Response(JSON.stringify({ + token: "token-1", expiresAt: "2099-04-20T10:15:00Z", + }), { status: 201, headers: { "content-type": "application/json" } })) + })) + + render() + + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const playerMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-player") + const token = { + id: "token-1", name: "Herói", layer: "CHARACTER", type: "IMAGE", + visible: true, locked: false, createdUserId: "u1", zIndex: 1, + lastModified: "", lastModifiedUserId: "u1", + position: { x: 100, y: 100 }, rotation: 0, scale: { x: 1, y: 1 }, metadata: {}, + } + playerMenu?.onClick?.({ items: [token] }) + + await screen.findByRole("heading", { name: "Vincular a personagem" }) + fireEvent.click(screen.getByText("Kael")) + + await waitFor(() => { + expect(sdkMock.scene.items.updateItems).toHaveBeenCalledWith( + ["token-1"], + expect.any(Function), + ) + }) + + // Verifica que a função de update salva kind="player" + const updateCall = sdkMock.scene.items.updateItems.mock.calls.find( + (call: unknown[]) => Array.isArray(call[0]) && call[0].includes("token-1") + ) + const draft: Array }> = [{ ...token, metadata: {} }] + updateCall?.[1](draft) + expect(draft[0].metadata["com.dndicas.owlbear/token"]).toMatchObject({ + kind: "player", + refId: "sheet-1", + tokenId: "token-1", + }) + await waitFor(() => { + expect(sdkMock.action.close).toHaveBeenCalled() + }) + }) +}) + +describe("OwlbearGmSceneController — dialog de NPC", () => { + it("exibe o dialog de NPC com lista de NPCs da sala", async () => { + useRoomNpcsMock.mockReturnValue({ + items: [goblinNpc], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + linkNpc: vi.fn(), + updateNpc: vi.fn(), + removeNpc: vi.fn(), + }) + + render() + + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const npcMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-npc") + const token = { + id: "token-2", name: "Token Goblin", layer: "CHARACTER", type: "IMAGE", + visible: true, locked: false, createdUserId: "u1", zIndex: 1, + lastModified: "", lastModifiedUserId: "u1", + position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, metadata: {}, + } + npcMenu?.onClick?.({ items: [token] }) + + expect(await screen.findByRole("heading", { name: "Vincular a NPC" })).toBeInTheDocument() + await waitFor(() => { + expect(sdkMock.action.open).toHaveBeenCalled() + expect(sdkMock.player.deselect).toHaveBeenCalledWith(["token-2"]) + }) + expect(screen.getByText("Goblin")).toBeInTheDocument() + expect(screen.getByText("7/7 PV")).toBeInTheDocument() + }) + + it("exibe estado vazio quando não há NPCs na sala", async () => { + useRoomNpcsMock.mockReturnValue({ + items: [], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + linkNpc: vi.fn(), + updateNpc: vi.fn(), + removeNpc: vi.fn(), + }) + + render() + + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const npcMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-npc") + const token = { + id: "token-2", name: "Token", layer: "CHARACTER", type: "IMAGE", + visible: true, locked: false, createdUserId: "u1", zIndex: 1, + lastModified: "", lastModifiedUserId: "u1", + position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, metadata: {}, + } + npcMenu?.onClick?.({ items: [token] }) + + expect(await screen.findByText(/Nenhum NPC adicionado à sala/)).toBeInTheDocument() + }) + + it("salva kind='npc' no metadata ao vincular NPC", async () => { + useRoomNpcsMock.mockReturnValue({ + items: [goblinNpc], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + linkNpc: vi.fn(), + updateNpc: vi.fn(), + removeNpc: vi.fn(), + }) + + render() + + await waitFor(() => expect(sdkMock.contextMenu.create).toHaveBeenCalledTimes(3)) + + const npcMenu = getRegisteredContextMenu("com.dndicas.owlbear.link-npc") + const token = { + id: "token-2", name: "Token Goblin", layer: "CHARACTER", type: "IMAGE", + visible: true, locked: false, createdUserId: "u1", zIndex: 1, + lastModified: "", lastModifiedUserId: "u1", + position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, metadata: {}, + } + npcMenu?.onClick?.({ items: [token] }) + + await screen.findByRole("heading", { name: "Vincular a NPC" }) + fireEvent.click(screen.getByText("Goblin")) + + await waitFor(() => { + expect(sdkMock.scene.items.updateItems).toHaveBeenCalledWith( + ["token-2"], + expect.any(Function), + ) + }) + + // Verifica que a função de update salva kind="npc" + const updateCall = sdkMock.scene.items.updateItems.mock.calls.find( + (call: unknown[]) => Array.isArray(call[0]) && call[0].includes("token-2") + ) + const draft: Array }> = [{ ...token, metadata: {} }] + updateCall?.[1](draft) + expect(draft[0].metadata["com.dndicas.owlbear/token"]).toMatchObject({ + kind: "npc", + refId: "npc-1", + tokenId: "token-2", + }) + await waitFor(() => { + expect(sdkMock.action.close).toHaveBeenCalled() + }) + }) +}) + +describe("OwlbearGmSceneController — SDK parse de metadata", () => { + it("parseTokenLinkMetadata aceita kind='npc'", async () => { + const { parseTokenLinkMetadata } = await import("@/features/owlbear/sdk") + const result = parseTokenLinkMetadata({ + "com.dndicas.owlbear/token": { + version: 1, + kind: "npc", + refId: "npc-1", + tokenId: "token-1", + overlayIds: [], + }, + }) + expect(result).not.toBeNull() + expect(result?.kind).toBe("npc") + expect(result?.refId).toBe("npc-1") + }) + + it("parseTokenLinkMetadata aceita kind='player'", async () => { + const { parseTokenLinkMetadata } = await import("@/features/owlbear/sdk") + const result = parseTokenLinkMetadata({ + "com.dndicas.owlbear/token": { + version: 1, + kind: "player", + refId: "sheet-1", + tokenId: "token-1", + overlayIds: ["ov-1", "ov-2"], + }, + }) + expect(result?.kind).toBe("player") + expect(result?.overlayIds).toEqual(["ov-1", "ov-2"]) + }) + + it("parseTokenLinkMetadata rejeita kind inválido", async () => { + const { parseTokenLinkMetadata } = await import("@/features/owlbear/sdk") + const result = parseTokenLinkMetadata({ + "com.dndicas.owlbear/token": { + version: 1, + kind: "unknown", + refId: "x", + tokenId: "y", + }, + }) + expect(result).toBeNull() + }) + + it("parseOverlayMetadata aceita role='bar'", async () => { + const { parseOverlayMetadata } = await import("@/features/owlbear/sdk") + const result = parseOverlayMetadata({ + "com.dndicas.owlbear/overlay": { + version: 1, + tokenId: "token-1", + role: "bar", + }, + }) + expect(result?.role).toBe("bar") + }) + + it("parseOverlayMetadata aceita role='backdrop'", async () => { + const { parseOverlayMetadata } = await import("@/features/owlbear/sdk") + const result = parseOverlayMetadata({ + "com.dndicas.owlbear/overlay": { + version: 1, + tokenId: "token-1", + role: "backdrop", + }, + }) + expect(result?.role).toBe("backdrop") + }) + + it("parseOverlayMetadata aceita role='label' como overlay legado removível", async () => { + const { parseOverlayMetadata } = await import("@/features/owlbear/sdk") + const result = parseOverlayMetadata({ + "com.dndicas.owlbear/overlay": { + version: 1, + tokenId: "token-1", + role: "label", + }, + }) + expect(result?.role).toBe("label") + }) +}) diff --git a/tests/owlbear/frontend/hp-bar-utils.test.ts b/tests/owlbear/frontend/hp-bar-utils.test.ts new file mode 100644 index 00000000..a62676ed --- /dev/null +++ b/tests/owlbear/frontend/hp-bar-utils.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import { getHpBarColor, hpPercent } from "@/features/owlbear/hp-bar-utils" + +describe("hpPercent", () => { + it("retorna 100 quando HP está cheio", () => { + expect(hpPercent(20, 20)).toBe(100) + }) + + it("retorna 0 quando HP é zero", () => { + expect(hpPercent(0, 20)).toBe(0) + }) + + it("retorna 50 quando HP está na metade", () => { + expect(hpPercent(10, 20)).toBe(50) + }) + + it("clampeia em 0 quando HP é negativo", () => { + expect(hpPercent(-5, 20)).toBe(0) + }) + + it("clampeia em 100 quando HP excede o máximo", () => { + expect(hpPercent(25, 20)).toBe(100) + }) + + it("retorna 0 quando o máximo é zero (evita divisão por zero)", () => { + expect(hpPercent(0, 0)).toBe(0) + }) + + it("retorna 0 quando o máximo é negativo", () => { + expect(hpPercent(5, -1)).toBe(0) + }) +}) + +describe("getHpBarColor", () => { + it("retorna verde quando HP está cheio (100%)", () => { + const color = getHpBarColor(20, 20) + // Verde: rgb(52, 211, 153) + expect(color).toBe("rgb(52, 211, 153)") + }) + + it("retorna vermelho escuro quando HP está em zero (0%)", () => { + const color = getHpBarColor(0, 20) + // Vermelho: rgb(88, 0, 0) + expect(color).toBe("rgb(88, 0, 0)") + }) + + it("retorna amarelo quando HP está exatamente na metade (50%)", () => { + const color = getHpBarColor(10, 20) + // Amarelo: rgb(234, 179, 8) + expect(color).toBe("rgb(234, 179, 8)") + }) + + it("retorna cor entre vermelho e amarelo quando HP está em 25%", () => { + const color = getHpBarColor(5, 20) + // Interpolação entre vermelho e amarelo a 50% do caminho + // from=[88,0,0] to=[234,179,8] at 0.5 → rgb(161, 90, 4) + expect(color).toBe("rgb(161, 90, 4)") + }) + + it("retorna cor entre amarelo e verde quando HP está em 75%", () => { + const color = getHpBarColor(15, 20) + // Interpolação entre amarelo e verde a 50% do caminho + // from=[234,179,8] to=[52,211,153] at 0.5 → rgb(143, 195, 81) + expect(color).toBe("rgb(143, 195, 81)") + }) + + it("retorna vermelho escuro quando HP está zerado e max também (edge case)", () => { + const color = getHpBarColor(0, 0) + expect(color).toBe("rgb(88, 0, 0)") + }) + + it("clampeia overflow — HP maior que max retorna verde", () => { + const color = getHpBarColor(30, 20) + expect(color).toBe("rgb(52, 211, 153)") + }) +}) diff --git a/tests/owlbear/frontend/owlbear-shell.test.tsx b/tests/owlbear/frontend/owlbear-shell.test.tsx index 390e4807..b550876a 100644 --- a/tests/owlbear/frontend/owlbear-shell.test.tsx +++ b/tests/owlbear/frontend/owlbear-shell.test.tsx @@ -72,10 +72,15 @@ const sdkMock = vi.hoisted(() => { callbacks.push(callback) return () => undefined }), + action: { + open: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }, player: { getId: vi.fn().mockResolvedValue("player-1"), getName: vi.fn().mockResolvedValue("Nando"), getRole: vi.fn<() => Promise<"GM" | "PLAYER">>(), + deselect: vi.fn().mockResolvedValue(undefined), }, party: { getPlayers: vi.fn().mockResolvedValue([]), @@ -208,12 +213,15 @@ describe("OwlbearShell", () => { sdkMock.isAvailable = true sdkMock.isReady = true sdkMock.room.id = "room-1" + sdkMock.action.open.mockResolvedValue(undefined) + sdkMock.action.close.mockResolvedValue(undefined) sdkMock.room.getMetadata.mockResolvedValue({}) sdkMock.room.setMetadata.mockResolvedValue(undefined) sdkMock.room.onMetadataChange.mockReturnValue(() => undefined) sdkMock.player.getId.mockResolvedValue("player-1") sdkMock.player.getName.mockResolvedValue("Nando") sdkMock.player.getRole.mockReset() + sdkMock.player.deselect.mockResolvedValue(undefined) sdkMock.party.getPlayers.mockResolvedValue([]) sdkMock.party.onChange.mockReturnValue(() => undefined) sdkMock.scene.isReady.mockResolvedValue(true) @@ -360,7 +368,7 @@ describe("OwlbearShell", () => { fireEvent.click(screen.getByRole("button", { name: "Fichas" })) await waitFor(() => { - expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalled() }) expect(screen.queryByTestId("clerk-sign-in")).not.toBeInTheDocument() From aac265d9d8eff9e82ebd6f471ea93477141677c0 Mon Sep 17 00:00:00 2001 From: nandobfer Date: Sat, 6 Jun 2026 02:50:36 +0200 Subject: [PATCH 3/3] corrigido erros de owlbear aware --- aicontext/modules/owlbear.md | 9 +- src/features/owlbear/gm-sheets-tab.tsx | 1 + src/features/owlbear/player-sheet-tab.tsx | 4 +- src/features/owlbear/use-owlbear-runtime.ts | 114 +++++++++++++----- src/features/owlbear/use-owlbear-session.ts | 103 +++++++++++----- tests/owlbear/frontend/owlbear-shell.test.tsx | 62 +++++++++- .../frontend/use-owlbear-session.test.tsx | 73 ++++++++++- 7 files changed, 297 insertions(+), 69 deletions(-) diff --git a/aicontext/modules/owlbear.md b/aicontext/modules/owlbear.md index 4eca82fc..3effe67e 100644 --- a/aicontext/modules/owlbear.md +++ b/aicontext/modules/owlbear.md @@ -25,7 +25,9 @@ O topo da aba usa `SearchInput` e busca Fuse.js local nos NPCs já vinculados. ` As rotas Owlbear-aware de sala são `GET/POST /api/owlbear/rooms/[roomId]/npcs`, `PATCH/DELETE /api/owlbear/rooms/[roomId]/npcs/[npcId]` e `POST /api/owlbear/rooms/[roomId]/npcs/user-npcs`. Todas exigem Bearer token da sessão Owlbear, papel `GM`, `roomId` correspondente à sessão e usuário real do Clerk. Remover pela lixeira desvincula apenas a instância da sala; não apaga o NPC do usuário nem o monstro do catálogo. ### Sessão Owlbear e transição de login -`useOwlbearSession` inclui o estado Clerk (`auth`/`anon`) na identidade interna usada para reaproveitar sessão backend. Quando um usuário entra no Dndicas sem recarregar a action, o hook invalida a sessão Owlbear anterior e abre uma nova sessão com a autenticação atual. Isso evita que abas autenticadas, como `Ficha` do jogador e `NPCs` do GM, continuem usando token anônimo depois do login, enquanto a aba `Fichas` do GM ainda pode funcionar antes do login. +`useOwlbearSession` inclui o estado Clerk (`auth:`/`anon`) na identidade interna usada para reaproveitar sessão backend. Quando um usuário entra no Dndicas sem recarregar a action, o hook espera `useAuth.userId` existir antes de abrir sessão autenticada, invalida a sessão Owlbear anterior e abre uma nova sessão com a autenticação atual. Isso evita que abas autenticadas, como `Ficha` do jogador e `NPCs` do GM, continuem usando token anônimo depois do login, enquanto a aba `Fichas` do GM ainda pode funcionar antes do login. + +Durante a transição pós-login, `401` com usuário logado, erros de rede e respostas `5xx` em `/api/owlbear/session` são tratados como transitórios com backoff curto (250ms, 500ms, 1s, 2s) até o limite de tentativas. A aba `Ficha` do jogador mostra loader enquanto a sessão está `idle`/`loading` ou sem token, e só exibe `A sessão Owlbear-aware não pôde ser inicializada` quando `sessionStatus === "error"`. ### Log de mapeamento `playerId` Quando a action do Owlbear inicializa o contexto do roller, ela combina o jogador atual de `sdk.player` com `sdk.party.getPlayers()`, deduplica por `id` e emite um log com `{ name, id, role }` no console para facilitar overrides manuais por `playerId` sem depender do nome exibido. @@ -33,6 +35,11 @@ Quando a action do Owlbear inicializa o contexto do roller, ela combina o jogado ### Carregamento contextual do SDK O SDK do Owlbear deve ser carregado apenas em superfícies Owlbear reais, como `/owlbear/*`, embeds conhecidos ou iframes ativos da integração. O site normal mantém o roller local sem importar o SDK, evitando dependências indevidas de chunks Owlbear fora desse contexto. +Em superfícies `/owlbear/*`, `sdk.isAvailable === false` no primeiro tick é tratado como estado transitório. `useOwlbearRuntime` mantém `status: "booting"` e faz retry com backoff (250ms, 500ms, 1s, 2s) até o SDK ficar disponível ou `OBR.onReady` disparar. O banner `SDK Owlbear indisponível nesta action` só deve aparecer fora de contexto Owlbear real, evitando falso negativo quando a action abre antes do SDK terminar de hidratar. + +### Fichas do GM na action +Na aba `Fichas`, o `SheetForm` do painel selecionado usa `key={selectedSheet._id}` para forçar remount completo ao alternar entre fichas. Isso evita reaproveitar caches internos de `react-hook-form`, subscriptions realtime e editores ricos ao voltar para uma ficha já visitada dentro da action. + ### Context menu de vínculo de token com personagem ou NPC O `OwlbearGmSceneController` registra context menus para GM com `Vincular a personagem`, `Vincular a NPC` e `Desvincular`. O registro dos menus depende apenas do runtime Owlbear pronto e papel `GM`, não da sessão backend pronta, para evitar que falhas ou delays de sessão escondam o menu. Os filtros do SDK ficam mínimos (`min/max` e `roles`) e a validação de item elegível acontece no `onClick`, porque filtros de `layer`/permissão podem divergir do shape real do item no Owlbear e esconder completamente o botão. Ao clicar em vincular, o controller chama `OBR.action.open()` para abrir a action e mostrar o modal de seleção mesmo quando o painel do Dndicas está fechado, e chama `OBR.player.deselect([tokenId])` para fechar o context menu nativo. Depois que o usuário seleciona personagem ou NPC, o vínculo é sincronizado e a action é fechada com `OBR.action.close()`. diff --git a/src/features/owlbear/gm-sheets-tab.tsx b/src/features/owlbear/gm-sheets-tab.tsx index e624dd21..47923efc 100644 --- a/src/features/owlbear/gm-sheets-tab.tsx +++ b/src/features/owlbear/gm-sheets-tab.tsx @@ -354,6 +354,7 @@ function GmSheetsTabContent({ ) : selectedSheet ? ( } - if (session.sessionStatus === "loading" || !metadataLoaded) { + if (session.sessionStatus !== "ready" || !session.sessionToken || !metadataLoaded) { return (
diff --git a/src/features/owlbear/use-owlbear-runtime.ts b/src/features/owlbear/use-owlbear-runtime.ts index 3aa2b2ef..ef7e9404 100644 --- a/src/features/owlbear/use-owlbear-runtime.ts +++ b/src/features/owlbear/use-owlbear-runtime.ts @@ -13,70 +13,120 @@ const INITIAL_STATE: OwlbearRuntimeState = { sceneReady: false, } +const RETRY_DELAYS_MS = [250, 500, 1000, 2000] as const + +function isOwlbearRuntimeContext() { + if (typeof window === "undefined") return false + if (window.location.pathname.startsWith("/owlbear")) return true + if (window.location.search.includes("obrref=")) return true + try { + return window.self !== window.top + } catch { + return true + } +} + +function getRetryDelay(attempt: number) { + return RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] +} + export function useOwlbearRuntime() { const [runtime, setRuntime] = React.useState(INITIAL_STATE) React.useEffect(() => { let mounted = true + let subscriptionsReady = false + let retryTimer: number | undefined const cleanups: Array<() => void> = [] - const bootstrapRuntime = async () => { - const next = await bootstrapOwlbearRuntime() - if (!mounted) return - setRuntime(next) + const clearRetry = () => { + if (retryTimer === undefined) return + window.clearTimeout(retryTimer) + retryTimer = undefined } - void (async () => { - const sdk = await loadOwlbearSdk() + const scheduleRetry = (attempt: number, callback: (nextAttempt: number) => void) => { + clearRetry() + retryTimer = window.setTimeout(() => callback(attempt + 1), getRetryDelay(attempt)) + } + const bootstrapRuntime = async (attempt = 0) => { + const isTransientContext = isOwlbearRuntimeContext() + const sdk = await loadOwlbearSdk().catch(() => null) if (!mounted) return + if (!sdk || !sdk.isAvailable) { - setRuntime((current) => ({ - ...current, - status: "unavailable", - })) + if (isTransientContext) { + setRuntime((current) => ({ ...current, status: "booting" })) + scheduleRetry(attempt, bootstrapRuntime) + return + } + + setRuntime((current) => ({ ...current, status: "unavailable" })) return } - void bootstrapRuntime() + if (!subscriptionsReady) { + subscriptionsReady = true - if (!sdk.isReady) { const unsubscribeReady = sdk.onReady(() => { - void bootstrapRuntime() + void bootstrapRuntime(0) }) if (typeof unsubscribeReady === "function") { cleanups.push(unsubscribeReady) } - } - const unsubscribeTheme = sdk.theme.onChange((theme) => { - if (!mounted) return - setRuntime((current) => ({ - ...current, - themeMode: theme.mode === "LIGHT" ? "light" : "dark", - })) - }) + const unsubscribeTheme = sdk.theme.onChange((theme) => { + if (!mounted) return + setRuntime((current) => ({ + ...current, + themeMode: theme.mode === "LIGHT" ? "light" : "dark", + })) + }) + + if (typeof unsubscribeTheme === "function") { + cleanups.push(unsubscribeTheme) + } + + const unsubscribeScene = sdk.scene.onReadyChange((ready) => { + if (!mounted) return + setRuntime((current) => ({ + ...current, + sceneReady: ready, + })) + }) - if (typeof unsubscribeTheme === "function") { - cleanups.push(unsubscribeTheme) + if (typeof unsubscribeScene === "function") { + cleanups.push(unsubscribeScene) + } } - const unsubscribeScene = sdk.scene.onReadyChange((ready) => { - if (!mounted) return - setRuntime((current) => ({ - ...current, - sceneReady: ready, - })) - }) + const next = await bootstrapOwlbearRuntime() + if (!mounted) return - if (typeof unsubscribeScene === "function") { - cleanups.push(unsubscribeScene) + if (next.status === "ready") { + clearRetry() + setRuntime(next) + return } + + if (isTransientContext) { + setRuntime((current) => ({ ...current, status: "booting" })) + scheduleRetry(attempt, bootstrapRuntime) + return + } + + setRuntime(next) + } + + void (async () => { + await bootstrapRuntime() })() return () => { mounted = false + clearRetry() cleanups.forEach((cleanup) => cleanup()) } }, []) diff --git a/src/features/owlbear/use-owlbear-session.ts b/src/features/owlbear/use-owlbear-session.ts index a4af1236..804c638f 100644 --- a/src/features/owlbear/use-owlbear-session.ts +++ b/src/features/owlbear/use-owlbear-session.ts @@ -16,8 +16,21 @@ function isSessionUsable(session: OwlbearSessionState) { return expiresAt > Date.now() } +const SESSION_RETRY_DELAYS_MS = [250, 500, 1000, 2000] as const +const MAX_SESSION_OPEN_ATTEMPTS = 8 + +function getSessionRetryDelay(attempt: number) { + return SESSION_RETRY_DELAYS_MS[Math.min(attempt, SESSION_RETRY_DELAYS_MS.length - 1)] +} + +function isRetryableSessionError(error: Error & { status?: number }, isSignedIn: boolean) { + if (error.status === 401 && isSignedIn) return true + if (!error.status) return true + return error.status >= 500 +} + export function useOwlbearSession(runtime: OwlbearRuntimeState) { - const { isLoaded, isSignedIn } = useAuth() + const { isLoaded, isSignedIn, userId } = useAuth() const [session, setSession] = React.useState({ sessionStatus: "idle", sessionToken: null, @@ -34,10 +47,19 @@ export function useOwlbearSession(runtime: OwlbearRuntimeState) { if (runtime.status !== "ready" || !runtime.roomId || !runtime.playerId || !runtime.role) return if (!isLoaded) return + if (isSignedIn && !userId) { + setSession({ + sessionStatus: "idle", + sessionToken: null, + sessionExpiresAt: null, + }) + return + } + const roomId = runtime.roomId const owlbearPlayerId = runtime.playerId const owlbearRole = runtime.role - const authIdentity = isSignedIn ? "auth" : "anon" + const authIdentity = isSignedIn ? `auth:${userId}` : "anon" const runtimeIdentity = `${roomId}:${owlbearPlayerId}:${owlbearRole}:${authIdentity}` const identityChanged = lastRuntimeIdentityRef.current !== null && lastRuntimeIdentityRef.current !== runtimeIdentity @@ -67,6 +89,11 @@ export function useOwlbearSession(runtime: OwlbearRuntimeState) { } let cancelled = false + let retryTimer: ReturnType | undefined + + const waitForRetry = (attempt: number) => new Promise((resolve) => { + retryTimer = setTimeout(resolve, getSessionRetryDelay(attempt)) + }) setSession((current) => ({ ...current, @@ -74,45 +101,63 @@ export function useOwlbearSession(runtime: OwlbearRuntimeState) { })) void (async () => { - try { - const nextSession = await openOwlbearBackendSession({ - roomId, - owlbearPlayerId, - owlbearRole, - }) - - if (cancelled) return - setSession({ - sessionStatus: "ready", - sessionToken: nextSession.token, - sessionExpiresAt: nextSession.expiresAt, - }) - } catch (error) { - const sessionError = error as Error & { status?: number } - if (cancelled) return - - if (sessionError.status === 401) { + for (let attempt = 0; attempt < MAX_SESSION_OPEN_ATTEMPTS; attempt += 1) { + try { + const nextSession = await openOwlbearBackendSession({ + roomId, + owlbearPlayerId, + owlbearRole, + }) + + if (cancelled) return setSession({ - sessionStatus: "idle", + sessionStatus: "ready", + sessionToken: nextSession.token, + sessionExpiresAt: nextSession.expiresAt, + }) + return + } catch (error) { + const sessionError = error as Error & { status?: number } + if (cancelled) return + + const canRetry = attempt < MAX_SESSION_OPEN_ATTEMPTS - 1 && isRetryableSessionError(sessionError, Boolean(isSignedIn)) + if (canRetry) { + console.warn("Retrying Owlbear backend session open", { + roomId, + owlbearPlayerId, + owlbearRole, + authIdentity, + attempt: attempt + 1, + status: sessionError.status, + }) + await waitForRetry(attempt) + if (cancelled) return + continue + } + + console.error("Failed to open Owlbear backend session", { + roomId, + owlbearPlayerId, + owlbearRole, + authIdentity, + status: sessionError.status, + error, + }) + setSession({ + sessionStatus: "error", sessionToken: null, sessionExpiresAt: null, }) return } - - console.error("Failed to open Owlbear backend session", error) - setSession({ - sessionStatus: "error", - sessionToken: null, - sessionExpiresAt: null, - }) } })() return () => { cancelled = true + if (retryTimer) clearTimeout(retryTimer) } - }, [isLoaded, isSignedIn, runtime.playerId, runtime.role, runtime.roomId, runtime.status]) + }, [isLoaded, isSignedIn, runtime.playerId, runtime.role, runtime.roomId, runtime.status, userId]) return { session, diff --git a/tests/owlbear/frontend/owlbear-shell.test.tsx b/tests/owlbear/frontend/owlbear-shell.test.tsx index b550876a..45e84e7c 100644 --- a/tests/owlbear/frontend/owlbear-shell.test.tsx +++ b/tests/owlbear/frontend/owlbear-shell.test.tsx @@ -194,8 +194,8 @@ vi.mock("@/components/ui/glass-sheet-card", () => ({ })) vi.mock("@/features/character-sheets/components/sheet-form", () => ({ - SheetForm: ({ sheet, layoutMode, navigateOnSlugChange }: { sheet: { name?: string }; layoutMode?: string; navigateOnSlugChange?: boolean }) => ( -
+ SheetForm: ({ sheet, layoutMode, navigateOnSlugChange }: { sheet: { _id?: string; name?: string }; layoutMode?: string; navigateOnSlugChange?: boolean }) => ( +
Sheet Form Mock
), @@ -209,6 +209,7 @@ describe("OwlbearShell", () => { beforeEach(() => { vi.clearAllMocks() vi.useRealTimers() + window.history.pushState({}, "", "/") sdkMock.callbacks.length = 0 sdkMock.isAvailable = true sdkMock.isReady = true @@ -375,6 +376,47 @@ describe("OwlbearShell", () => { expect(screen.queryByText("A sessão Owlbear-aware não pôde ser inicializada. Reabra a action para tentar novamente.")).not.toBeInTheDocument() }) + it("allows switching GM sheets A to B and back to A", async () => { + const miraSheet = { + ...kaelSheet, + _id: "sheet-2", + name: "Mira", + slug: "mira", + } + sdkMock.player.getRole.mockResolvedValue("GM") + useRoomLinkedSheetsMock.mockReturnValue({ + entries: [ + { playerId: "player-1", sheetId: "sheet-1" }, + { playerId: "player-2", sheetId: "sheet-2" }, + ], + sheets: [kaelSheet, miraSheet], + isLoading: false, + errorMessage: null, + reload: vi.fn(), + unlinkSheet: vi.fn(), + }) + useSheetMock.mockImplementation((id: string | null) => ({ + data: id === "sheet-2" ? miraSheet : id === "sheet-1" ? kaelSheet : null, + isLoading: false, + isFetching: false, + isError: false, + error: null, + })) + + render() + + await screen.findByRole("button", { name: "Fichas" }) + fireEvent.click(screen.getByRole("button", { name: "Fichas" })) + + expect(await screen.findByTestId("sheet-form")).toHaveAttribute("data-sheet-id", "sheet-1") + + clickSheetCard("Mira") + expect(await screen.findByTestId("sheet-form")).toHaveAttribute("data-sheet-id", "sheet-2") + + clickSheetCard("Kael") + expect(await screen.findByTestId("sheet-form")).toHaveAttribute("data-sheet-id", "sheet-1") + }) + it("treats a ready GM Owlbear-aware session as sufficient for scene management", () => { expect(canManageGmScene(readyGmRuntime, readySession)).toBe(true) expect(canManageGmScene(readyPlayerRuntime, readySession)).toBe(false) @@ -411,6 +453,22 @@ describe("OwlbearShell", () => { expect(screen.getByTitle("Dndicas Dashboard")).toBeInTheDocument() }) + it("keeps retrying inside the Owlbear action when the SDK is initially unavailable", async () => { + window.history.pushState({}, "", "/owlbear/action") + sdkMock.isAvailable = false + sdkMock.player.getRole.mockResolvedValue("GM") + + render() + + expect(screen.queryByText("SDK Owlbear indisponível nesta action.")).not.toBeInTheDocument() + + await new Promise((resolve) => window.setTimeout(resolve, 50)) + sdkMock.isAvailable = true + + expect(await screen.findByRole("button", { name: "Fichas" }, { timeout: 2500 })).toBeInTheDocument() + expect(screen.queryByText("SDK Owlbear indisponível nesta action.")).not.toBeInTheDocument() + }) + it("waits for OBR.onReady before leaving booting state", async () => { sdkMock.isReady = false sdkMock.player.getRole.mockResolvedValue("GM") diff --git a/tests/owlbear/frontend/use-owlbear-session.test.tsx b/tests/owlbear/frontend/use-owlbear-session.test.tsx index 65eb2141..b605bad9 100644 --- a/tests/owlbear/frontend/use-owlbear-session.test.tsx +++ b/tests/owlbear/frontend/use-owlbear-session.test.tsx @@ -1,12 +1,13 @@ import * as React from "react" -import { renderHook, waitFor } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { useOwlbearSession } from "@/features/owlbear/use-owlbear-session" import type { OwlbearRuntimeState } from "@/features/owlbear/types" const authState = vi.hoisted(() => ({ isLoaded: true, isSignedIn: false, + userId: null as string | null, })) const openOwlbearBackendSessionMock = vi.hoisted(() => vi.fn()) @@ -15,6 +16,7 @@ vi.mock("@/core/hooks/useAuth", () => ({ useAuth: () => ({ isLoaded: authState.isLoaded, isSignedIn: authState.isSignedIn, + userId: authState.userId, }), })) @@ -41,17 +43,23 @@ describe("useOwlbearSession", () => { vi.clearAllMocks() authState.isLoaded = true authState.isSignedIn = false + authState.userId = null openOwlbearBackendSessionMock .mockResolvedValueOnce({ token: "token-anon", expiresAt: "2099-01-01T00:00:00.000Z" }) .mockResolvedValueOnce({ token: "token-auth", expiresAt: "2099-01-01T00:00:00.000Z" }) }) + afterEach(() => { + vi.useRealTimers() + }) + it("reopens a GM Owlbear session when Clerk changes from anonymous to authenticated", async () => { const { result, rerender } = renderHook(() => useOwlbearSession(gmRuntime)) await waitFor(() => expect(result.current.session.sessionToken).toBe("token-anon")) authState.isSignedIn = true + authState.userId = "user-1" rerender() await waitFor(() => expect(result.current.session.sessionToken).toBe("token-auth")) @@ -64,15 +72,74 @@ describe("useOwlbearSession", () => { }) it("keeps PLAYER sessions idle while anonymous and opens after login", async () => { + openOwlbearBackendSessionMock + .mockReset() + .mockResolvedValue({ token: "token-player", expiresAt: "2099-01-01T00:00:00.000Z" }) + const { result, rerender } = renderHook(() => useOwlbearSession(playerRuntime)) expect(result.current.session.sessionStatus).toBe("idle") expect(openOwlbearBackendSessionMock).not.toHaveBeenCalled() authState.isSignedIn = true + authState.userId = "user-1" rerender() - await waitFor(() => expect(result.current.session.sessionToken).toBe("token-anon")) + await waitFor(() => expect(result.current.session.sessionToken).toBe("token-player")) expect(openOwlbearBackendSessionMock).toHaveBeenCalledTimes(1) }) + + it("waits for Clerk userId before opening an authenticated PLAYER session", async () => { + openOwlbearBackendSessionMock + .mockReset() + .mockResolvedValue({ token: "token-player", expiresAt: "2099-01-01T00:00:00.000Z" }) + authState.isSignedIn = true + authState.userId = null + + const { result, rerender } = renderHook(() => useOwlbearSession(playerRuntime)) + + expect(result.current.session.sessionStatus).toBe("idle") + expect(openOwlbearBackendSessionMock).not.toHaveBeenCalled() + + authState.userId = "user-1" + rerender() + + await waitFor(() => expect(result.current.session.sessionToken).toBe("token-player")) + expect(openOwlbearBackendSessionMock).toHaveBeenCalledTimes(1) + }) + + it("retries a transient 401 after login until the backend Clerk session is ready", async () => { + const unauthorized = new Error("Não autorizado") as Error & { status?: number } + unauthorized.status = 401 + openOwlbearBackendSessionMock + .mockReset() + .mockRejectedValueOnce(unauthorized) + .mockResolvedValueOnce({ token: "token-player", expiresAt: "2099-01-01T00:00:00.000Z" }) + authState.isSignedIn = true + authState.userId = "user-1" + + const { result } = renderHook(() => useOwlbearSession(playerRuntime)) + + await waitFor(() => expect(result.current.session.sessionStatus).toBe("loading")) + await waitFor(() => expect(result.current.session.sessionToken).toBe("token-player"), { timeout: 1500 }) + expect(openOwlbearBackendSessionMock).toHaveBeenCalledTimes(2) + }) + + it("sets error when retryable session failures are exhausted", async () => { + vi.useFakeTimers() + openOwlbearBackendSessionMock + .mockReset() + .mockRejectedValue(new Error("Network error")) + authState.isSignedIn = true + authState.userId = "user-1" + + const { result } = renderHook(() => useOwlbearSession(playerRuntime)) + + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(result.current.session.sessionStatus).toBe("error") + expect(openOwlbearBackendSessionMock).toHaveBeenCalledTimes(8) + }) })