InícioDocs para devsSalvar configurações e preferências do módulo
Tutoriais de implementação 8 min de leitura

Salvar configurações e preferências do módulo

Implementar JSON pessoal, compartilhado e local com revisões e descarte.

Módulo Persist module settings safely instalado após executar a receita user-read. Gravewright 0.3.0, interface em inglês.
Módulo Persist module settings safely instalado após executar a receita user-read. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

O projeto que você vai criar

Crie um laboratório de configurações. As ações mostram o ciclo completo de uma chave em cada escopo, com leitura, gravação, listagem e exclusão. Use esses elementos em preferences ou customize para montar seu formulário real.

Host alvo: Gravewright 0.3.0. As ações rodam somente quando você clica Run action; leituras e escritas usam a conta conectada. O painel de demonstração está em inglês. Cada receita abaixo informa os dados de entrada e o resultado concreto.

1. Crie os arquivos

Crie uma pasta module-settings. Salve os arquivos mostrados abaixo em UTF-8, ou baixe o projeto completo. manifest.json fica na raiz. Não há dependências npm nem build de JavaScript.

text
module-settings/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

2. Empacote, instale e ative

Partindo da pasta do projeto, execute no PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../module-settings-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../module-settings-1.0.0.zip

No Linux/macOS, substitua python por python3 no mesmo comando. A listagem precisa mostrar manifest.json na raiz. Como proprietário, abra Inside → Módulos → Instalar módulo → Instalar de ZIP, selecione o ZIP e clique Instalar. Como GM, abra a campanha, use o botão Gravewright da mesa → Extensions e clique Activate na versão 1.0.0 de Persist module settings safely. Feche o menu e clique no botão Persist module settings safely. Se precisar de detalhes de cada tela, veja seu primeiro módulo.

O projeto fica inativo até a seleção na mesa. Teste uma extensão de demonstração por vez, pois seus botões usam a mesma posição. Para novos bytes, mude version para 1.0.1, gere outro ZIP, instale e ative essa versão; não edite o pacote instalado.

3. Execute cada receita

Ler preferências pessoais e listar chaves

Execute antes de salvar. Chave ausente retorna null; isso não é falha de armazenamento.

javascript
async ({storage}) => {
  return {current:await storage.user.get('workshop.preferences'),keys:await storage.user.list('workshop.')};
}

Resultado esperado: null no primeiro uso e lista de chaves/revisões.

Como conferir: Repita em outra conta ou campanha; armazenamento pessoal do servidor é separado.

Salvar preferência pessoal versionada com CAS

Escolha o rótulo. A leitura fornece expectedRevision; alteração concorrente gera conflict. Não substitua a revisão para repetir automaticamente.

javascript
async ({storage},v) => {
  const current=await storage.user.get('workshop.preferences');
  if(current && current.value?.schema!==1) throw new Error('Unsupported settings schema; migrate explicitly');
  return storage.user.set('workshop.preferences',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
}

Resultado esperado: Valor e nova revisão opaca, preservados ao desativar.

Como conferir: Leia novamente, desative/ative e compare. Faça duas leituras antes de duas gravações; a segunda desatualizada deve falhar.

Apagar apenas a preferência deste tutorial

A exclusão usa a revisão atual de uma chave exata. Nenhuma preferência de outro módulo/campanha é removida.

javascript
async ({storage}) => {
  const current=await storage.user.get('workshop.preferences');
  if(current) await storage.user.delete('workshop.preferences',{expectedRevision:current.revision});
  return storage.user.get('workshop.preferences');
}

Resultado esperado: null.

Como conferir: Leia e liste novamente; a chave do tutorial desapareceu.

Compartilhar uma configuração do GM com a mesa

GM grava; jogadores seguem o ramo de leitura. É JSON compartilhado do módulo, sem autorizar alterações de documentos.

javascript
async ({api,storage,signal},v) => {
  if(!(await api.actors.state({}, {signal})).is_gm) return {readOnly:true,current:await storage.table.get('workshop.shared')};
  const current=await storage.table.get('workshop.shared');
  const saved=await storage.table.set('workshop.shared',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
  return {saved,keys:await storage.table.list('workshop.')};
}

Resultado esperado: GM recebe a revisão; jogador recebe o mesmo valor compartilhado.

Como conferir: Use duas contas na mesma campanha. storage.table.set direto de jogador deve ser recusado no servidor.

Remover a chave compartilhada do tutorial

Execute como GM depois de conferir o valor compartilhado.

javascript
async ({storage}) => {
  const current=await storage.table.get('workshop.shared');
  if(current)await storage.table.delete('workshop.shared',{expectedRevision:current.revision});
  return storage.table.get('workshop.shared');
}

Resultado esperado: null; jogador não pode apagar esta chave.

Como conferir: Leia como jogador depois da exclusão pelo GM.

Usar configuração local do navegador e limpá-la

Guarde apenas escolha de interface aqui. O namespace é módulo+origem e é compartilhado entre contas e mesas no navegador.

javascript
async ({storage},v) => {
  await storage.local.set('workshop.local',{schema:1,density:v.density});
  const value=await storage.local.get('workshop.local');
  await storage.local.delete('workshop.local');
  return {observed:value,afterDelete:await storage.local.get('workshop.local')};
}

Resultado esperado: observed contém a escolha e afterDelete é null. Armazenamento local não usa revisão CAS.

Como conferir: Não armazene notas privadas, permissões ou segredos da conta neste namespace.

4. Entenda e adapte os arquivos completos

main.mjs desenha apenas o lançador e o formulário. recipes.mjs contém as ações: para adaptar o comportamento, altere a função run da receita desejada, mantendo sua assinatura. O formulário passa tipos reais (number, boolean e File), bloqueia requisições simultâneas e desenha resultados com textContent. Não há avaliação de código fornecido pelo usuário. api, host, assets e storage pertencem à montagem; signal cancela trabalho ao desativar. O modal não fecha durante uma operação pendente e é liberado ao abortar a montagem. A função de negócio continua responsável por revisões, permissões e sequência transacional.

manifest.json

json
{
  "id": "tutorial.module-settings",
  "name": "Persist module settings safely",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Implement personal, shared and browser-local JSON with revisions and cleanup.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {recipes} from './recipes.mjs';
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
      const doc=root.ownerDocument;
      const button=doc.createElement('button');
      button.type='button';button.className='tutorial-launcher';
      button.textContent="Persist module settings safely";
      root.append(button);
      for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
      let modal,pending=false;
      button.addEventListener('click',event=>{
        event.stopPropagation();
        if(modal?.isConnected) { modal.focus();return; }
        const panel=doc.createElement('section');
        panel.className="tutorial-workbench-module-settings";
        panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
        panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
        const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
        const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
        for(const recipe of recipes) {
          const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
        }
        const form=doc.createElement('form'),fields=doc.createElement('div');
        const description=doc.createElement('p');
        const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
        const close=doc.createElement('button');close.type='button';close.textContent='Close';
        const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
        form.append(description,fields,run,close,output);panel.append(choose,form);
        function renderFields(){
          const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
          description.textContent=recipe.description;
          for(const field of recipe.fields||[]) {
            const label=doc.createElement('label');label.textContent=field.label;
            const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
            if(field.type==='number')input.step=String(field.step??'any');
            if(field.type==='checkbox')input.checked=!!field.value;
            else if(field.type!=='file')input.value=String(field.value??'');
            input.required=!!field.required;
            if(field.accept)input.accept=field.accept;
            label.append(input);fields.append(label);
          }
          output.textContent='';
        }
        choose.addEventListener('change',renderFields,{signal});renderFields();
        form.addEventListener('submit',async event=>{
          event.preventDefault();if(pending)return;
          const recipe=recipes.find(item=>item.id===choose.value),values={};
          for(const input of fields.querySelectorAll('input')) {
            values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
          }
          pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
          for(const input of fields.querySelectorAll('input'))input.disabled=true;
          output.textContent='Running…';
          try {
            const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
            if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
          } catch(error) {
            if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
          } finally {
            pending=false;
            if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
          }
        },{signal});
        close.addEventListener('click',()=>void modal.close(),{signal});
        modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
      },{signal});
      onDispose(()=>{button.remove();if(modal)void modal.close();});
    });
  },
  stop() {}
};

recipes.mjs

javascript
export const recipes=[
{
  id:"user-read",
  label:"Read personal settings and list their keys",
  description:"Run before saving. A missing key returns null; this is different from an unavailable storage service.",
  fields:[],
  run:async ({storage}) => {
  return {current:await storage.user.get('workshop.preferences'),keys:await storage.user.list('workshop.')};
}
},
{
  id:"user-set",
  label:"Save a versioned personal setting with CAS",
  description:"Choose a label. Read supplies the expected revision; a concurrent edit causes conflict. Do not replace that revision and retry automatically.",
  fields:[
  {
    "name": "label",
    "label": "Panel label",
    "value": "My campaign notes",
    "type": "text",
    "required": true
  }
],
  run:async ({storage},v) => {
  const current=await storage.user.get('workshop.preferences');
  if(current && current.value?.schema!==1) throw new Error('Unsupported settings schema; migrate explicitly');
  return storage.user.set('workshop.preferences',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
}
},
{
  id:"user-delete",
  label:"Delete only this tutorial preference",
  description:"Delete uses the latest revision of one exact key. No other module or campaign preference is removed.",
  fields:[],
  run:async ({storage}) => {
  const current=await storage.user.get('workshop.preferences');
  if(current) await storage.user.delete('workshop.preferences',{expectedRevision:current.revision});
  return storage.user.get('workshop.preferences');
}
},
{
  id:"table-store",
  label:"Share one GM-authored setting with the table",
  description:"GM writes; players take the read-only branch. This is shared module JSON, not permission to change unrelated documents.",
  fields:[
  {
    "name": "label",
    "label": "Shared label",
    "value": "Workshop rules of courtesy",
    "type": "text",
    "required": true
  }
],
  run:async ({api,storage,signal},v) => {
  if(!(await api.actors.state({}, {signal})).is_gm) return {readOnly:true,current:await storage.table.get('workshop.shared')};
  const current=await storage.table.get('workshop.shared');
  const saved=await storage.table.set('workshop.shared',{schema:1,label:v.label},{expectedRevision:current?.revision??null});
  return {saved,keys:await storage.table.list('workshop.')};
}
},
{
  id:"table-delete",
  label:"Remove the shared tutorial key",
  description:"Run as GM after testing the shared value.",
  fields:[],
  run:async ({storage}) => {
  const current=await storage.table.get('workshop.shared');
  if(current)await storage.table.delete('workshop.shared',{expectedRevision:current.revision});
  return storage.table.get('workshop.shared');
}
},
{
  id:"local",
  label:"Use browser-local settings and clear them",
  description:"Only UI choices belong here. This namespace is module+browser origin, shared across accounts and tables in that browser.",
  fields:[
  {
    "name": "density",
    "label": "Local density",
    "value": "compact",
    "type": "text",
    "required": true
  }
],
  run:async ({storage},v) => {
  await storage.local.set('workshop.local',{schema:1,density:v.density});
  const value=await storage.local.get('workshop.local');
  await storage.local.delete('workshop.local');
  return {observed:value,afterDelete:await storage.local.get('workshop.local')};
}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.module-settings"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-module-settings { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-module-settings label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-module-settings input, .tutorial-workbench-module-settings select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-module-settings pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-module-settings button { margin-right: var(--space-3); }

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. Permissões, falhas e teste final

Execute gravações apenas em campanha nova de desenvolvimento com recursos originais. Esconder controles não substitui autorização no servidor. Teste GM, jogador só de leitura, UUID ausente, ID de outra campanha, falha de rede e desativação durante requisição. conflict exige nova leitura e decisão do usuário; não force repetição silenciosa. Cada nova ação lógica precisa de requestId novo. Reutilize um UUID apenas para repetir aquela operação quando o serviço suporta recibos. Leitura e escrita são transações separadas; confira estado salvo após falha parcial.

Como usar a IA para estender este projeto

text
Adapte o módulo completo module-settings para [objetivo] no Gravewright 0.3.0. Leia o serviço público e a receita correspondente. Preserve projeções autorizadas, revisões opacas, UUIDs, cancelamento e descarte. Entregue arquivos completos, nova versão imutável e testes explícitos de GM/jogador e falhas.

Todos os tutoriais · API