Como criar, sortear e restaurar tabelas de rolagem
Crie uma tabela de diário original, altere um peso preservando dados e publique sorteios nativos com ou sem reposição.

O projeto que você vai criar
Crie tabela de clima do porto com duas entradas, leia seu formato real, altere um peso, sorteie até esgotar e restaure. O tipo é journal_type roll_table, diferente de pack de compêndio ou tabela específica de sistema.
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 roll-tables. 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.
roll-tables/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtUse campanha descartável como mestre e uma segunda conta de jogador para conferir audiência. Use resultados originais. Informe cena autorizada apenas se o sorteio deve ter aquela cena como alvo; caso contrário, usa o contexto nativo de transmissão.
2. Empacote, instale e ative
Partindo da pasta do projeto, execute no PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../roll-tables-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../roll-tables-1.0.0.zipNo 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 Roll table tools. Feche o menu e clique no botão Roll table tools. 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
Criar uma tabela original com pesos
Como mestre, crie journal_type roll_table e configure duas entradas completas. Pesos 3 e 1 produzem probabilidades diferentes. Sem reposição, entradas sorteadas deixam de participar até reset.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const created=await api.journals.create({title:v.title,journal_type:'roll_table'},{signal,requestId:crypto.randomUUID()});try{await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,visibility:'private',data:{withReplacement:v.replacement,resultVisibility:v.secret?'gm':'public',entries:[{id:'clear',name:'Clear sky',weight:3,result:'The beacon is visible across the bay.',active:true,drawn:false,sortOrder:10},{id:'fog',name:'Fog',weight:1,result:'The boats wait for the fog to lift.',active:true,drawn:false,sortOrder:20}]}},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Roll table '+created.journal_id+' exists; configuration failed: '+(error.code||error.message)+'. Repair this table before creating another.');}}Resultado esperado: Uma tabela com totalWeight 4, IDs próprios de entradas e audiência escolhida.
Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.
Ler pesos, resultados e marcadores de sorteio
Use o UUID da tabela. roll_table contém entradas e totalWeight; texto exclusivo do mestre é removido para os demais membros.
async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table journal.');return row;}Resultado esperado: Entradas autorizadas com weight, active, drawn e sortOrder.
Como conferir: Dê leitura ao jogador e compare uma tabela secreta. Ele deve receber result vazio e não pode rolá-la.
Alterar um peso preservando as demais entradas
Como mestre, use o ID exato da entrada. Copie a tabela completa e altere um peso; totalWeight é calculado e não entra nos dados enviados.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required to preserve complete results.');if(!Number.isInteger(v.weight)||v.weight<1||v.weight>1000000)throw new Error('Invalid weight.');const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table.');const data=structuredClone(row.roll_table);if(!data.entries.some(entry=>entry.id===v.entryId))throw new Error('Entry not found.');data.entries=data.entries.map(entry=>entry.id===v.entryId?{...entry,weight:v.weight}:entry);delete data.totalWeight;await api.journals.update({journal_id:row.id,version:row.version,title:row.title,visibility:row.visibility,data},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}Resultado esperado: O peso escolhido muda e totalWeight é recalculado.
Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.
Sortear e publicar um resultado da tabela
Execute como mestre. O comando nativo sorteia com pesos, persiste drawn quando necessário e publica no chat. O payload usa mapId para cena explícita.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');return api.journals.roll({journal_id:v.id,...(v.sceneId.trim()?{mapId:v.sceneId.trim()}:{})},{signal,requestId:crypto.randomUUID()});}Resultado esperado: A entrada escolhida e message_id; o chat nativo mostra o resultado autorizado.
Como conferir: Sem reposição, role duas vezes e confira entradas distintas; a terceira deve falhar até reset. Não repita o mesmo sorteio com UUID novo após perder a resposta.
Restaurar marcadores sem alterar resultados
Use o UUID da tabela esgotada. Reset define drawn falso em cada entrada; não limpa o chat nem substitui o texto da tabela.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');await api.journals.reset({journal_id:v.id},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}Resultado esperado: As entradas ativas participam novamente e totalWeight é restaurado.
Como conferir: Abra o diretório ou documento nativo e compare o estado do servidor. Compare mestre e jogador; apenas conteúdo autorizado pode aparecer.
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
{
"id": "tutorial.roll-tables",
"name": "Roll table tools",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Build an original journal roll table, edit a weight without losing data and publish native draws with or without replacement.",
"author": "Gravewright documentation",
"license": "MIT"
}main.mjs
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="Roll table tools";
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-roll-tables";
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
export const recipes=[
{
id:"create-table",
label:"Create a weighted original roll table",
description:"As GM, create journal_type roll_table and configure its two complete entries. Weights 3 and 1 give different probabilities. Without replacement, drawn entries stop being eligible until reset.",
fields:[
{
"name": "title",
"label": "Roll table title",
"type": "text",
"value": "Harbor weather",
"required": true
},
{
"name": "secret",
"label": "GM-only results",
"type": "checkbox",
"value": false
},
{
"name": "replacement",
"label": "Allow replacement",
"type": "checkbox",
"value": false
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const created=await api.journals.create({title:v.title,journal_type:'roll_table'},{signal,requestId:crypto.randomUUID()});try{await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,visibility:'private',data:{withReplacement:v.replacement,resultVisibility:v.secret?'gm':'public',entries:[{id:'clear',name:'Clear sky',weight:3,result:'The beacon is visible across the bay.',active:true,drawn:false,sortOrder:10},{id:'fog',name:'Fog',weight:1,result:'The boats wait for the fog to lift.',active:true,drawn:false,sortOrder:20}]}},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Roll table '+created.journal_id+' exists; configuration failed: '+(error.code||error.message)+'. Repair this table before creating another.');}}
},
{
id:"read-table",
label:"Read weights, results and draw flags",
description:"Use the table UUID. The projected roll_table contains entries and totalWeight; GM-only result text is removed for other members.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
}
],
run:async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table journal.');return row;}
},
{
id:"set-weight",
label:"Change one weight while preserving every other entry",
description:"As GM, use the exact entry ID. Copy the complete projected table and alter one weight; totalWeight is derived and omitted from the submitted data.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
},
{
"name": "entryId",
"label": "Entry ID",
"type": "text",
"value": "fog",
"required": true
},
{
"name": "weight",
"label": "Weight, integer 1 to 1000000",
"type": "number",
"value": 2,
"required": true
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required to preserve complete results.');if(!Number.isInteger(v.weight)||v.weight<1||v.weight>1000000)throw new Error('Invalid weight.');const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table.');const data=structuredClone(row.roll_table);if(!data.entries.some(entry=>entry.id===v.entryId))throw new Error('Entry not found.');data.entries=data.entries.map(entry=>entry.id===v.entryId?{...entry,weight:v.weight}:entry);delete data.totalWeight;await api.journals.update({journal_id:row.id,version:row.version,title:row.title,visibility:row.visibility,data},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}
},
{
id:"roll",
label:"Draw and publish one table result",
description:"Run as GM. The native command uses a secure weighted draw, persists drawn when needed and publishes a chat result. Its payload uses mapId for an explicit scene.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
},
{
"name": "sceneId",
"label": "Authorized scene UUID, optional",
"type": "text",
"value": "",
"required": false
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');return api.journals.roll({journal_id:v.id,...(v.sceneId.trim()?{mapId:v.sceneId.trim()}:{})},{signal,requestId:crypto.randomUUID()});}
},
{
id:"reset",
label:"Reset drawn flags without changing results",
description:"Use the exhausted table UUID. Reset changes drawn to false for every entry; it does not clear native chat or replace table text.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');await api.journals.reset({journal_id:v.id},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}
}
];styles.css
.gw-module-root[data-module-id="tutorial.roll-tables"] .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-roll-tables { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-roll-tables label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-roll-tables input, .tutorial-workbench-roll-tables select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-roll-tables pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-roll-tables button { margin-right: var(--space-3); }LICENSE.txt
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
Somente mestres podem sortear ou restaurar. Um jogador com leitura vê metadados autorizados, mas não texto de resultado quando resultVisibility é gm. Com reposição falsa, duas entradas ativas permitem dois sorteios até esgotar. Reset restaura participação sem sortear ou limpar chat. Edições preservam todas as entradas e a versão exata; não envie projeção filtrada de jogador como tabela completa do mestre. Repare falha de configuração pelo UUID criado informado.
Como usar a IA para estender este projeto
Estenda roll-tables no Gravewright 0.3.0 com [comportamento]. Leia normalize_roll_table_data, journals.types.apply e o mapeamento público. Preserve sorteio nativo com pesos, active/drawn, withReplacement, resultVisibility, roll/reset exclusivos do mestre, versão exata e UUID de tentativa. Entregue entradas completas e teste esgotamento, restauração, resultados privados e cena ausente.