Criar um módulo de personagens e pastas
Criar, organizar, renomear e autorizar atores com tipos do sistema.

O projeto que você vai criar
Crie um organizador de atores para o GM. Consulte tipos reais, crie ator e pasta de teste, mova/renomeie e conceda leitura a um jogador. O tutorial de ficha trata os dados completos e tokens independentes.
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 actor-workshop. 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.
actor-workshop/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txt2. Empacote, instale e ative
Partindo da pasta do projeto, execute no PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../actor-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../actor-workshop-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 Build an actor-directory module. Feche o menu e clique no botão Build an actor-directory module. 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
Consultar tipos, documentos e pastas
Execute primeiro. actorType vem do sistema selecionado; copie um ID de ator existente para as próximas ações.
async ({api,signal}) => {
const state=await api.actors.state({}, {signal});
return {actorTypes:state.actorTypes,actors:state.actors,folders:state.folders,players:state.players,isGM:state.is_gm};
}Resultado esperado: Diretório projetado, tipos registrados e IDs dos jogadores. O corpo da ficha é lido separadamente.
Como conferir: Compare com Actors como GM e jogador. Entradas só de listagem têm canOpen:false.
Criar ator com o tipo do sistema selecionado
Informe nome fictício; crie apenas um ator descartável. A receita usa o primeiro tipo registrado de verdade, sem presumir um RPG.
async ({api,signal},v) => {
const state=await api.actors.state({}, {signal});
if(!state.is_gm) throw new Error('GM required');
const type=state.actorTypes[0]?.id;
if(!type) throw new Error('Select a system declaring actor types first');
const created=await api.actors.create({name:v.name,type,data:{}},{requestId:crypto.randomUUID(),signal});
return api.actors.get(created.id,{}, {signal});
}Resultado esperado: Metadados do novo ator e seu ID.
Como conferir: Abra Actors, encontre Workshop scout e abra a ficha nativa/do sistema.
Renomear ator com revisão explícita
Cole o ID criado acima e um novo nome. Só os metadados mudam; dados do sistema usam saveSheet.
async ({api,signal},v) => {
const before=await api.actors.get(v.id,{}, {signal});
await api.actors.update(v.id,{name:v.name,version:before.version},{requestId:crypto.randomUUID(),signal});
return api.actors.get(v.id,{}, {signal});
}Resultado esperado: Mesmo ID, novo nome e nova versão.
Como conferir: Atualize o diretório. Revisão antiga deve retornar conflict, sem gravação forçada.
Criar e identificar pasta de atores
Escolha nome original. Nem todo comando retorna ID, por isso a receita encontra a pasta nova no state.
async ({api,signal},v) => {
const before=await api.actors.state({}, {signal});
if(!before.is_gm) throw new Error('GM required');
await api.actors.createFolder({name:v.name,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
const after=await api.actors.state({}, {signal});
return after.folders.filter(folder=>!before.folders.some(old=>old.id===folder.id));
}Resultado esperado: Pasta criada e seu UUID real.
Como conferir: Encontre a pasta em Actors e copie o ID.
Renomear pasta e mover ator para ela
Use IDs da pasta e ator descartáveis. parentId:null coloca a pasta na raiz; a profundidade é limitada pelo serviço.
async ({api,signal},v) => {
await api.actors.updateFolder({id:v.folderId,name:v.name,parentId:null,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
const actor=await api.actors.get(v.actorId,{}, {signal});
await api.actors.update(actor.id,{folderId:v.folderId,version:actor.version},{requestId:crypto.randomUUID(),signal});
return api.actors.state({}, {signal});
}Resultado esperado: folderId do ator aponta para a pasta.
Como conferir: Expanda a pasta em Actors e confira o ator dentro.
Conceder leitura a um jogador
Copie ID de jogador em Consultar, não ID de personagem. A receita mescla o mapa para preservar as demais permissões.
async ({api,signal},v) => {
const actor=await api.actors.get(v.actorId,{}, {signal});
if(!actor.permissions) throw new Error('GM projection required');
await api.actors.setPermissions({id:actor.id,version:actor.version,permissions:{...actor.permissions,[v.userId]:'read'}},{requestId:crypto.randomUUID(),signal});
return api.actors.get(actor.id,{}, {signal});
}Resultado esperado: GM vê o mapa preservado com a nova permissão de leitura.
Como conferir: Abra segunda sessão do jogador: leitura funciona e gravação da ficha continua negada.
Remover apenas a pasta de teste
Confirme a pasta criada no tutorial. recursive:false preserva atores; não use recursive:true como atalho para limpeza.
async ({api,signal},v) => {
if(!v.confirm) throw new Error('Select the disposable folder confirmation');
await api.actors.deleteFolder({id:v.folderId,recursive:false},{requestId:crypto.randomUUID(),signal});
return api.actors.state({}, {signal});
}Resultado esperado: Pasta desaparece; atores permanecem conforme remoção nativa.
Como conferir: Confira que o ator descartável continua em Actors.
Excluir o ator de teste
Use apenas o ator criado acima. A exclusão também pode remover tokens pelos serviços nativos.
async ({api,signal},v) => {
if(!v.confirm) throw new Error('Select the disposable actor confirmation');
await api.actors.delete({id:v.id},{requestId:crypto.randomUUID(),signal});
return api.actors.list({}, {signal});
}Resultado esperado: Ator ausente da lista autorizada.
Como conferir: Atualize Actors e a cena de teste. Não selecione dados fora deste exemplo.
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.actor-workshop",
"name": "Build an actor-directory module",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Create, organize, rename and authorize actors using system-owned types.",
"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="Build an actor-directory module";
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-actor-workshop";
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:"inspect",
label:"Inspect actor types, documents and folders",
description:"Run this first. The actorType comes from the selected system; copy an existing actor ID for later steps.",
fields:[],
run:async ({api,signal}) => {
const state=await api.actors.state({}, {signal});
return {actorTypes:state.actorTypes,actors:state.actors,folders:state.folders,players:state.players,isGM:state.is_gm};
}
},
{
id:"create",
label:"Create an actor using the selected system",
description:"Enter a fictional name; create only a disposable actor. This uses the first real registered type, not an assumed RPG type.",
fields:[
{
"name": "name",
"label": "Actor name",
"value": "Workshop scout",
"type": "text",
"required": true
}
],
run:async ({api,signal},v) => {
const state=await api.actors.state({}, {signal});
if(!state.is_gm) throw new Error('GM required');
const type=state.actorTypes[0]?.id;
if(!type) throw new Error('Select a system declaring actor types first');
const created=await api.actors.create({name:v.name,type,data:{}},{requestId:crypto.randomUUID(),signal});
return api.actors.get(created.id,{}, {signal});
}
},
{
id:"rename",
label:"Rename an actor with an explicit revision",
description:"Paste the ID created above and a new name. Only metadata changes; use saveSheet when editing system data.",
fields:[
{
"name": "id",
"label": "Actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "name",
"label": "New name",
"value": "Workshop navigator",
"type": "text",
"required": true
}
],
run:async ({api,signal},v) => {
const before=await api.actors.get(v.id,{}, {signal});
await api.actors.update(v.id,{name:v.name,version:before.version},{requestId:crypto.randomUUID(),signal});
return api.actors.get(v.id,{}, {signal});
}
},
{
id:"folder-create",
label:"Create and identify an actor folder",
description:"Choose an original folder name. The service does not promise an ID in every command result, so find the newly added folder in state.",
fields:[
{
"name": "name",
"label": "Folder name",
"value": "Workshop actors",
"type": "text",
"required": true
}
],
run:async ({api,signal},v) => {
const before=await api.actors.state({}, {signal});
if(!before.is_gm) throw new Error('GM required');
await api.actors.createFolder({name:v.name,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
const after=await api.actors.state({}, {signal});
return after.folders.filter(folder=>!before.folders.some(old=>old.id===folder.id));
}
},
{
id:"folder-edit",
label:"Rename a folder and move an actor into it",
description:"Use the disposable folder and actor IDs. parentId:null moves the folder to the root. Nesting is bounded by the service.",
fields:[
{
"name": "folderId",
"label": "Folder ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "actorId",
"label": "Actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "name",
"label": "Folder name",
"value": "Workshop party",
"type": "text",
"required": true
}
],
run:async ({api,signal},v) => {
await api.actors.updateFolder({id:v.folderId,name:v.name,parentId:null,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
const actor=await api.actors.get(v.actorId,{}, {signal});
await api.actors.update(actor.id,{folderId:v.folderId,version:actor.version},{requestId:crypto.randomUUID(),signal});
return api.actors.state({}, {signal});
}
},
{
id:"permissions",
label:"Grant one player read access",
description:"Copy a real player ID from Inspect, not an actor ID. Merge the existing permission map to preserve other grants.",
fields:[
{
"name": "actorId",
"label": "Actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "userId",
"label": "Player user ID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},v) => {
const actor=await api.actors.get(v.actorId,{}, {signal});
if(!actor.permissions) throw new Error('GM projection required');
await api.actors.setPermissions({id:actor.id,version:actor.version,permissions:{...actor.permissions,[v.userId]:'read'}},{requestId:crypto.randomUUID(),signal});
return api.actors.get(actor.id,{}, {signal});
}
},
{
id:"delete-folder",
label:"Remove only the disposable actor folder",
description:"Confirm a folder you created in this tutorial. recursive:false preserves actors; never use recursive:true as an unrelated cleanup shortcut.",
fields:[
{
"name": "folderId",
"label": "Folder ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "I chose the disposable folder",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,signal},v) => {
if(!v.confirm) throw new Error('Select the disposable folder confirmation');
await api.actors.deleteFolder({id:v.folderId,recursive:false},{requestId:crypto.randomUUID(),signal});
return api.actors.state({}, {signal});
}
},
{
id:"delete-actor",
label:"Delete the disposable actor",
description:"Use only the actor created above. Deleting an actor can also remove its tokens through native domain services.",
fields:[
{
"name": "id",
"label": "Actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "I chose the disposable actor",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,signal},v) => {
if(!v.confirm) throw new Error('Select the disposable actor confirmation');
await api.actors.delete({id:v.id},{requestId:crypto.randomUUID(),signal});
return api.actors.list({}, {signal});
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.actor-workshop"] .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-actor-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-actor-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-actor-workshop input, .tutorial-workbench-actor-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-actor-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-actor-workshop 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
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
Adapte o módulo completo actor-workshop 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.