Como criar ferramentas de itens e inventário
Crie, organize, duplique, compartilhe e edite itens originais com revisões e permissões nativas.

O projeto que você vai criar
Crie Expedition item tools. Descubra tipos registrados na campanha, crie Survey kit e reserva, organize pastas e conceda acesso. O core não oferece transação genérica de equipar/inventário do ator: estas receitas gerenciam documentos de item da campanha; relações e regras de inventário pertencem ao sistema. Todas as mutações públicas de itens aparecem abaixo.
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 items. 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.
items/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtUse campanha descartável com sistema que declare tipo de item, como gear do tutorial de sistema com compêndios. Criar/duplicar/permissões/pastas/excluir exige GM; proprietário de item pode alterar dados autorizados. Cole nos campos os UUIDs retornados pelas ações autorizadas.
2. Empacote, instale e ative
Partindo da pasta do projeto, execute no PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../items-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../items-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 Expedition item tools. Feche o menu e clique no botão Expedition item tools. 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
Inspecione tipos e pastas reais
Execute primeiro. Copie um ID de types; a criação usa gear por padrão, disponível nos sistemas dos tutoriais.
async ({api,signal}) => {
return {...await api.items.state({}, {signal}),readableItems:await api.items.list({}, {signal})};
}Resultado esperado: Itens autorizados, árvore de pastas, tipos registrados e isGM. Uma entrada só de listagem não tem corpo legível.
Como conferir: Compare GM e jogador com leitura; dados ocultos não devem aparecer.
Crie e duplique um kit de expedição
Informe um tipo registrado e execute uma vez. Copie folderId, itemId e duplicateId para as próximas receitas.
async ({api,signal},values) => {
const state = await api.items.state({}, {signal});
if (!state.types.some(type => type.id === values.type)) throw new Error("Choose an item type registered by this campaign system");
const folder = await api.items.createFolder({name:"Expedition supplies",color:"#6498cc",parentId:null}, {signal});
const created = await api.items.create({name:"Survey kit",type:values.type,folderId:folder.id,data:{quantity:1,description:"An original compass and a blank notebook."},permissions:{default:"none"}}, {signal});
const duplicate = await api.items.duplicate({id:created.id,version:created.item.version,name:"Spare survey kit"}, {signal});
return {folderId:folder.id,itemId:created.id,duplicateId:duplicate.id};
}Resultado esperado: Uma pasta e dois itens separados com JSON original pertencente ao sistema. Cada execução explícita cria outro conjunto.
Como conferir: Abra Itens, edite um kit e confira que a cópia permanece independente.
Edite e mova o kit
Use itemId, defina nome/quantidade e cole folderId; destino vazio move para a raiz.
async ({api,signal},values) => {
const current = await api.items.get(values.id, {}, {signal});
return api.items.update({id:current.id,version:current.version,name:values.name,folderId:values.folderId || null,data:{...current.data,quantity:values.quantity}}, {signal});
}Resultado esperado: O item muda atomicamente com a versão atual e preserva outros campos. Quantity é campo de exemplo do sistema, não regra de inventário do core.
Como conferir: Confira pasta e quantidade; um payload antigo em outra sessão deve receber conflict.
Conceda leitura ou edição conscientemente
Como GM, use o UUID do kit. Read concede leitura padrão; um UUID real de membro cria exceção. Owner permite edição.
async ({api,signal},values) => {
const current = await api.items.get(values.id, {}, {signal});
const permissions = {...current.permissions,[values.userId || "default"]:values.level};
return api.items.setPermissions({id:current.id,version:current.version,permissions}, {signal});
}Resultado esperado: Permissões com versão. Limited permite apenas listagem; none remove a concessão selecionada.
Como conferir: Teste read, limited e none com jogador. Leitura não autoriza edição; esconder botão não é a barreira de segurança.
Renomeie ou reorganize a pasta
Cole folderId. Pai vazio deixa a pasta na raiz; outro UUID da campanha muda seu pai.
async ({api,signal},values) => {
return api.items.updateFolder({id:values.id,name:values.name,parentId:values.parentId || null,color:"#6498cc"}, {signal});
}Resultado esperado: A pasta é renomeada/movida. O servidor rejeita ciclos e IDs de outra campanha. Este contrato de pasta não tem campo version.
Como conferir: Atualize Itens, confira a árvore e tente usar a própria pasta como pai.
Exclua o kit reserva
Use apenas duplicateId desta demonstração. A ação exclui esse item e exige GM.
async ({api,signal},values) => {
const current = await api.items.get(values.id, {}, {signal});
return api.items.delete({id:current.id,version:current.version}, {signal});
}Resultado esperado: A reserva desaparece e o kit original permanece.
Como conferir: Atualize o diretório e confira que o UUID original ainda abre.
Remova a pasta de demonstração
Mova os itens que deseja manter à raiz pela receita de edição antes de remover sua pasta de teste.
async ({api,signal},values) => {
return api.items.deleteFolder({id:values.id}, {signal});
}Resultado esperado: A pasta selecionada é removida pelo serviço nativo.
Como conferir: Atualize Itens e confira os recursos restantes em vez de confiar no estado antigo do cliente.
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.items",
"name": "Expedition item tools",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Create, organize, duplicate, share and edit original items with native revisions and permissions.",
"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="Expedition item tools";
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-items";
panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
for(const recipe of recipes) {
const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
}
const form=doc.createElement('form'),fields=doc.createElement('div');
const description=doc.createElement('p');
const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
const close=doc.createElement('button');close.type='button';close.textContent='Close';
const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
form.append(description,fields,run,close,output);panel.append(choose,form);
function renderFields(){
const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
description.textContent=recipe.description;
for(const field of recipe.fields||[]) {
const label=doc.createElement('label');label.textContent=field.label;
const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
if(field.type==='number')input.step=String(field.step??'any');
if(field.type==='checkbox')input.checked=!!field.value;
else if(field.type!=='file')input.value=String(field.value??'');
input.required=!!field.required;
if(field.accept)input.accept=field.accept;
label.append(input);fields.append(label);
}
output.textContent='';
}
choose.addEventListener('change',renderFields,{signal});renderFields();
form.addEventListener('submit',async event=>{
event.preventDefault();if(pending)return;
const recipe=recipes.find(item=>item.id===choose.value),values={};
for(const input of fields.querySelectorAll('input')) {
values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
}
pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
for(const input of fields.querySelectorAll('input'))input.disabled=true;
output.textContent='Running…';
try {
const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
} catch(error) {
if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
} finally {
pending=false;
if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
}
},{signal});
close.addEventListener('click',()=>void modal.close(),{signal});
modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
},{signal});
onDispose(()=>{button.remove();if(modal)void modal.close();});
});
},
stop() {}
};recipes.mjs
export const recipes=[
{
id:"inspect",
label:"Inspect real item types and folders",
description:"Choose this first and Run action. Copy a registered type ID from types; the creation recipe defaults to gear, supplied by the tutorial systems.",
fields:[],
run:async ({api,signal}) => {
return {...await api.items.state({}, {signal}),readableItems:await api.items.list({}, {signal})};
}
},
{
id:"create-kit",
label:"Create and duplicate an expedition kit",
description:"Enter a real registered item type, then Run action once. Copy folderId, itemId and duplicateId from the result for the next recipes.",
fields:[
{
"name": "type",
"label": "Registered item type",
"value": "gear",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const state = await api.items.state({}, {signal});
if (!state.types.some(type => type.id === values.type)) throw new Error("Choose an item type registered by this campaign system");
const folder = await api.items.createFolder({name:"Expedition supplies",color:"#6498cc",parentId:null}, {signal});
const created = await api.items.create({name:"Survey kit",type:values.type,folderId:folder.id,data:{quantity:1,description:"An original compass and a blank notebook."},permissions:{default:"none"}}, {signal});
const duplicate = await api.items.duplicate({id:created.id,version:created.item.version,name:"Spare survey kit"}, {signal});
return {folderId:folder.id,itemId:created.id,duplicateId:duplicate.id};
}
},
{
id:"update-kit",
label:"Edit and move the kit",
description:"Use itemId, set a name/quantity and paste the created folderId, or leave the destination blank to move to root.",
fields:[
{
"name": "id",
"label": "Item UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "name",
"label": "New name",
"value": "Prepared survey kit",
"type": "text",
"required": true
},
{
"name": "quantity",
"label": "Quantity",
"value": 2,
"type": "number",
"required": true
},
{
"name": "folderId",
"label": "Destination folder UUID, blank for root",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,signal},values) => {
const current = await api.items.get(values.id, {}, {signal});
return api.items.update({id:current.id,version:current.version,name:values.name,folderId:values.folderId || null,data:{...current.data,quantity:values.quantity}}, {signal});
}
},
{
id:"permissions",
label:"Grant reading or editing deliberately",
description:"As GM enter the kit UUID. Use read for the default grant or a real member UUID for a specific override; owner grants editing.",
fields:[
{
"name": "id",
"label": "Item UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "level",
"label": "Access: none, limited, read or owner",
"value": "read",
"type": "text",
"required": true
},
{
"name": "userId",
"label": "Member UUID, blank for default",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,signal},values) => {
const current = await api.items.get(values.id, {}, {signal});
const permissions = {...current.permissions,[values.userId || "default"]:values.level};
return api.items.setPermissions({id:current.id,version:current.version,permissions}, {signal});
}
},
{
id:"folder",
label:"Rename or reparent the folder",
description:"Paste folderId. A blank parent keeps this folder at root; another campaign folder UUID reparents it.",
fields:[
{
"name": "id",
"label": "Folder UUID",
"value": "",
"type": "text",
"required": true
},
{
"name": "name",
"label": "Folder name",
"value": "Prepared supplies",
"type": "text",
"required": true
},
{
"name": "parentId",
"label": "Parent folder UUID, blank for root",
"value": "",
"type": "text",
"required": false
}
],
run:async ({api,signal},values) => {
return api.items.updateFolder({id:values.id,name:values.name,parentId:values.parentId || null,color:"#6498cc"}, {signal});
}
},
{
id:"delete-item",
label:"Delete the spare kit",
description:"Use only duplicateId from this demonstration. This action deletes that item and requires GM.",
fields:[
{
"name": "id",
"label": "Disposable item UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
const current = await api.items.get(values.id, {}, {signal});
return api.items.delete({id:current.id,version:current.version}, {signal});
}
},
{
id:"delete-folder",
label:"Remove the demonstration folder",
description:"Move items you want to retain to root with the update recipe before removing your test folder.",
fields:[
{
"name": "id",
"label": "Disposable folder UUID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({api,signal},values) => {
return api.items.deleteFolder({id:values.id}, {signal});
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.items"] .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-items { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-items label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-items input, .tutorial-workbench-items select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-items pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-items 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
Crie uma vez, edite o original, compare a cópia, reorganize pastas e teste read/limited/none com jogador. Repetir criação produz outro conjunto de propósito. O SDK gera UUID novo por ação; não repita cegamente uma criação em várias etapas após resposta perdida, pois etapas anteriores podem ter sido confirmadas. Inspecione estado e retome conscientemente. Conflict exige ler/revisar estado; o código não troca revisão em uma requisição rejeitada. Item só de listagem não abre. Teste UUID de outra campanha, cancelamento e lista vazia de tipos. Exclua só os recursos de teste ao final.
Como usar a IA para estender este projeto
Amplie tutorial-items para [campos do sistema]. Confira items/services.py e frontend.json da release alvo. Preserve tipos registrados, dados, version, permissões e contenção de pastas. Não invente endpoints de inventário/equipar/transferir de ator. Entregue arquivos completos e testes GM/jogador com edição antiga e ações repetidas.