Como criar um módulo de ferramentas de token
Coloque, mova, duplique, configure e remova tokens; controle visão, ocultação e condições com duração.

O projeto que você vai criar
Crie Token workshop em um módulo comum. Coloque dois tokens descartáveis na cena anterior; teste movimento, duplicação snapshot, rotação/elevação, visão, ocultação e condição original. Mantenha tokens para o tutorial de combate antes da remoção.
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 tokens-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.
tokens-workshop/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtConclua Scene workshop e guarde ID da cena. Crie ator descartável pelo diretório nativo Actors com o sistema escolhido, ou siga tutorial de atores. Copie IDs reais em Inspect. O módulo chama api.tokens pública. GridX/gridY são células de posicionamento; paredes nativas usam pixels da cena.
2. Empacote, instale e ative
Partindo da pasta do projeto, execute no PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../tokens-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../tokens-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 Token workshop. Feche o menu e clique no botão Token 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 atores disponíveis e tokens da cena
Cole o Scene ID do tutorial de mapas. Copie ID de ator legível para Place e depois ID de token para as demais ações.
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 {
return {actors:await api.actors.list({},read),tokens:await api.tokens.list({mapId:v.mapId},read)};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: Metadados autorizados e IDs, versões, posições, visão e condições visíveis dos tokens.
Como conferir: Compare GM e jogador: tokens ocultos e condições privadas são filtrados antes da entrega.
Colocar token de ator na cena de teste
Crie antes um ator descartável original pela ficha nativa/do sistema. GridX/gridY usam células, não pixels da cena. O host valida limites e tamanho.
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.actors.get(v.actorId,{},read);
const created=await api.tokens.place({mapId:v.mapId,actorId:v.actorId,gridX:v.x,gridY:v.y},write());
return {createdIds:created.createdIds,tokens:created.tokens};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: ID criado e projeção atual. O host decide o modo vinculado/snapshot.
Como conferir: Encontre em grid2,2. O primeiro token pode ser vinculado; outra colocação do mesmo ator pode converter vínculos existentes em snapshots independentes pelo serviço nativo.
Mover token controlado e destrancado
Informe token e destino próximo. Path:[] solicita segmento direto; pontos intermediários usam as mesmas células. Movimento de jogador verifica paredes e controle.
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 token=await api.tokens.get(v.id,{mapId:v.mapId},read);
if(!token.canControl||token.locked) throw new Error('Choose a controlled unlocked token');
return await api.tokens.move({mapId:v.mapId,id:token.id,expectedVersion:token.version,gridX:v.x,gridY:v.y,path:[]},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: Mesmo token na posição aceita, com versão incrementada.
Como conferir: Teste como jogador controlador: caminho livre funciona, parede bloqueia sem alterar posição e expectedVersion antigo causa conflito.
Duplicar um token do tutorial
Use o token colocado acima. O serviço nativo cria snapshot independente e escolhe deslocamento dentro do mapa.
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.tokens.get(v.id,{mapId:v.mapId},read);
const result=await api.tokens.duplicate({mapId:v.mapId,tokenIds:[v.id]},write());
return {createdIds:result.createdIds,tokens:result.tokens};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: Outro ID, normalmente deslocado uma célula, com linkMode snapshot.
Como conferir: Confira ambos IDs e abra suas fichas. Copie ID da cópia para limpeza ou combate.
Configurar rotação, elevação e disposição
Configure um token descartável. Mantenha locked false para mover. Elevação afeta elegibilidade em regiões. Jogador pode girar token controlado só com values contendo rotation; esta receita com vários campos é de GM.
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 token=await api.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.configure({mapId:v.mapId,tokenIds:[token.id],expectedVersion:token.version,values:{rotation:v.rotation,elevation:v.elevation,disposition:v.disposition,locked:v.locked}},write());
return await api.tokens.get(token.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: Rotação, elevação, disposição e trava solicitadas, preservando ator/ficha.
Como conferir: Confira o editor nativo e confirme que locked impede mover até ser retirado.
Habilitar e configurar visão do token
Use o token colocado; range é distância não negativa em células. Para ver linha de visão, configure iluminação dinâmica na cena e use jogador controlador.
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.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.setVision({mapId:v.mapId,tokenIds:[v.id],enabled:v.enabled,range:v.range},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: VisionEnabled e visionRange correspondem ao formulário.
Como conferir: Alterne enabled false/true e confira a visão do jogador na configuração nativa de iluminação. A visão de GM sozinha não comprova visão de jogador.
Ocultar e mostrar token de teste
Execute com Hidden marcado, confira a conta de jogador, depois desmarque e repita. Isso altera visibilidade do token, não permissões do ator.
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.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.setHidden({mapId:v.mapId,tokenIds:[v.id],hidden:v.hidden},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: GM mantém o token; jogadores só o recebem quando não oculto e autorizado.
Como conferir: Compare Inspect em duas contas. Presença oculta continua seguindo os filtros do servidor.
Adicionar condição original com duração
Use nome original. É marcador genérico, sem regra de livro. Copie condition_id para Remove condition. Duração avança por eventos reais de combate para token participante.
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 {
await api.tokens.get(v.id,{mapId:v.mapId},read);
if(!['everyone','gm'].includes(v.visibility)) throw new Error('Use everyone or gm');
if(!Number.isInteger(v.rounds)||v.rounds<1) throw new Error('Use a positive integer duration');
if(v.visibility==='gm'&&context.role!=='gm') throw new Error('GM required for hidden conditions');
await api.tokens.addCondition({mapId:v.mapId,tokenIds:[v.id],label:v.label,description:'Original tutorial marker with no RPG rules.',kind:'positive',visibility:v.visibility,duration:{type:'rounds',value:v.rounds,expires:'end'}},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: Condição com condition_id único e duração rounds/end explícita.
Como conferir: Como jogador, acrescente apenas em token controlado com visibility everyone. Condição GM privada deve faltar na projeção de jogador.
Remover a condição escolhida
Cole condition_id exato, não o rótulo. Remova somente o marcador criado no tutorial.
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 token=await api.tokens.get(v.id,{mapId:v.mapId},read);
if(!token.conditions.some(item=>item.condition_id===v.conditionId)) throw new Error('Choose a visible condition from this token');
await api.tokens.removeCondition({mapId:v.mapId,tokenIds:[v.id],conditionId:v.conditionId},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: A condição escolhida desaparece e as demais permanecem.
Como conferir: Compare antes/depois. Jogador não pode descobrir/remover condição privada adivinhando ID.
Remover token descartável
Use apenas token/cópia do tutorial, depois de limpar o combate. Remover token não apaga seu ator.
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 token');
await api.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.remove({mapId:v.mapId,tokenIds:[v.id]},write());
return await api.tokens.list({mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}Resultado esperado: Token ausente; ator permanece em Actors.
Como conferir: Confira cena, diretório e preservação dos outros IDs de token.
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.tokens-workshop",
"name": "Token workshop",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Place, move, duplicate, configure and remove tokens; control native vision/visibility and timed conditions.",
"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="Token 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-tokens-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:"inspect",
label:"Inspect available actors and scene tokens",
description:"Paste the test Scene ID from the maps tutorial. Copy a readable actor ID for Place, then a token ID for later actions.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
}
],
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 {
return {actors:await api.actors.list({},read),tokens:await api.tokens.list({mapId:v.mapId},read)};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"place",
label:"Place an actor token in the test scene",
description:"Create an original disposable actor using the native/system sheet first. Coordinates use cell-space gridX/gridY for token placement, not scene pixels. The host validates bounds and token size.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "actorId",
"label": "Actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "x",
"label": "Grid x",
"value": 2,
"type": "number",
"required": true
},
{
"name": "y",
"label": "Grid y",
"value": 2,
"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.actors.get(v.actorId,{},read);
const created=await api.tokens.place({mapId:v.mapId,actorId:v.actorId,gridX:v.x,gridY:v.y},write());
return {createdIds:created.createdIds,tokens:created.tokens};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"move",
label:"Move a controlled unlocked token",
description:"Enter the token ID and a nearby destination. path:[] requests the direct segment; intermediate waypoints must use the same grid coordinates. Player movement still checks walls and ownership.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "x",
"label": "Destination grid x",
"value": 3,
"type": "number",
"required": true
},
{
"name": "y",
"label": "Destination grid y",
"value": 2,
"type": "number",
"required": true
}
],
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 token=await api.tokens.get(v.id,{mapId:v.mapId},read);
if(!token.canControl||token.locked) throw new Error('Choose a controlled unlocked token');
return await api.tokens.move({mapId:v.mapId,id:token.id,expectedVersion:token.version,gridX:v.x,gridY:v.y,path:[]},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"duplicate",
label:"Duplicate one tutorial token",
description:"Use the token placed above. The native service creates an independent snapshot and chooses its bounded offset.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"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 {
await api.tokens.get(v.id,{mapId:v.mapId},read);
const result=await api.tokens.duplicate({mapId:v.mapId,tokenIds:[v.id]},write());
return {createdIds:result.createdIds,tokens:result.tokens};
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"configure",
label:"Configure rotation, elevation and disposition",
description:"Configure a single disposable token. Keep locked false for the movement recipe. Elevation can affect region eligibility. A player may rotate a controlled token only with the rotation-only values variant; this multi-field recipe is GM-only.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "rotation",
"label": "Rotation degrees",
"value": 90,
"type": "number",
"required": true
},
{
"name": "elevation",
"label": "Elevation",
"value": 0,
"type": "number",
"required": true
},
{
"name": "disposition",
"label": "friendly, neutral, hostile or unknown",
"value": "friendly",
"type": "text",
"required": true
},
{
"name": "locked",
"label": "Lock token movement",
"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 {
const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.configure({mapId:v.mapId,tokenIds:[token.id],expectedVersion:token.version,values:{rotation:v.rotation,elevation:v.elevation,disposition:v.disposition,locked:v.locked}},write());
return await api.tokens.get(token.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"vision",
label:"Enable and configure token vision",
description:"Use the placed token; range is a nonnegative cell distance. To observe line-of-sight, configure dynamic lighting in the native scene and use the token controlling player.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "enabled",
"label": "Enable vision",
"value": true,
"type": "checkbox",
"required": false
},
{
"name": "range",
"label": "Vision range in cells",
"value": 6,
"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.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.setVision({mapId:v.mapId,tokenIds:[v.id],enabled:v.enabled,range:v.range},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"hidden",
label:"Hide and show the tutorial token",
description:"Run with Hidden selected, inspect the player account, then clear it and run again. This changes token visibility, not the actor permission map.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "hidden",
"label": "Hide from players",
"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 {
await api.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.setHidden({mapId:v.mapId,tokenIds:[v.id],hidden:v.hidden},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"condition-add",
label:"Add an original timed token condition",
description:"Use an original label. This is a generic marker, not a bundled rule. Copy condition_id from the result for Remove condition. Duration advances through real forward combat events when the token participates.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "label",
"label": "Condition label",
"value": "Workshop focus",
"type": "text",
"required": true
},
{
"name": "visibility",
"label": "everyone or gm",
"value": "everyone",
"type": "text",
"required": true
},
{
"name": "rounds",
"label": "Duration in rounds",
"value": 2,
"type": "number",
"required": true
}
],
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 {
await api.tokens.get(v.id,{mapId:v.mapId},read);
if(!['everyone','gm'].includes(v.visibility)) throw new Error('Use everyone or gm');
if(!Number.isInteger(v.rounds)||v.rounds<1) throw new Error('Use a positive integer duration');
if(v.visibility==='gm'&&context.role!=='gm') throw new Error('GM required for hidden conditions');
await api.tokens.addCondition({mapId:v.mapId,tokenIds:[v.id],label:v.label,description:'Original tutorial marker with no RPG rules.',kind:'positive',visibility:v.visibility,duration:{type:'rounds',value:v.rounds,expires:'end'}},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"condition-remove",
label:"Remove the chosen token condition",
description:"Paste the exact condition_id, not its display label. Remove only the marker created in this tutorial.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "conditionId",
"label": "condition_id from Inspect",
"value": "",
"type": "text",
"required": true
}
],
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 token=await api.tokens.get(v.id,{mapId:v.mapId},read);
if(!token.conditions.some(item=>item.condition_id===v.conditionId)) throw new Error('Choose a visible condition from this token');
await api.tokens.removeCondition({mapId:v.mapId,tokenIds:[v.id],conditionId:v.conditionId},write());
return await api.tokens.get(v.id,{mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
},
{
id:"remove",
label:"Remove the disposable token",
description:"Use only the tutorial token/duplicate. Run after combat cleanup. Removing a token does not delete its actor.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Token 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 token');
await api.tokens.get(v.id,{mapId:v.mapId},read);
await api.tokens.remove({mapId:v.mapId,tokenIds:[v.id]},write());
return await api.tokens.list({mapId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
throw problem;
}
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.tokens-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-tokens-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-tokens-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-tokens-workshop input, .tutorial-workbench-tokens-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-tokens-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-tokens-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
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.
Use GM, jogador controlador e não controlador. Teste token trancado, movimento bloqueado, filtragem de token/condição privada, expectedVersion antigo e token de outra cena. Avanço de duração pertence ao combate real, sem timer local. Após reconexão, consulte o estado em vez de repetir mutações como efeitos de notificações.
Operações públicas cobertas por receita
| API | Receita no painel |
|---|---|
| api.tokens.place | place |
| api.tokens.move | move |
| api.tokens.remove | remove |
| api.tokens.duplicate | duplicate |
| api.tokens.setHidden | hidden |
| api.tokens.setVision | vision |
| api.tokens.configure | configure |
| api.tokens.addCondition | condition-add |
| api.tokens.removeCondition | condition-remove |
Como usar a IA para estender este projeto
Estenda Token workshop no Gravewright0.3.0 para [tarefa]. Confira tokens/services.py e frontend.json. Separe células e pixels, use expectedVersion real para move/configure, preserve modos vinculado/snapshot e filtros de controle/trava/ocultação. Entregue código completo e testes concretos GM/jogador, parede, versão antiga e abort.