Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion aicontext/modules/dice-roller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -67,4 +70,3 @@ Para eliminar o atraso percebido ao abrir o painel de dados, a aplicação reali




26 changes: 26 additions & 0 deletions aicontext/modules/owlbear.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,34 @@ 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:<userId>`/`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.

### 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()`.

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.
6 changes: 6 additions & 0 deletions public/owlbear-context-menu.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions src/app/api/owlbear/rooms/[roomId]/npcs/[npcId]/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions src/app/api/owlbear/rooms/[roomId]/npcs/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions src/app/api/owlbear/rooms/[roomId]/npcs/user-npcs/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 8 additions & 4 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand All @@ -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 })
}
}
Expand Down
7 changes: 5 additions & 2 deletions src/features/character-sheets/components/sheet-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -35,6 +37,7 @@ export function SheetForm({
layoutMode = "responsive",
editMode = "auto",
onSlugChange,
onFieldPatch,
navigateOnSlugChange = true,
runtimeContext = "default",
}: SheetFormProps) {
Expand Down Expand Up @@ -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 })
Expand Down
20 changes: 8 additions & 12 deletions src/features/character-sheets/components/sheet-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1076,17 +1076,13 @@ export function useSheetHeaderSections({ sheet, form, items = [], isReadOnly = f
className="overflow-hidden"
>
<div className="mt-4 rounded-xl border border-white/10 bg-black/10 p-3">
<DiceRollerPanel
preset={{
label: "PV ao subir de nível",
terms: [{ dice: hitDieForLevelUp, quantity: 1 }],
modifier: constitutionModifier,
source: "sheet",
sourceRef: { sheetId: sheet._id, fieldId: "level-up-hp" },
}}
hideConfigurationControls
onRollResolved={(result) => {
setRolledHpGain(result.total)
<HpDicePanel
label="PV ao subir de nível"
terms={[{ dice: hitDieForLevelUp, quantity: 1 }]}
modifier={constitutionModifier}
sourceRef={{ sheetId: sheet._id, fieldId: "level-up-hp" }}
onRollResolved={(total) => {
setRolledHpGain(total)
setIsHpRollerVisible(false)
}}
/>
Expand Down
7 changes: 7 additions & 0 deletions src/features/character-sheets/hooks/use-sheet-auto-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}


Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
42 changes: 42 additions & 0 deletions src/features/dice-roller/components/hp-dice-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DiceRollerPanel
preset={preset}
hideConfigurationControls
onRollResolved={(result) => onRollResolved(result.total, result)}
/>
)
}
61 changes: 61 additions & 0 deletions src/features/dice-roller/utils/hp-dice.ts
Original file line number Diff line number Diff line change
@@ -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<DiceType, number>()
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
}
Loading
Loading