InícioDocs para devsEnviar e usar arquivos de módulos e campanhas
Tutoriais de implementação 9 min de leitura

Enviar e usar arquivos de módulos e campanhas

Usar File/Blob, imagens de ator, cartas, provedores nativos e downloads.

O módulo de arquivos enviou um retrato original pelo SDK multipart real. Gravewright 0.3.0, interface em inglês.
O módulo de arquivos enviou um retrato original pelo SDK multipart real. Gravewright 0.3.0, interface em inglês. · Abrir captura completa ↗

O projeto que você vai criar

Crie módulo que trabalha com arquivos reais. Formulários enviam objetos File ao SDK multipart público. Cada receita explica a biblioteca nativa que possui o ID retornado. Arquivos do pacote ficam no tutorial seguinte de tradução/eventos.

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

2. Empacote, instale e ative

Partindo da pasta do projeto, execute no PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../asset-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../asset-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 Upload and use module and campaign files. Feche o menu e clique no botão Upload and use module and campaign files. 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

Listar arquivos reutilizáveis da campanha

Use campanha com arquivo nativo suportado, como PDF original no sistema PDF. A operação lista arquivos de modelo de ator sem ator associado, não todo mapa/áudio.

javascript
async ({host,signal}) => host.call('asset.list',{}, {signal})

Resultado esperado: items com id, name, contentType e createdAt.

Como conferir: Compare com a biblioteca nativa de modelos/arquivos; array vazio é válido.

Enviar arquivo real suportado por um provedor

Escolha PDF pequeno e válido de sua autoria em campanha PDF. asset.upload seleciona provedor e validador registrados; não é armazenamento de binário arbitrário.

javascript
async ({host,signal},v) => {
  if(!(v.file instanceof File)) throw new Error('Choose a file');
  return host.call('asset.upload',{file:v.file},{signal});
}

Resultado esperado: DTO com ID do arquivo; conteúdo sem suporte falha com invalid_data.

Como conferir: Liste novamente e confira o nome. Teste arquivo de texto e confira a recusa.

Ler arquivo baixável como Blob

Cole ID retornado em Listar. O retorno contém Blob, não URL. Uma interface pode criar URL de objeto para download e deve revogá-la ao terminar.

javascript
async ({host,signal},v) => {
  const result=await host.call('asset.download',{id:v.id},{signal});
  return {name:result.name,contentType:result.contentType,size:result.size,blobBytes:result.blob.size};
}

Resultado esperado: Metadados e size/blobBytes iguais, sem expor conteúdo do arquivo na página.

Como conferir: Compare o tamanho com o original. ID de outra campanha não deve ser legível.

Enviar retrato ou arte de token

Use portrait e depois token com ator descartável editável. Multipart exige File real. Host valida limites e faz conversão.

javascript
async ({host,signal},v) => {
  if(!['portrait','token'].includes(v.kind))throw new Error('kind must be portrait or token');
  if(!(v.file instanceof File))throw new Error('Choose an image');
  return host.call('actor.image.upload',{id:v.id,kind:v.kind,file:v.file},{signal});
}

Resultado esperado: URL da imagem salva. actor.version muda; releia antes de gravar novamente.

Como conferir: Reabra ator e confira ambas imagens. Jogador só de leitura e imagem inválida devem falhar.

Enviar frente/verso para um baralho

Como GM, escolha imagem original e finalidade. Use assetId retornado no tutorial de definição de cartas, não URL externa.

javascript
async ({host,signal},v) => {
  if(!['card_front','card_back'].includes(v.purpose))throw new Error('Choose card_front or card_back');
  return host.call('card.asset.upload',{purpose:v.purpose,file:v.file},{signal});
}

Resultado esperado: assetId da biblioteca nativa de imagens de carta.

Como conferir: Defina baralho com front_asset_id/back_asset_id usando esse ID e confira em Cards.

Apagar apenas arquivo reutilizável de teste

Selecione só o arquivo reutilizável sem ator que você enviou. Remoção nativa verifica provedor e referências. Não exclui caminhos arbitrários de mídia.

javascript
async ({api,signal},v) => {
  if(!v.confirm)throw new Error('Confirm the disposable asset');
  await api.actors.deleteAsset({id:v.id},{requestId:crypto.randomUUID(),signal});
  return {deleted:v.id};
}

Resultado esperado: Arquivo removido pelo domínio nativo de atores.

Como conferir: Liste novamente; deve estar ausente. IDs de imagem pertencente ao ator não são aceitos aqui.

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.asset-workshop",
  "name": "Upload and use module and campaign files",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Use File/Blob, actor images, card assets, native file providers and downloads.",
  "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="Upload and use module and campaign files";
      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-asset-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:"list",
  label:"List reusable campaign assets",
  description:"Use a test campaign with a native-supported asset, such as an original PDF in the PDF system. This operation lists unattached actor-template assets, not every map/audio file.",
  fields:[],
  run:async ({host,signal}) => host.call('asset.list',{}, {signal})
},
{
  id:"upload",
  label:"Upload a real provider-supported file",
  description:"Choose your own small valid PDF on a PDF campaign. asset.upload selects a registered content provider and its validator; it is not an arbitrary binary file store.",
  fields:[
  {
    "name": "file",
    "label": "Original supported file",
    "value": "",
    "type": "file",
    "required": true
  }
],
  run:async ({host,signal},v) => {
  if(!(v.file instanceof File)) throw new Error('Choose a file');
  return host.call('asset.upload',{file:v.file},{signal});
}
},
{
  id:"download",
  label:"Read a downloadable file as a Blob",
  description:"Paste an asset ID from List. The result contains a Blob; it is not a URL string. A UI may create an object URL for download and must revoke it afterward.",
  fields:[
  {
    "name": "id",
    "label": "Reusable asset ID",
    "value": "",
    "type": "text",
    "required": true
  }
],
  run:async ({host,signal},v) => {
  const result=await host.call('asset.download',{id:v.id},{signal});
  return {name:result.name,contentType:result.contentType,size:result.size,blobBytes:result.blob.size};
}
},
{
  id:"actor-image",
  label:"Upload portrait or token artwork",
  description:"Use portrait, then token, with an editable disposable actor. Multipart requires a real File. Limits and conversion are enforced by the host.",
  fields:[
  {
    "name": "id",
    "label": "Actor ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "kind",
    "label": "Image kind",
    "value": "portrait",
    "type": "text",
    "required": true
  },
  {
    "name": "file",
    "label": "Original PNG/JPEG/WebP",
    "value": "",
    "type": "file",
    "required": true
  }
],
  run:async ({host,signal},v) => {
  if(!['portrait','token'].includes(v.kind))throw new Error('kind must be portrait or token');
  if(!(v.file instanceof File))throw new Error('Choose an image');
  return host.call('actor.image.upload',{id:v.id,kind:v.kind,file:v.file},{signal});
}
},
{
  id:"card-image",
  label:"Upload front/back artwork for a deck",
  description:"As GM, choose an original image and purpose. Paste the returned assetId into the card-definition tutorial, not an external URL.",
  fields:[
  {
    "name": "purpose",
    "label": "Purpose",
    "value": "card_front",
    "type": "text",
    "required": true
  },
  {
    "name": "file",
    "label": "Original PNG/JPEG/WebP",
    "value": "",
    "type": "file",
    "required": true
  }
],
  run:async ({host,signal},v) => {
  if(!['card_front','card_back'].includes(v.purpose))throw new Error('Choose card_front or card_back');
  return host.call('card.asset.upload',{purpose:v.purpose,file:v.file},{signal});
}
},
{
  id:"delete",
  label:"Delete only a reusable tutorial asset",
  description:"Select only the unattached reusable asset you uploaded. Native deletion checks provider support and updates references. This does not delete arbitrary media paths.",
  fields:[
  {
    "name": "id",
    "label": "Reusable asset ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "This is my disposable asset",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,signal},v) => {
  if(!v.confirm)throw new Error('Confirm the disposable asset');
  await api.actors.deleteAsset({id:v.id},{requestId:crypto.randomUUID(),signal});
  return {deleted:v.id};
}
}
];

styles.css

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

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

Como usar a IA para estender este projeto

text
Adapte o módulo completo asset-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.

Todos os tutoriais · API