Enviar e usar arquivos de módulos e campanhas
Usar File/Blob, imagens de ator, cartas, provedores nativos e downloads.

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.
asset-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 ../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.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 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.
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.
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.
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.
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.
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.
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
{
"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
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
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
.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
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 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.