Como criar um módulo de ferramentas de combate
Crie laboratório de encontro com participantes, iniciativa, ordem, estados, turnos, rodadas e limpeza.

O projeto que você vai criar
Crie Combat workshop e opere o encontro nativo de uma cena descartável. Adicione dois tokens, defina/role iniciativa, inicie, selecione/reordene, avance turnos/rodadas e finalize removendo participantes. O sistema de RPG escolhido continua responsável por suas regras e provedor.
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 combat-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.
combat-workshop/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtMantenha cena e dois tokens do Token workshop. Execute Inspect para IDs reais de cena/combatente. Use tokenId para token colocado ou actorId para participante só de ator. O módulo não fornece sistema, catálogo nem complemento Server Rules. A fórmula explícita de teste é ajuste do encontro na campanha descartável.
2. Empacote, instale e ative
Partindo da pasta do projeto, execute no PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../combat-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../combat-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 Combat workshop. Feche o menu e clique no botão Combat 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 encontro desta cena
Cole ID da cena descartável. Ainda pode não existir encontro. Após adicionar participantes, copie IDs da lista projetada.
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 await api.combat.state({sceneId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Versão, active/round/turn, configuração e combatentes autorizados.
Como conferir: Compare com Combat nativo. Jogador não deve receber entradas ocultas ou recursos não autorizados.
Adicionar token ou ator ao combate
Use tokenId com os dois IDs do tutorial de tokens, uma execução por token. Ou actorId para ator sem token na cena. Cada Run action é uma operação.
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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.add({sceneId:v.mapId,version:before.version,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Combatente adicionado sem duplicar identidade existente; versão avança.
Como conferir: Adicione dois combatentes distintos e confira no rastreador. Token de outra cena deve ser recusado.
Definir fórmula explícita de iniciativa
Só no encontro descartável, defina expressão genérica1d20. O sistema selecionado é dono da fórmula/provedor padrão; este ajuste nativo explícito não implementa regras de RPG. Limpe o campo para modo manual.
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 before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.configure({sceneId:v.mapId,version:before.version,formula:v.formula.trim()},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Config.formula reflete a expressão; config.input informa a entrada conforme sistema.
Como conferir: Abra ajustes de Combat e confira. Fórmula inválida é rejeitada sem gravação parcial.
Definir iniciativa de um combatente
Use identidade participante. A receita respeita config atual: texto continua texto e entrada numérica/rolada vira número finito. Não fixe atributo de um sistema.
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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
const value=before.config?.input==='text'?v.value:Number(v.value);
if(typeof value==='number'&&!Number.isFinite(value)) throw new Error('Enter a finite initiative number');
return await api.combat.setInitiative({sceneId:v.mapId,version:before.version,...identity,value},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Iniciativa selecionada alterada; números seguem a ordenação nativa.
Como conferir: Defina12 e8 para dois participantes e confira. Sistemas em modo texto podem manter ordem manual.
Rolar iniciativa para um escopo
Configure fórmula válida antes. Missing rola só valores ausentes; all refaz todos; npcs usa regras nativas de posse; one usa a identidade participante informada.
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(!['all','missing','npcs','one'].includes(v.scope)) throw new Error('Choose an initiative scope');
const before=await api.combat.state({sceneId:v.mapId},read);
if(!before.config?.formula) throw new Error('Configure an initiative formula first');
const identity={};
if(v.scope==='one'){
if(!v.id||!['tokenId','actorId'].includes(v.identityType)) throw new Error('Supply the selected combatant identity');
identity[v.identityType]=v.id;
}
return await api.combat.rollInitiative({sceneId:v.mapId,version:before.version,scope:v.scope,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Só o escopo escolhido recebe rolagens de servidor; o rastreador ordena resultados.
Como conferir: Defina um valor manual, execute missing e confirme preservação. Não presuma resultado fixo de rolagem.
Mover participante para cima ou baixo
Use combatente e execute up, depois down. Reordenar preserva identidade do combatente ativo, sem passar o turno silenciosamente.
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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
const payload={sceneId:v.mapId,version:before.version,...identity};
if(v.direction==='up') return await api.combat.moveUp(payload,write());
if(v.direction==='down') return await api.combat.moveDown(payload,write());
throw new Error('Choose up or down');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Participante muda de posição e identidade ativa é preservada.
Como conferir: Inicie combate, anote ID atual e reordene outra entrada. O participante atual deve continuar o mesmo.
Definir oculto e derrotado explicitamente
As opções definem booleanos exatos, sem inverter estado desconhecido. Entradas derrotadas são puladas no avanço. Desmarque ambas para restaurar o participante.
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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.toggle({sceneId:v.mapId,version:before.version,...identity,hidden:v.hidden,defeated:v.defeated},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Entrada com as opções escolhidas. Entrada de combate oculta ausente para jogadores.
Como conferir: Compare rastreador GM/jogador e retire defeated antes de testar o ciclo completo.
Iniciar ou parar encontro descartável
Adicione ao menos dois participantes. Start cria identidade de encontro e começa round1/turn0. Stop limpa cursor/histórico ativos, mantendo participantes. Use só o encontro de teste.
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 before=await api.combat.state({sceneId:v.mapId},read);
const payload={sceneId:v.mapId,version:before.version};
if(v.action==='start') return await api.combat.start(payload,write());
if(v.action==='stop') return await api.combat.stop(payload,write());
throw new Error('Choose start or stop');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Start: active true, round1. Stop: active false, round0.
Como conferir: Confira encounterId e rastreador após cada ação intencional.
Selecionar participante atual específico
Inicie primeiro e use identidade participante. Isso seleciona posição sem fingir que turnos pulados executaram efeitos normais.
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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.setTurn({sceneId:v.mapId,version:before.version,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Participante escolhido aparece como atual.
Como conferir: Compare current_name e is_current com o ID escolhido.
Avançar ou retornar um turno
Inicie antes. Next avança relógios nativos e pula derrotados. Jogador autorizado pode encerrar seu turno atual; previous é de GM e exige histórico.
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 before=await api.combat.state({sceneId:v.mapId},read);
if(!before.active) throw new Error('Start combat first');
const payload={sceneId:v.mapId,version:before.version};
if(v.direction==='next') return await api.combat.nextTurn(payload,write());
if(v.direction==='previous'){
if(context.role!=='gm') throw new Error('GM required for previous turn');
return await api.combat.previousTurn(payload,write());
}
throw new Error('Choose next or previous');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Identidade atual muda conforme ordem nativa; previous retorna o cursor salvo quando existe.
Como conferir: Tente next como controlador atual e outro jogador. Só o primeiro pode. Retornar não ressuscita condições expiradas nem desfaz efeitos periódicos.
Avançar ou retornar uma rodada
Use next para avançar rodada e previous para conferir o cursor nativo. Avanço pode consumir duração de condições/efeitos. Previous navega, sem desfazer efeitos persistidos.
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 before=await api.combat.state({sceneId:v.mapId},read);
if(!before.active) throw new Error('Start combat first');
const payload={sceneId:v.mapId,version:before.version};
if(v.direction==='next') return await api.combat.nextRound(payload,write());
if(v.direction==='previous') return await api.combat.previousRound(payload,write());
throw new Error('Choose next or previous');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Rodada muda dentro dos limites nativos; turno vai à primeira entrada.
Como conferir: Confira round/turn e condição do tutorial de tokens. Não espere restauração da duração consumida ao voltar.
Remover só participante de combate do tutorial
Pare o encontro de teste e remova participantes um por vez. Isso remove participação no rastreador, sem apagar token/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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
if(!v.confirm) throw new Error('Confirm the tutorial participant');
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.remove({sceneId:v.mapId,version:before.version,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}Resultado esperado: Identidade sai do combate; cursor/histórico continuam consistentes.
Como conferir: Confirme que token permanece e, se desejar, remova-o pela receita separada de tokens.
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.combat-workshop",
"name": "Combat workshop",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Build a working encounter laboratory covering participants, initiative, order, flags, turns, rounds and cleanup.",
"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="Combat 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-combat-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 this scene encounter",
description:"Paste the disposable scene ID. It need not contain an encounter yet. Copy combatant IDs from the projected list after adding participants.",
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 await api.combat.state({sceneId:v.mapId},read);
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"add",
label:"Add a placed token or an actor to combat",
description:"Use tokenId with the two token IDs from the token tutorial, one run per token. Or use actorId for an actor without a scene token. Each Run action is one operation.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "Identity field: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Combatant token/actor 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 {
if(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.add({sceneId:v.mapId,version:before.version,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"configure",
label:"Set an explicit encounter initiative formula",
description:"On the disposable encounter only, set the generic dice expression1d20. The selected system owns its default formula/provider; this explicit native encounter override does not implement new RPG rules. Clear the input to use manual mode.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "formula",
"label": "Formula, blank to restore manual input",
"value": "1d20",
"type": "text",
"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 before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.configure({sceneId:v.mapId,version:before.version,formula:v.formula.trim()},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"initiative",
label:"Set one combatant initiative value",
description:"Use a participating identity. The recipe follows the current config: text input stays text; numeric/rolled input becomes a finite number. Do not hardcode a system attribute.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "Identity field: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Combatant token/actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "value",
"label": "Initiative value",
"value": "12",
"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 {
if(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
const value=before.config?.input==='text'?v.value:Number(v.value);
if(typeof value==='number'&&!Number.isFinite(value)) throw new Error('Enter a finite initiative number');
return await api.combat.setInitiative({sceneId:v.mapId,version:before.version,...identity,value},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"roll",
label:"Roll initiative for a chosen scope",
description:"Configure an explicit valid formula first. Missing rolls only unset entries; all rerolls all; npcs uses native player-ownership rules; one uses the supplied participating identity.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "scope",
"label": "all, missing, npcs or one",
"value": "missing",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "For one: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "For one: combatant ID",
"value": "",
"type": "text",
"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(!['all','missing','npcs','one'].includes(v.scope)) throw new Error('Choose an initiative scope');
const before=await api.combat.state({sceneId:v.mapId},read);
if(!before.config?.formula) throw new Error('Configure an initiative formula first');
const identity={};
if(v.scope==='one'){
if(!v.id||!['tokenId','actorId'].includes(v.identityType)) throw new Error('Supply the selected combatant identity');
identity[v.identityType]=v.id;
}
return await api.combat.rollInitiative({sceneId:v.mapId,version:before.version,scope:v.scope,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"order",
label:"Move a participant up or down",
description:"Use a combatant and run up, then down. Manual reordering preserves the active combatant identity rather than silently granting the turn to another entry.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "Identity field: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Combatant token/actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "direction",
"label": "up or down",
"value": "up",
"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 {
if(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
const payload={sceneId:v.mapId,version:before.version,...identity};
if(v.direction==='up') return await api.combat.moveUp(payload,write());
if(v.direction==='down') return await api.combat.moveDown(payload,write());
throw new Error('Choose up or down');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"toggle",
label:"Set hidden and defeated flags deliberately",
description:"The two checkboxes set exact booleans; they do not invert an unknown state. Defeated entries are skipped by forward next-turn processing. Clear both to restore the tutorial participant.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "Identity field: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Combatant token/actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "hidden",
"label": "Hide combat entry from players",
"value": false,
"type": "checkbox",
"required": false
},
{
"name": "defeated",
"label": "Mark defeated",
"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 {
if(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.toggle({sceneId:v.mapId,version:before.version,...identity,hidden:v.hidden,defeated:v.defeated},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"start-stop",
label:"Start or stop the disposable encounter",
description:"Add at least two participants. Start creates an encounter identity and begins round1/turn0. Stop resets the active cursor/history but retains participants. Use only this tutorial encounter.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "action",
"label": "start or stop",
"value": "start",
"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 {
const before=await api.combat.state({sceneId:v.mapId},read);
const payload={sceneId:v.mapId,version:before.version};
if(v.action==='start') return await api.combat.start(payload,write());
if(v.action==='stop') return await api.combat.stop(payload,write());
throw new Error('Choose start or stop');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"set-turn",
label:"Select a specific current participant",
description:"Start the encounter first, then use a participating identity. This selects its position without pretending that skipped turns ran normal forward effects.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "Identity field: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Combatant token/actor 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 {
if(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.setTurn({sceneId:v.mapId,version:before.version,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"turns",
label:"Advance or return one turn",
description:"Start first. Next advances valid native turn clocks and skips defeated entries. An authorized player may end their own current turn; previous is GM-only and requires history.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "direction",
"label": "next or previous",
"value": "next",
"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 before=await api.combat.state({sceneId:v.mapId},read);
if(!before.active) throw new Error('Start combat first');
const payload={sceneId:v.mapId,version:before.version};
if(v.direction==='next') return await api.combat.nextTurn(payload,write());
if(v.direction==='previous'){
if(context.role!=='gm') throw new Error('GM required for previous turn');
return await api.combat.previousTurn(payload,write());
}
throw new Error('Choose next or previous');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"rounds",
label:"Advance or return one round",
description:"Use next to move forward a round, then previous to inspect the native cursor behavior. Forward rounds can advance condition/effect clocks. Previous is navigation, not an undo of persisted effects.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "direction",
"label": "next or previous",
"value": "next",
"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 {
const before=await api.combat.state({sceneId:v.mapId},read);
if(!before.active) throw new Error('Start combat first');
const payload={sceneId:v.mapId,version:before.version};
if(v.direction==='next') return await api.combat.nextRound(payload,write());
if(v.direction==='previous') return await api.combat.previousRound(payload,write());
throw new Error('Choose next or previous');
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
},
{
id:"remove",
label:"Remove only a tutorial combat participant",
description:"Stop the disposable encounter and remove its participants one at a time. This removes tracker membership, not the token or actor.",
fields:[
{
"name": "mapId",
"label": "Scene ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "identityType",
"label": "Identity field: tokenId or actorId",
"value": "tokenId",
"type": "text",
"required": true
},
{
"name": "id",
"label": "Combatant token/actor 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(!['tokenId','actorId'].includes(v.identityType)) throw new Error('Use tokenId or actorId');
const identity={[v.identityType]:v.id};
if(!v.confirm) throw new Error('Confirm the tutorial participant');
const before=await api.combat.state({sceneId:v.mapId},read);
return await api.combat.remove({sceneId:v.mapId,version:before.version,...identity},write());
} catch(problem) {
if(problem.code==='conflict'&&!signal?.aborted) await api.combat.state({sceneId:v.mapId},read);
throw problem;
}
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.combat-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-combat-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-combat-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-combat-workshop input, .tutorial-workbench-combat-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-combat-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-combat-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.
Teste dois participantes, jogador encerrando o próprio turno, outro jogador negado, todos derrotados, histórico anterior ausente, revisão antiga e abort. Avançar relógios pode expirar condições ou aplicar efeitos do sistema. Previous turn/round muda cursor, sem desfazer efeitos persistidos ou restaurar condições. A identidade ativa permanece ao reordenar.
Operações públicas cobertas por receita
| API | Receita no painel |
|---|---|
| api.combat.add | add |
| api.combat.remove | remove |
| api.combat.configure | configure |
| api.combat.setInitiative | initiative |
| api.combat.rollInitiative | roll |
| api.combat.toggle | toggle |
| api.combat.moveUp | order |
| api.combat.moveDown | order |
| api.combat.setTurn | set-turn |
| api.combat.nextRound | rounds |
| api.combat.previousRound | rounds |
| api.combat.start | start-stop |
| api.combat.stop | start-stop |
| api.combat.nextTurn | turns |
| api.combat.previousTurn | turns |
Como usar a IA para estender este projeto
Estenda Combat workshop no Gravewright0.3.0 para [tarefa nativa]. Leia combat/services.py, frontend.json e relógios de condições/efeitos. Regras de iniciativa/provedor pertencem ao sistema selecionado. Use version real e identidade tokenId/actorId. Nunca repita conflito substituindo versão. Entregue módulo completo, preparo exato, testes GM/jogador atual/outro e distinção entre avanço de relógios e retorno do cursor.