InícioDocs para devsComo criar ferramentas de chat, dados e prontidão
Tutoriais de implementação 13 min de leitura

Como criar ferramentas de chat, dados e prontidão

Crie um módulo funcional para mensagens, rolagens, moderação, busca de documentos e atualização da prontidão.

Módulo Chat, dice and lobby instalado após executar a receita history. Gravewright 0.3.0, interface em inglês.
Módulo Chat, dice and lobby instalado após executar a receita history. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

O projeto que você vai criar

Monte um painel em modal nativo com doze ações independentes. Comece lendo o histórico, envie mensagem pública, teste sussurro/GM/ação e role dados públicos e secretos. Depois, leia e atualize o lobby. A moderação é explícita e deve usar campanha descartável.

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 chat-dice. 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
chat-dice/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Use campanha descartável com dois membros e um ator original. Ative prontidão e busca nativas nas configurações da instalação para essas receitas; as APIs informam permission_denied quando o recurso está desabilitado. Copie nome de jogador da mesa e UUIDs de documentos das listas nativas.

2. Empacote, instale e ative

Partindo da pasta do projeto, execute no PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../chat-dice-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../chat-dice-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 Chat, dice and lobby. Feche o menu e clique no botão Chat, dice and lobby. 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 o histórico visível

Deixe Scene UUID vazio para usar a cena transmitida ou informe uma cena autorizada. O array retornado é o histórico recente visível, não todas as mensagens já enviadas.

javascript
async ({api,signal},v)=>api.chat.history(v.sceneId.trim()?{sceneId:v.sceneId.trim()}:{},{signal})

Resultado esperado: IDs de mensagens, autores, texto e visibilidade desta conta.

Como conferir: Compare remetente, destinatário de sussurro e outro jogador. Mensagens ocultas não podem aparecer.

Enviar uma mensagem pública

Informe texto original. A receita rejeita barra inicial para que a ação pública não se torne comando sem intenção. A cena opcional usa sceneId.

javascript
async ({api,signal},v)=>{const text=v.text.trim();if(text.startsWith('/'))throw new Error('Use a dedicated command recipe.');return api.chat.send({text,...(v.sceneId.trim()?{sceneId:v.sceneId.trim()}:{})},{signal,requestId:crypto.randomUUID()});}

Resultado esperado: Uma mensagem visível e seu ID no servidor.

Como conferir: Confira o chat nativo com dois membros. Execute novamente apenas se quiser uma segunda mensagem.

Enviar um sussurro ao destinatário

Informe o nome de exibição exato de um membro, incluindo espaços. O comando nativo /w resolve os membros, não uma lista de audiência inventada no cliente. Nomes de exibição iguais podem corresponder a vários membros.

javascript
async ({api,signal},v)=>api.chat.send({text:'/w '+v.recipient.trim()+' '+v.text.trim()},{signal,requestId:crypto.randomUUID()})

Resultado esperado: O servidor retorna sussurro visível ao remetente e destinatários correspondentes.

Como conferir: Leia o histórico como destinatário e não destinatário; só o primeiro recebe o conteúdo. Use nomes únicos no teste.

Enviar uma mensagem aos mestres

O comando nativo /gm envia aos mestres da campanha e ao remetente. É um comando de chat, não um campo visibility em chat.send.

javascript
async ({api,signal},v)=>api.chat.send({text:'/gm '+v.text.trim()},{signal,requestId:crypto.randomUUID()})

Resultado esperado: Uma mensagem filtrada no chat de mestres e remetente.

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.

Publicar uma ação de personagem

Informe uma ação. O comando nativo /me marca a mensagem como ação e mantém a entrega pública.

javascript
async ({api,signal},v)=>api.chat.send({text:'/me '+v.text.trim()},{signal,requestId:crypto.randomUUID()})

Resultado esperado: Uma mensagem com metadata.emote verdadeiro.

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.

Rolar e publicar dados públicos ou exclusivos do mestre

Informe expressão aceita pelo parser, rótulo e repetições. GM-only result escolhe visibility gm. A ação persiste e publica uma rolagem; não é calculadora local.

javascript
async ({api,signal},v)=>{if(!Number.isInteger(v.repeat)||v.repeat<1||v.repeat>12)throw new Error('Repeat must be 1 to 12.');return api.dice.roll(v.expression,{label:v.label,repeat:v.repeat,visibility:v.secret?'gm':'public'},{signal,requestId:crypto.randomUUID()});}

Resultado esperado: Uma submissão persistida com as rolagens e a audiência escolhida.

Como conferir: Compare rolagens públicas e de mestre em duas contas. Rejeite repetição 0 ou 13. Uma tentativa da mesma submissão deve reutilizar o UUID original em vez de rolar novamente.

Moderar uma mensagem

Como mestre, copie o ID numérico do histórico. A ação remove conteúdo e anexos dessa mensagem, preservando a identidade para tentativas.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');if(!/^\d+$/.test(v.messageId))throw new Error('Use the numeric chat ID from history.');return api.chat.remove(v.messageId,{signal});}

Resultado esperado: Quantidade removida; históricos seguintes omitem a mensagem.

Como conferir: Modere apenas mensagem sintética criada para o tutorial. O servidor deve rejeitar um jogador.

Limpar o chat da campanha descartável

Use somente campanha descartável. Digite CLEAR CHAT. A moderação do mestre abrange a campanha inteira e suas cenas, não apenas o histórico exibido.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');if(v.confirmation!=='CLEAR CHAT')throw new Error('Type CLEAR CHAT to clear the whole campaign chat.');return api.chat.clear({signal});}

Resultado esperado: Todas as mensagens ainda não removidas da campanha são apagadas.

Como conferir: Leia o histórico de outra cena e confirme a limpeza. Os recibos de submissão permanecem.

Inspecionar a prontidão e os atores selecionáveis

A instalação deve ter a verificação nativa de prontidão habilitada. Leia membros, resumo e atores que esta conta pode selecionar.

javascript
async ({api,signal})=>api.table.lobby({signal})

Resultado esperado: members, actors e summary.ready/total.

Como conferir: Compare um ator somente leitura com um pertencente ao jogador; apenas os controláveis são selecionáveis.

Atualizar a prontidão deste membro

Escolha a prontidão, UUID selecionável do resultado do lobby e assets_state. Deixe o ator vazio para limpar a seleção. A API usa o membro atual; não recebe ID de usuário para imitar outra pessoa.

javascript
async ({api,signal},v)=>{if(!['unknown','loading','ready','error'].includes(v.assets))throw new Error('Choose a native assets_state.');if(v.actorId.trim()){const lobby=await api.table.lobby({signal});if(!lobby.actors.some(actor=>actor.id===v.actorId.trim()))throw new Error('Actor is not selectable.');}return api.table.updateLobby({is_ready:v.ready,assets_state:v.assets,selected_actor_id:v.actorId.trim()||null},{signal});}

Resultado esperado: O lobby retornado tem a seleção e prontidão atualizadas deste membro.

Como conferir: Use duas contas: apenas a linha do membro atual muda. Ator ou estado de recursos desconhecido deve falhar.

Buscar os sete kinds de documento

Use pelo menos dois caracteres. Kind vazio busca tudo; filtros: actor, item, journal, quest, quest_board, scene e compendium. O resultado contém title, kind, snippet e actions, não o documento completo.

javascript
async ({api,signal},v)=>{const kind=v.kind.trim();if(!['','actor','item','journal','quest','quest_board','scene','compendium'].includes(kind))throw new Error('Unknown search kind.');return api.table.search(v.query,{...(kind?{kind}:{}),signal});}

Resultado esperado: Um array com até 20 resultados autorizados.

Como conferir: Busque o mesmo título privado como mestre e jogador; ele deve estar ausente para o jogador. Teste quest separado de journal.

Revalidar um documento recente

Use o ID de um resultado já lido. Referências recentes usam type journal para missões e quadros; o kind da busca distingue as categorias. O array aceita até 12 referências únicas.

javascript
async ({api,signal},v)=>{if(!['actor','item','journal','scene','compendium'].includes(v.type))throw new Error('Invalid reference type.');return api.table.search('',{recent:[{id:v.id,type:v.type}],signal});}

Resultado esperado: Retorna apenas referências ainda legíveis para o membro atual.

Como conferir: Retire a leitura do jogador e repita a mesma referência recente. Ela deve desaparecer.

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.chat-dice",
  "name": "Chat, dice and lobby",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Create a working module that sends messages, publishes rolls, moderates chat, searches documents and updates readiness.",
  "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="Chat, dice and lobby";
      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-chat-dice";
        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:"history",
  label:"Read visible chat history",
  description:"Leave Scene UUID empty to use the broadcast scene, or enter an authorized scene UUID. The returned array is the visible recent history, not every message ever sent.",
  fields:[
  {
    "name": "sceneId",
    "label": "Scene UUID, optional",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,signal},v)=>api.chat.history(v.sceneId.trim()?{sceneId:v.sceneId.trim()}:{},{signal})
},
{
  id:"public-message",
  label:"Send one public message",
  description:"Enter original text. This recipe rejects a leading slash so the public action cannot accidentally become a command. Optional scene selection uses sceneId.",
  fields:[
  {
    "name": "text",
    "label": "Public message",
    "type": "text",
    "value": "The harbor gates are open.",
    "required": true
  },
  {
    "name": "sceneId",
    "label": "Scene UUID, optional",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,signal},v)=>{const text=v.text.trim();if(text.startsWith('/'))throw new Error('Use a dedicated command recipe.');return api.chat.send({text,...(v.sceneId.trim()?{sceneId:v.sceneId.trim()}:{})},{signal,requestId:crypto.randomUUID()});}
},
{
  id:"whisper",
  label:"Send a recipient-bound whisper",
  description:"Enter the exact display name of a member in this campaign, including spaces. Native /w parsing resolves members, not a client-supplied audience list. Identical display names can resolve multiple matching members.",
  fields:[
  {
    "name": "recipient",
    "label": "Exact member display name",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "text",
    "label": "Private message",
    "type": "text",
    "value": "Meet me at the lighthouse.",
    "required": true
  }
],
  run:async ({api,signal},v)=>api.chat.send({text:'/w '+v.recipient.trim()+' '+v.text.trim()},{signal,requestId:crypto.randomUUID()})
},
{
  id:"gm-message",
  label:"Send a message to the GMs",
  description:"The /gm native command addresses campaign GMs plus the sender. It is a chat command, not a visibility field on chat.send.",
  fields:[
  {
    "name": "text",
    "label": "Message to GMs",
    "type": "text",
    "value": "A hidden passage was found.",
    "required": true
  }
],
  run:async ({api,signal},v)=>api.chat.send({text:'/gm '+v.text.trim()},{signal,requestId:crypto.randomUUID()})
},
{
  id:"emote",
  label:"Publish an emote",
  description:"Enter an action. The native /me command marks the message as an emote and retains normal public delivery.",
  fields:[
  {
    "name": "text",
    "label": "Emote text",
    "type": "text",
    "value": "lights the harbor beacon.",
    "required": true
  }
],
  run:async ({api,signal},v)=>api.chat.send({text:'/me '+v.text.trim()},{signal,requestId:crypto.randomUUID()})
},
{
  id:"dice",
  label:"Roll and publish public or GM-only dice",
  description:"Enter an expression supported by the native parser, label and repeat count. GM-only result chooses visibility gm. This action persists and publishes a roll; it is not a local calculator.",
  fields:[
  {
    "name": "expression",
    "label": "Dice expression",
    "type": "text",
    "value": "2d6+1",
    "required": true
  },
  {
    "name": "label",
    "label": "Label, up to 48 characters",
    "type": "text",
    "value": "Harbor check",
    "required": true
  },
  {
    "name": "repeat",
    "label": "Repeat, integer 1 to 12",
    "type": "number",
    "value": 1,
    "required": true
  },
  {
    "name": "secret",
    "label": "GM-only result",
    "type": "checkbox",
    "value": false
  }
],
  run:async ({api,signal},v)=>{if(!Number.isInteger(v.repeat)||v.repeat<1||v.repeat>12)throw new Error('Repeat must be 1 to 12.');return api.dice.roll(v.expression,{label:v.label,repeat:v.repeat,visibility:v.secret?'gm':'public'},{signal,requestId:crypto.randomUUID()});}
},
{
  id:"remove-message",
  label:"Moderate one message",
  description:"As GM, copy the numeric ID from history. This removes that message content and attachments while preserving retry identity.",
  fields:[
  {
    "name": "messageId",
    "label": "Numeric message ID",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');if(!/^\d+$/.test(v.messageId))throw new Error('Use the numeric chat ID from history.');return api.chat.remove(v.messageId,{signal});}
},
{
  id:"clear-chat",
  label:"Clear the disposable campaign chat",
  description:"Use only a disposable campaign. Type CLEAR CHAT. This GM moderation operation is campaign-wide, including scenes, rather than just the history currently displayed.",
  fields:[
  {
    "name": "confirmation",
    "label": "Type CLEAR CHAT",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');if(v.confirmation!=='CLEAR CHAT')throw new Error('Type CLEAR CHAT to clear the whole campaign chat.');return api.chat.clear({signal});}
},
{
  id:"lobby",
  label:"Inspect the ready check and selectable actors",
  description:"The installation must have its native ready check enabled. Read members, summary and the actors this account may select.",
  fields:[],
  run:async ({api,signal})=>api.table.lobby({signal})
},
{
  id:"set-ready",
  label:"Update this member’s ready state",
  description:"Set the checkbox, a selectable actor UUID from the lobby result, and assets_state. Leave actor UUID blank to clear the selection. The API chooses the current member; no user ID can impersonate someone else.",
  fields:[
  {
    "name": "ready",
    "label": "I am ready",
    "type": "checkbox",
    "value": true
  },
  {
    "name": "actorId",
    "label": "Selected owned actor UUID, optional",
    "type": "text",
    "value": "",
    "required": false
  },
  {
    "name": "assets",
    "label": "Asset state: unknown/loading/ready/error",
    "type": "text",
    "value": "ready",
    "required": true
  }
],
  run:async ({api,signal},v)=>{if(!['unknown','loading','ready','error'].includes(v.assets))throw new Error('Choose a native assets_state.');if(v.actorId.trim()){const lobby=await api.table.lobby({signal});if(!lobby.actors.some(actor=>actor.id===v.actorId.trim()))throw new Error('Actor is not selectable.');}return api.table.updateLobby({is_ready:v.ready,assets_state:v.assets,selected_actor_id:v.actorId.trim()||null},{signal});}
},
{
  id:"search",
  label:"Search all seven document kinds",
  description:"Use at least two characters. Blank kind searches all; accepted filters are actor, item, journal, quest, quest_board, scene and compendium. A result is a search projection with title, kind, snippet and actions, not a full document.",
  fields:[
  {
    "name": "query",
    "label": "At least two search characters",
    "type": "text",
    "value": "Harbor",
    "required": true
  },
  {
    "name": "kind",
    "label": "Kind, optional",
    "type": "text",
    "value": "",
    "required": false
  }
],
  run:async ({api,signal},v)=>{const kind=v.kind.trim();if(!['','actor','item','journal','quest','quest_board','scene','compendium'].includes(kind))throw new Error('Unknown search kind.');return api.table.search(v.query,{...(kind?{kind}:{}),signal});}
},
{
  id:"recent",
  label:"Revalidate a recent document",
  description:"Use one previously read result ID. Recent references use type journal for quests and quest boards, while search kind distinguishes those categories. Up to 12 unique references can be supplied in an array.",
  fields:[
  {
    "name": "type",
    "label": "Reference type: actor/item/journal/scene/compendium",
    "type": "text",
    "value": "journal",
    "required": true
  },
  {
    "name": "id",
    "label": "Previously seen document UUID",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,signal},v)=>{if(!['actor','item','journal','scene','compendium'].includes(v.type))throw new Error('Invalid reference type.');return api.table.search('',{recent:[{id:v.id,type:v.type}],signal});}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.chat-dice"] .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-chat-dice { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-chat-dice label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-chat-dice input, .tutorial-workbench-chat-dice select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-chat-dice pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-chat-dice 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 ação usa API da montagem e signal. Submissões de alteração recebem UUID novo, exceto lobby/moderação cujos serviços nativos não usam recibos. Para tentar a mesma submissão de chat ou dados em sua integração, preserve UUID e payload idênticos; clicar Run novamente é outra ação lógica. Use duas contas para audiências privadas e acesso revogado. Em conflito, leia e revise o estado antes de novo comando. O servidor filtra membros, cenas, mensagens e documentos; desabilitar botões não autoriza operações.

Como usar a IA para estender este projeto

text
Estenda o projeto completo chat-dice no Gravewright 0.3.0 com [um recurso]. Leia modules/frontend.py, chat/services.py, dice/services.py e table/services.py. Preserve parser nativo de comandos, payloads snake_case reais do lobby, audiência, UUIDs, permissões e cancelamento. Mostre todos os arquivos e valide com duas contas, mensagens privadas, acesso revogado e recursos da instalação desabilitados.

Todos os tutoriais · API