InícioDocs para devsComo criar um módulo de compêndio
Tutoriais de implementação 12 min de leitura

Como criar um módulo de compêndio

Monte o pacote completo a partir de pasta vazia, instale e confira seu conteúdo original.

A biblioteca Harbor criou um pack da campanha e leu o snapshot do diário original. Gravewright 0.3.0, interface em inglês.
A biblioteca Harbor criou um pack da campanha e leu o snapshot do diário original. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

O que você vai construir

Você começa com uma pasta vazia e termina com um módulo instalável. Na mesa ele mostra Harbor library. O GM clica Prepare my library e obtém um compêndio Harbor Library com uma entrada de diário Beacon Watch. Depois lê o snapshot e importa uma cópia pela própria interface do módulo.

Este é o caminho para um módulo comum que trabalha com documentos da campanha. O conteúdo nasce por ações autorizadas da API e fica salvo na campanha. Um manifesto de módulo comum não aceita content.compendiums sem system: esse outro caminho é ensinado em Como incluir compêndios em um sistema.

Use Gravewright 0.3.0 e uma campanha descartável. Você precisa da conta proprietária para instalar e do papel GM na campanha para preparar/importar. Não precisa de npm, acesso ao banco, biblioteca de RPG ou módulo de regras. Os textos de exemplo são originais. Os botões do pacote estão em inglês.

1. Crie a pasta e entenda os arquivos

Crie uma pasta chamada campaign-library. Salve em UTF-8 todos os arquivos completos da seção 4, preservando os nomes e os caminhos. Alternativamente, extraia o ZIP abaixo. manifest.json identifica o pacote; main.mjs desenha o painel; library.mjs faz a sequência de gravações; maintenance.mjs contém funções opcionais de manutenção; styles.css limita os estilos ao módulo; LICENSE.txt autoriza o uso do exemplo.

text
campaign-library/
  manifest.json
  main.mjs
  library.mjs
  maintenance.mjs
  styles.css
  LICENSE.txt

Baixar o ZIP completo

2. Empacote e instale

Use um Python já disponível no ambiente de desenvolvimento. Os comandos abaixo partem da pasta que contém campaign-library. No POSIX, use python3 se esse for o nome do seu interpretador. Eles compactam somente os arquivos listados, com manifest.json na raiz, sem incluir estado local.

POSIX

sh
cd campaign-library
python -m zipfile -c ../campaign-library-source.zip "manifest.json" "main.mjs" "library.mjs" "maintenance.mjs" "styles.css" "LICENSE.txt"

PowerShell

powershell
Set-Location -LiteralPath .\campaign-library
python -m zipfile -c ..\campaign-library-source.zip "manifest.json" "main.mjs" "library.mjs" "maintenance.mjs" "styles.css" "LICENSE.txt"

Confira o ZIP com python -m zipfile -l ../campaign-library-source.zip enquanto ainda estiver dentro da pasta. Como proprietário, abra Inside → Módulos → Instalar módulo → Instalar de ZIP. Escolha o ZIP e clique Instalar. Em inglês: Modules → Install module → Install from ZIP → Install. Não é necessário assinar este upload local; publicação via catálogo exige o fluxo de assinatura documentado.

Abra sua campanha de teste como GM. No botão Gravewright da mesa, abra Extensions, encontre Harbor campaign library 1.0.0 e clique Activate. Feche o menu. O painel Harbor library aparece abaixo da barra superior. Veja seu primeiro módulo se precisar localizar essas telas.

3. Prepare, leia e importe

  1. Clique Prepare my library. O controlador confirma isGM, reserva quatro UUIDs de operação em storage.user e cria o compêndio.

  2. Ele cria um diário nativo, usa journal_id e version retornados para salvar uma página com formato gw-journal-doc-v1 e adiciona o diário ao pack usando kind journal e resourceId. O corpo não é enviado diretamente para compendiums.add.

  3. Aguarde Library ready. Os seletores passam a mostrar Harbor Library e Beacon Watch. Clique Read snapshot: aparece o envelope com kind journal e a página original sobre a lanterna azul.

  4. Clique Import copy. O resultado informa o ID criado. Abra Journals na mesa e confira o diário importado e a mesma página. A importação usa a campanha da montagem; nenhum campo permite apontar para outra campanha.

  5. Clique Prepare my library novamente e depois desative/reative o módulo. O mesmo GM recupera o registro e não cria outro pack nem outra entrada. Recarregue a lista para conferir. Clicar Import copy novamente após um sucesso é uma nova ação e pode criar outra cópia.

O registro de preparação pertence ao usuário, módulo e campanha. Outro GM tem seu próprio registro e, se clicar preparar, cria sua própria biblioteca. Isso mantém os UUIDs de retry na mesma conta que executou as operações. O compêndio resultante pertence à campanha e permanece após desativar o módulo.

4. Copie todos os arquivos

Para mudar o conteúdo distribuído pelo exemplo, edite journalData em library.mjs antes de preparar uma biblioteca nova. Cada seção tem ID estável, título, audience e um documento rico. Para mudar nomes do pack/diário, altere os literais nas chamadas create/update/add. Alterar o código não atualiza automaticamente snapshots já capturados: eles são cópias, não links vivos para o diário.

manifest.json

json
{
  "id": "example.harbor-library",
  "name": "Harbor campaign library",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Creates an original journal and reusable campaign snapshot through the public API.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {createLibrary} from "./library.mjs";

export default {
  start({styles}) { styles.use("styles.css"); },
  register({register}) {
    register("table.interface", async ({root, api, storage, signal, onDispose}) => {
      const library = createLibrary({api, storage, signal});
      const el = (tag, text) => {
        const node = root.ownerDocument.createElement(tag);
        if (text !== undefined) node.textContent = text;
        return node;
      };
      const panel = el("section"); panel.className = "harbor-library";
      const prepare = el("button", "Prepare my library");
      const refresh = el("button", "Refresh list");
      const read = el("button", "Read snapshot");
      const copy = el("button", "Import copy");
      for (const button of [prepare, refresh, read, copy]) button.type = "button";
      const packLabel = el("label", "Compendium");
      const packs = el("select"); packLabel.append(packs);
      const entryLabel = el("label", "Entry");
      const entries = el("select"); entryLabel.append(entries);
      const output = el("pre"); output.setAttribute("role", "status");
      panel.append(el("h3", "Harbor library"), prepare, refresh, packLabel, entryLabel, read, copy, output);
      root.append(panel); onDispose(() => panel.remove());
      let state, busy = true, importAttempt;
      const controls = () => {
        prepare.disabled = busy || !state?.isGM;
        refresh.disabled = busy;
        read.disabled = busy || !entries.value;
        copy.disabled = busy || !state?.isGM || !entries.value;
        packs.disabled = busy; entries.disabled = busy;
      };
      const options = (select, rows, selected) => {
        select.replaceChildren();
        const empty = el("option", "Choose..."); empty.value = ""; select.append(empty);
        for (const row of rows) { const option = el("option", row.name); option.value = row.id; select.append(option); }
        select.value = rows.some(row => row.id === selected) ? selected : "";
      };
      const chooseEntries = selected => {
        options(entries, state?.packs.find(pack => pack.id === packs.value)?.entries ?? [], selected);
        controls();
      };
      const load = async (packId = packs.value, entryId = entries.value) => {
        const next = await api.compendiums.state({}, {signal});
        if (signal.aborted) return;
        state = next; options(packs, state.packs, packId); chooseEntries(entryId);
      };
      const run = async task => {
        if (busy || signal.aborted) return;
        busy = true; controls(); output.textContent = "Working...";
        try { await task(); }
        catch (error) { if (!signal.aborted) output.textContent = "Error: " + (error.code ?? "unavailable") + ". Refresh and review before retrying."; }
        finally { if (!signal.aborted) {busy = false; controls();} }
      };
      prepare.addEventListener("click", () => void run(async () => {
        const saved = await library.prepare();
        await load(saved.packId, saved.entryId);
        if (!signal.aborted) output.textContent = "Library ready. Select Read snapshot or Import copy.";
      }), {signal});
      refresh.addEventListener("click", () => void run(async () => {await load(); if (!signal.aborted) output.textContent = "List refreshed.";}), {signal});
      packs.addEventListener("change", () => {chooseEntries(""); importAttempt = undefined;}, {signal});
      entries.addEventListener("change", () => {importAttempt = undefined; controls();}, {signal});
      read.addEventListener("click", () => void run(async () => {
        const value = await library.read(packs.value, entries.value);
        if (!signal.aborted) output.textContent = JSON.stringify(value, null, 2);
      }), {signal});
      copy.addEventListener("click", () => void run(async () => {
        importAttempt ??= {packId: packs.value, entryId: entries.value, requestId: crypto.randomUUID()};
        const created = await library.importCopy(importAttempt.packId, importAttempt.entryId, importAttempt.requestId);
        importAttempt = undefined;
        if (!signal.aborted) output.textContent = "Imported " + created.kind + ": " + created.id + ". Open Journals to inspect it.";
      }), {signal});
      controls();
      try { await load(); }
      catch (error) { if (!signal.aborted) output.textContent = "Error: " + (error.code ?? "unavailable"); }
      finally { if (!signal.aborted) {busy = false; controls();} }
    });
  },
  stop() {},
};

library.mjs

javascript
// The source text and snapshot belong to this original example.
export const journalData = {
  sections: [{
    id: "harbor-notice", title: "Harbor notice", kind: "text", audience: "public",
    content: {
      format: "gw-journal-doc-v1", version: 1,
      doc: {type: "doc", content: [{type: "paragraph", content: [
        {type: "text", text: "At sunset, the harbor keeper lights a blue lantern. Bring a coil of rope to join the watch."},
      ]}]},
    },
  }],
};

const key = "harbor-library-workflow-v1";
const fail = code => Object.assign(new Error(code), {code});

export function createLibrary({api, storage, signal}) {
  return {
    async prepare() {
      if (!(await api.compendiums.state({}, {signal})).isGM) throw fail("permission_denied");
      let record = await storage.user.get(key);
      if (record && record.value?.schema !== 1) throw fail("invalid_data");
      if (!record) record = await storage.user.set(key, {
        schema: 1,
        requests: Object.fromEntries(["pack", "journal", "body", "snapshot"].map(name => [name, crypto.randomUUID()])),
      }, {expectedRevision: null});
      const checkpoint = async changes => {
        record = await storage.user.set(key, {...record.value, ...changes}, {expectedRevision: record.revision});
      };
      // Save request identities before making writes; retries reuse exact inputs.
      if (!record.value.packId) {
        const created = await api.compendiums.create({name: "Harbor Library"}, {signal, requestId: record.value.requests.pack});
        await checkpoint({packId: created.id});
      }
      if (!record.value.journalId) {
        const created = await api.journals.create({title: "Beacon Watch", journal_type: "diary"}, {signal, requestId: record.value.requests.journal});
        await checkpoint({journalId: created.journal_id, journalVersion: created.version});
      }
      if (!record.value.bodySaved) {
        await api.journals.update({
          journal_id: record.value.journalId, version: record.value.journalVersion,
          title: "Beacon Watch", visibility: "private", data: journalData,
        }, {signal, requestId: record.value.requests.body});
        await checkpoint({bodySaved: true});
      }
      if (!record.value.entryId) {
        const added = await api.compendiums.add({
          packId: record.value.packId, kind: "journal", resourceId: record.value.journalId,
          name: "Beacon Watch",
        }, {signal, requestId: record.value.requests.snapshot});
        await checkpoint({entryId: added.id});
      }
      return record.value;
    },
    async read(packId, entryId) {
      return api.compendiums.read({packId, id: entryId}, {signal});
    },
    async importCopy(packId, entryId, requestId) {
      return api.compendiums.import({packId, id: entryId}, {signal, requestId});
    },
  };
}

maintenance.mjs

javascript
const options = signal => ({signal, requestId: crypto.randomUUID()});
const editable = packId => {
  if (packId.startsWith("native:")) throw Object.assign(new Error("Installed packs are read only"), {code: "invalid_data"});
};
export async function shareCampaignPack(api, packId, signal) {
  editable(packId);
  const pack = (await api.compendiums.state({}, {signal})).packs.find(row => row.id === packId);
  if (!pack) throw Object.assign(new Error("Pack not found"), {code: "not_found"});
  return api.compendiums.configure({packId, version: pack.version, name: "Harbor Library revised", shared: true, locked: false}, options(signal));
}
export async function grantInstalledPackRead(api, packId, signal) {
  if (!packId.startsWith("native:")) throw new Error("Use configure.shared or the native ownership editor for a table pack");
  return api.compendiums.setPermissions({packId, level: "read"}, options(signal));
}
export async function readBatch(api, packId, entryIds, signal) {
  return api.compendiums.read({packId, ids: entryIds}, {signal});
}
export async function removeSnapshot(api, packId, entryId, signal) {
  editable(packId);
  const entry = await api.compendiums.read({packId, id: entryId}, {signal});
  return api.compendiums.remove({packId, id: entryId, version: entry.version}, options(signal));
}
export async function deleteCampaignPack(api, packId, signal) {
  editable(packId);
  const pack = (await api.compendiums.state({}, {signal})).packs.find(row => row.id === packId);
  if (!pack) throw Object.assign(new Error("Pack not found"), {code: "not_found"});
  return api.compendiums.delete({packId, version: pack.version}, options(signal));
}

styles.css

css
.gw-module-root[data-module-id="example.harbor-library"] .harbor-library { position: fixed; top: 64px; left: var(--space-4); z-index: 10; box-sizing: border-box; width: 400px; max-width: calc(100vw - 32px); max-height: 50vh; overflow: auto; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font: 14px/1.5 system-ui, sans-serif; }
.gw-module-root[data-module-id="example.harbor-library"] .harbor-library h3 { font-size: 16px; margin: var(--space-0) var(--space-0) var(--space-2); }
.gw-module-root[data-module-id="example.harbor-library"] .harbor-library label { display: block; margin-top: var(--space-2); }
.gw-module-root[data-module-id="example.harbor-library"] .harbor-library button, .gw-module-root[data-module-id="example.harbor-library"] .harbor-library select { font: inherit; margin: var(--space-1); padding: var(--space-1); max-width: 100%; }
.gw-module-root[data-module-id="example.harbor-library"] .harbor-library pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }

LICENSE.txt

text
MIT License

Copyright (c) 2026 Gravewright contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5. Compartilhe e mantenha a biblioteca

O pack começa privado. Para compartilhá-lo como GM, integre a função shareCampaignPack de maintenance.mjs a uma ação explícita no seu módulo e passe o packId retornado por prepare. Ela lê a versão atual antes de configure e marca shared true, deixando o pack desbloqueado. Na interface nativa de permissões, confira se concessões explícitas não restringem a conta de teste. Como jogador com leitura, clique Refresh list, selecione o pack e use Read snapshot. Prepare e Import ficam desabilitados; o servidor também rejeita escrita/importação.

maintenance.mjs também inclui readBatch para 1 a 100 IDs únicos (não misture id e ids), removeSnapshot e deleteCampaignPack. Use-as apenas em sua biblioteca de teste após conferir os IDs. Remover uma entrada usa a versão da entrada; excluir o pack usa a versão do pack lida depois das alterações. Nenhuma dessas ações remove o diário original ou os documentos que já foram importados. Um pack bloqueado precisa ser desbloqueado por configure antes de editar/excluir.

setPermissions é mostrado em grantInstalledPackRead porque esse método configura packs nativos/instalados, identificados por native:. Para um pack criado na mesa, use configure.shared ou o editor nativo de propriedade. Os arquivos completos acima contêm os payloads reais de todas essas ações.

6. Valide repetição, falhas e permissões

Prepare duas vezes com o mesmo GM e conte um pack e uma entrada. Edite o diário original pela interface, leia o snapshot e confira que ele preserva o conteúdo capturado. Importe, feche/reabra e compare a página. Compartilhe para um jogador, teste leitura e tente importação diretamente pela API: a negação precisa vir do servidor.

Se uma resposta de escrita se perder, Prepare retoma na mesma conta usando os UUIDs persistidos e o mesmo payload. Cada checkpoint usa expectedRevision; em conflict, atualize a lista/estado antes de tentar novamente. Se o diário foi editado antes do passo de salvar corpo, a revisão antiga é rejeitada e não é substituída silenciosamente: revise o diário e a operação pendente antes de ajustar código/estado. Import copy conserva seu UUID enquanto a tentativa fica no painel; se fechar com resultado desconhecido, inspecione os diários antes de pedir outra importação. Cancelar o contexto não desfaz escrita já confirmada.

Um erro de rede é erro, não lista vazia. IDs excluídos ou acesso revogado causam not_found/permission_denied; não apague o registro automaticamente nem crie substitutos para esconder o problema. Não reinicialize o workflow para atualizar conteúdo. Para novos bytes de pacote, use version 1.0.1, instale e ative a nova versão; os documentos e o workflow continuam salvos.

Como usar a IA para adaptar esta biblioteca

Adapte tutorial-compendium-module para uma biblioteca de avisos originais. Leia frontend.json, journals/services.py e compendiums/services.py da release 0.3.0. Preserve journal_id/version, documento rico, snapshot por resourceId, UUIDs de retry e expectedRevision. Mostre todos os arquivos, cliques e testes GM/jogador. Não acrescente content ao manifesto de módulo comum nem atualize snapshots automaticamente. Inclua batch read e manutenção com versões sem excluir conteúdo da campanha.

API · Todos os tutoriais