InícioDocs para devsComo criar um módulo de ferramentas de cena
Tutoriais de implementação 18 min de leitura

Como criar um módulo de ferramentas de cena

Monte módulo que consulta camadas, edita metadados/pastas, cria uma porta nativa e transmite cena de teste.

Módulo Scene workshop instalado após executar a receita inspect. Gravewright 0.3.0, interface em inglês.
Módulo Scene workshop instalado após executar a receita inspect. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

O projeto que você vai criar

Crie Scene workshop, módulo de navegador com laboratório em modal nativo. Siga uma sequência concreta: consultar cena descartável, renomear, organizar pasta, criar/editar/operar porta e transmitir conscientemente. Exclua os dados só depois dos tutoriais de tokens e combate.

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 maps-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.

text
maps-workshop/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Em campanha descartável, crie uma cena vazia de pelo menos8×8 células pelo fluxo nativo Scenes → New scene → Map Builder, depois Generate scene. Comece sem conteúdo valioso. A API de domínio do navegador não tem maps.create para raster/projeto Builder. Use o Map Builder para autoria; geração do projeto é separada dos comandos abaixo.

2. Empacote, instale e ative

Partindo da pasta do projeto, execute no PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../maps-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../maps-workshop-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 Scene workshop. Feche o menu e clique no botão Scene workshop. 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 cenas, pastas e camadas nativas

Execute primeiro sem Scene ID. Copie o ID da cena de teste, preencha e repita para consultar paredes, imagens, luzes, neblina, iluminação e suas versões.

javascript
async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const directory=await api.maps.state({},read);
    const layers=v.mapId.trim()?await api.maps.layers({mapId:v.mapId.trim()},read):null;
    return {directory,layers};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: IDs autorizados, activeMapId e navigatingMapId; camadas nativas da cena quando informada.

Como conferir: Compare o diretório de GM com a conta de jogador. O estado de jogador omite cenas privadas, regiões e grafos privados.

Renomear cena de teste e definir a grade

Use cena descartável. Name e gridVisible são os ajustes enviados; os demais são preservados. Não envie toda a projeção como settings.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const before=await api.maps.get(v.mapId,{},read);
    await api.maps.update({mapId:before.id,version:before.version,settings:{name:v.name,gridVisible:v.gridVisible}},write());
    return await api.maps.get(before.id,{},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: Mesmo ID, novo nome, visibilidade da grade escolhida e nova versão inteira.

Como conferir: Abra os ajustes nativos e confira nome/grade; imageScale, gridSize e unidades continuam iguais.

Criar pasta de cenas

Informe nome original de pasta. Copie created.folderId para as próximas duas receitas.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const created=await api.maps.createFolder({label:v.label,color:'#b49a5a',parentId:null},write());
    return {created,folders:(await api.maps.state({},read)).folders};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: UUID da pasta e lista de pastas atualizada.

Como conferir: Encontre Workshop scenes no diretório nativo de Cenas.

Renomear ou mover pasta de cenas

Use a pasta criada acima. Parent vazio coloca na raiz. O servidor rejeita ciclos, pastas de outra campanha e profundidade excedida.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.maps.updateFolder({folderId:v.folderId,label:v.label,color:'#b49a5a',parentId:v.parentId.trim()||null},write());
    return (await api.maps.state({},read)).folders;
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: ID preservado com novo nome/pai.

Como conferir: Expanda a árvore e confira a posição. Tente usar a própria pasta como pai e confira a recusa.

Mover cena para a pasta do tutorial

Cole os IDs da cena e pasta. Este comando usa groupId, não o folder_id dos diários.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.maps.get(v.mapId,{},read);
    await api.maps.move({mapId:v.mapId,groupId:v.folderId.trim()||null},write());
    return await api.maps.get(v.mapId,{},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: groupId da cena referencia a pasta ou null na raiz.

Como conferir: Localize a mesma cena em Workshop rooms. ID e objetos nativos permanecem.

Criar porta nativa por updateObjects

Use cena de pelo menos 8 células de largura. As coordenadas são pixels da cena, diferentes das células de token.move. Os pontos precisam diferir. Cria porta nativa simples, sem blueprint ou imagem.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.maps.get(v.mapId,{},read);
    const result=await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'create',data:{x1:v.x1,y1:v.y1,x2:v.x2,y2:v.y2,kind:'door',door_state:'closed'}},write());
    return {created:result.wall,layers:await api.maps.layers({mapId:v.mapId},read)};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: Registro de parede com kind door, ID e versão. Copie seu ID.

Como conferir: Abra Walls na cena nativa e localize a porta horizontal. Confira os pontos na projeção.

Ajustar ponto da porta com sua versão

Use a porta criada acima. Só x2 muda; o validador nativo preserva os demais campos. Versão da cena e versão do objeto são diferentes.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const layers=await api.maps.layers({mapId:v.mapId},read);
    const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
    if(!door) throw new Error('Choose a door from this scene');
    await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'update',data:{wall_id:door.id,version:door.version,x2:v.x2}},write());
    return (await api.maps.layers({mapId:v.mapId},read)).walls.find(row=>row.id===door.id);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: Porta maior/menor, mesmo ID e versão de objeto incrementada.

Como conferir: Confira preservação de x1/y1/y2, apresentação e canais de barreira. Edição nativa intercalada deve causar conflict.

Abrir ou fechar a porta simples de teste

Informe open e execute; depois closed e repita. A receita usa porta simples. Em Objeto Inteligente, a interação pode executar grafos em vez de uma alternância simples.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!['open','closed','locked'].includes(v.state)) throw new Error('Use open, closed or locked');
    const layers=await api.maps.layers({mapId:v.mapId},read);
    const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
    if(!door||door.blueprint) throw new Error('Choose the plain tutorial door without a blueprint');
    return await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'door',data:{wall_id:door.id,door_state:v.state}},write());
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: A porta simples abre/fecha na cena nativa.

Como conferir: Observe geometria e bloqueio. Teste interação de jogador pelos controles nativos, incluindo recusa de porta trancada.

Transmitir a cena de teste preparada

Escolha a cena descartável preparada e marque a transmissão. Activate torna a cena visível e altera a transmissão da campanha.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the test scene broadcast');
    await api.maps.get(v.mapId,{},read);
    await api.maps.activate({mapId:v.mapId},write());
    return await api.maps.state({},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: activeMapId corresponde à cena; jogadores conectados mudam para ela.

Como conferir: Use segunda conta de jogador. Confira que a transmissão muda só depois de Run action com a opção marcada.

Excluir somente a porta do tutorial

Use somente a porta criada no tutorial e confirme. Apagar Objeto Inteligente pode executar destruição configurada; a porta simples do tutorial não tem grafo.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable door');
    const layers=await api.maps.layers({mapId:v.mapId},read);
    const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
    if(!door) throw new Error('Door not found');
    await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'delete',data:{wall_id:door.id,expected_version:door.version}},write());
    return await api.maps.layers({mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: A porta selecionada desaparece e as demais paredes permanecem.

Como conferir: Compare paredes antes/depois e confirme remoção só do ID copiado.

Remover pasta de teste preservando cenas

Use a pasta criada aqui. Recursive:false move cenas/subpastas ao pai, em vez de apagar a árvore.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable folder');
    await api.maps.deleteFolder({folderId:v.folderId,recursive:false},write());
    return await api.maps.state({},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: Pasta ausente e cenas preservadas.

Como conferir: Encontre Workshop chamber na pasta pai.

Excluir a cena descartável por último

Execute por último e só na cena descartável, depois dos tutoriais de tokens/combate. Excluir cena também remove seu conteúdo.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable scene');
    await api.maps.get(v.mapId,{},read);
    await api.maps.delete({mapId:v.mapId},write());
    return await api.maps.list({},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}

Resultado esperado: Cena selecionada ausente da lista autorizada.

Como conferir: Confira que as outras cenas/campanhas permanecem. Não use em mapa de campanha existente.

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.maps-workshop",
  "name": "Scene workshop",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Build a working module to inspect scene layers, edit metadata, manage folders, draw a native door and broadcast a test scene.",
  "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="Scene workshop";
      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-maps-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

javascript
export const recipes=[
{
  id:"inspect",
  label:"Inspect scenes, folders and native layers",
  description:"Run with the Scene ID blank first. Copy the test scene ID, enter it and run again to inspect walls, images, lights, fog, lighting and their returned versions.",
  fields:[
  {
    "name": "mapId",
    "label": "Optional scene ID",
    "value": "",
    "type": "text",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const directory=await api.maps.state({},read);
    const layers=v.mapId.trim()?await api.maps.layers({mapId:v.mapId.trim()},read):null;
    return {directory,layers};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"metadata",
  label:"Rename the test scene and set grid visibility",
  description:"Use a disposable scene. Name and gridVisible are supplied settings; other scene settings remain unchanged. Do not send the entire projected scene as settings.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "name",
    "label": "Scene name",
    "value": "Workshop chamber",
    "type": "text",
    "required": true
  },
  {
    "name": "gridVisible",
    "label": "Show grid",
    "value": true,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const before=await api.maps.get(v.mapId,{},read);
    await api.maps.update({mapId:before.id,version:before.version,settings:{name:v.name,gridVisible:v.gridVisible}},write());
    return await api.maps.get(before.id,{},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"folder-create",
  label:"Create a scene folder",
  description:"Enter an original folder label. Copy created.folderId for the next two recipes.",
  fields:[
  {
    "name": "label",
    "label": "Folder label",
    "value": "Workshop scenes",
    "type": "text",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const created=await api.maps.createFolder({label:v.label,color:'#b49a5a',parentId:null},write());
    return {created,folders:(await api.maps.state({},read)).folders};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"folder-update",
  label:"Rename or reparent a scene folder",
  description:"Use the folder created above. Blank Parent puts it at the root. The server rejects cycles, foreign folders and nesting deeper than its supported limit.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "label",
    "label": "New label",
    "value": "Workshop rooms",
    "type": "text",
    "required": true
  },
  {
    "name": "parentId",
    "label": "Parent folder ID, blank for root",
    "value": "",
    "type": "text",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.maps.updateFolder({folderId:v.folderId,label:v.label,color:'#b49a5a',parentId:v.parentId.trim()||null},write());
    return (await api.maps.state({},read)).folders;
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"move",
  label:"Move the scene into the tutorial folder",
  description:"Paste the test scene and folder IDs. This command uses groupId; it is not the journal folder_id contract.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "folderId",
    "label": "Folder ID, blank for root",
    "value": "",
    "type": "text",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.maps.get(v.mapId,{},read);
    await api.maps.move({mapId:v.mapId,groupId:v.folderId.trim()||null},write());
    return await api.maps.get(v.mapId,{},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"door-create",
  label:"Draw a native test door through updateObjects",
  description:"Use a scene at least 8 cells wide. Coordinates are scene pixels, not the cell coordinates used by token.move. The endpoints must differ. This creates a plain native door with no blueprint or image dependency.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "x1",
    "label": "Start x in scene pixels",
    "value": 70,
    "type": "number",
    "required": true
  },
  {
    "name": "y1",
    "label": "Start y in scene pixels",
    "value": 70,
    "type": "number",
    "required": true
  },
  {
    "name": "x2",
    "label": "End x in scene pixels",
    "value": 210,
    "type": "number",
    "required": true
  },
  {
    "name": "y2",
    "label": "End y in scene pixels",
    "value": 70,
    "type": "number",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.maps.get(v.mapId,{},read);
    const result=await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'create',data:{x1:v.x1,y1:v.y1,x2:v.x2,y2:v.y2,kind:'door',door_state:'closed'}},write());
    return {created:result.wall,layers:await api.maps.layers({mapId:v.mapId},read)};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"door-update",
  label:"Adjust a door endpoint with its object version",
  description:"Use the door created above. Only x2 changes; the native validator retains its other fields. Scene version and object version are different.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "doorId",
    "label": "Door ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "x2",
    "label": "New end x in scene pixels",
    "value": 280,
    "type": "number",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const layers=await api.maps.layers({mapId:v.mapId},read);
    const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
    if(!door) throw new Error('Choose a door from this scene');
    await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'update',data:{wall_id:door.id,version:door.version,x2:v.x2}},write());
    return (await api.maps.layers({mapId:v.mapId},read)).walls.find(row=>row.id===door.id);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"door-operate",
  label:"Open or close the plain test door",
  description:"Enter open, run, then enter closed and run again. This recipe deliberately uses a plain door. On a Smart Object the same interaction may execute authored graphs instead of applying a simple toggle.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "doorId",
    "label": "Door ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "state",
    "label": "Door state: open, closed or locked",
    "value": "open",
    "type": "text",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!['open','closed','locked'].includes(v.state)) throw new Error('Use open, closed or locked');
    const layers=await api.maps.layers({mapId:v.mapId},read);
    const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
    if(!door||door.blueprint) throw new Error('Choose the plain tutorial door without a blueprint');
    return await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'door',data:{wall_id:door.id,door_state:v.state}},write());
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"activate",
  label:"Broadcast the prepared test scene",
  description:"Choose the disposable prepared scene and explicitly check the broadcast box. activate makes the scene player-visible and changes the campaign broadcast.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I intend to broadcast this test scene to players",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the test scene broadcast');
    await api.maps.get(v.mapId,{},read);
    await api.maps.activate({mapId:v.mapId},write());
    return await api.maps.state({},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"door-delete",
  label:"Delete only the tutorial door",
  description:"Use only the door created in this tutorial and confirm. A Smart Object deletion can have authored destruction effects; the tutorial plain door has none.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "doorId",
    "label": "Door ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I selected disposable tutorial data",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable door');
    const layers=await api.maps.layers({mapId:v.mapId},read);
    const door=layers.walls.find(row=>row.id===v.doorId&&row.kind==='door');
    if(!door) throw new Error('Door not found');
    await api.maps.updateObjects({mapId:v.mapId,area:'walls',action:'delete',data:{wall_id:door.id,expected_version:door.version}},write());
    return await api.maps.layers({mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"folder-delete",
  label:"Remove the test folder while keeping scenes",
  description:"Use the folder created here. recursive:false moves contained scenes/child folders to the parent instead of deleting the scene tree.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I selected disposable tutorial data",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable folder');
    await api.maps.deleteFolder({folderId:v.folderId,recursive:false},write());
    return await api.maps.state({},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
},
{
  id:"delete",
  label:"Delete the disposable scene last",
  description:"Run last on the disposable scene only, after the token/combat tutorials. Scene deletion also removes its scene-owned content.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I selected the disposable scene and its test contents",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable scene');
    await api.maps.get(v.mapId,{},read);
    await api.maps.delete({mapId:v.mapId},write());
    return await api.maps.list({},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.maps.state({},read);
    throw problem;
  }
}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.maps-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-maps-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-maps-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-maps-workshop input, .tutorial-workbench-maps-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-maps-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-maps-workshop 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

Cada gravação exige Run action explícito. Leituras usam projeções autorizadas; escritas mantêm permissões nativas e UUID novo por operação. Update de cena/objeto, movimento/configuração de token e combate usam seus campos reais de revisão. Alguns comandos nativos de seleção não aceitam revisão esperada nesta versão; os exemplos não inventam esse parâmetro. Em conflito, a receita relê o estado e para. Revise e execute conscientemente nova operação. Chamadas diferentes são transações distintas: uma já concluída não é revertida se uma leitura posterior falhar. Desativar aborta trabalho da montagem, sem desfazer commit.

Teste filtragem GM/jogador, revisão antiga de cena e de porta, ciclo de pasta negado e desativação durante leitura. Activate transmite explicitamente aos jogadores. Limite do Map Builder: este pacote não cria JSON de Blueprint nem usa rotas privadas. Use o editor real, Simulate e Test behavior para criar automação; depois Generate/Update scene. Quatro kinds de Blueprint, 42 nós completos e conexões execution/data trazem receitas completas.

Operações públicas cobertas por receita

APIReceita no painel
api.maps.updatemetadata
api.maps.movemove
api.maps.deletedelete
api.maps.activateactivate
api.maps.updateObjectsdoor-create, door-update, door-operate, door-delete
api.maps.createFolderfolder-create
api.maps.updateFolderfolder-update
api.maps.deleteFolderfolder-delete

Como usar a IA para estender este projeto

text
Estenda Scene workshop no Gravewright0.3.0 para [tarefa concreta]. Confira frontend.json, maps/services.py e maps/objects.py para payloads/revisões exatos. Preserve ajustes e campos sem relação. No módulo use só api.maps pública. Para editar fonte Builder/Blueprint, dê passos reais no editor, sem rotas privadas. Entregue código completo, cenário descartável, entradas e testes GM/jogador/conflito/abort.

Todos os tutoriais · API