HomeDeveloper docsHow to create a combat tools module
Implementation tutorials 20 min read

How to create a combat tools module

Build a working encounter laboratory covering participants, initiative, order, flags, turns, rounds and cleanup.

Installed Combat workshop module after running its inspect recipe. Gravewright 0.3.0, English interface.
Installed Combat workshop module after running its inspect recipe. Gravewright 0.3.0, English interface. · Open full screenshot ↗

The project you will build

Create Combat workshop and operate the native encounter of one disposable scene. Add the two tokens, enter or roll initiative, start, select/reorder participants, advance turns/rounds, then stop and remove participants. The selected RPG system remains responsible for its rules and provider.

Target host: Gravewright 0.3.0. Actions run only when you click Run action, using the signed-in account. The demonstration panel is English. Each recipe specifies inputs and an observable result.

1. Create the files

Create a folder named combat-workshop. Save the complete files below as UTF-8, or download the project. manifest.json belongs at the root. No npm dependencies or JavaScript build is required.

text
combat-workshop/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Keep the scene and two tokens from Token workshop. Run Inspect to copy exact scene/combatant identities. Use tokenId for placed tokens or actorId for actor-only participants. This module supplies no RPG system, catalog or Server Rules companion. Its explicit test formula is an encounter setting on the disposable campaign.

2. Package, install and activate

From the project folder, run in PowerShell:

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.zip

On Linux/macOS, replace python with python3 in the same commands. The archive listing must show manifest.json at its root. As installation owner, open Inside → Modules → Install module → Install from ZIP, select the ZIP and click Install. As GM, open the campaign, select its Gravewright button → Extensions and Activate version 1.0.0 of Combat workshop. Close that menu and click Combat workshop. See your first module for screen-by-screen instructions.

The package stays inactive until selected for the table. Test one demonstration extension at a time because their launchers share a position. For changed bytes, increment version to 1.0.1, build another ZIP, install and activate that version; do not edit installed files.

3. Run each recipe

Inspect this scene encounter

Paste the disposable scene ID. It need not contain an encounter yet. Copy combatant IDs from the projected list after adding participants.

javascript
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;
  }
}

Expected result: Encounter version, active/round/turn, configuration and authorized combatants.

How to verify: Compare with the native Combat tracker. A player must not receive hidden entries or unauthorized resources.

Add a placed token or an actor to combat

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.

javascript
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;
  }
}

Expected result: A combatant is added without duplicating an existing identity; encounter version advances.

How to verify: Add two distinct combatants and verify both in the native tracker. Tokens from another scene must be refused.

Set an explicit encounter initiative formula

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.

javascript
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;
  }
}

Expected result: config.formula reflects the chosen expression; system-dependent config.input reports how initiative is entered.

How to verify: Open Combat settings and inspect the formula. Invalid formulas are rejected without a partial config write.

Set one combatant initiative value

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.

javascript
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;
  }
}

Expected result: The selected initiative changes; numeric initiative follows the native ordering policy.

How to verify: Set values12 and8 for two participants and inspect order. Text-mode systems may retain manual order.

Roll initiative for a chosen scope

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.

javascript
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;
  }
}

Expected result: Only the selected scope receives server-side rolls; the native tracker orders results.

How to verify: Set one manual value, run missing and confirm that value remains. Do not claim a specific random roll result.

Move a participant up or down

Use a combatant and run up, then down. Manual reordering preserves the active combatant identity rather than silently granting the turn to another entry.

javascript
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;
  }
}

Expected result: The selected participant changes position while the active identity is preserved.

How to verify: Start combat, note the current ID and reorder a different entry. The same participant should remain current.

Set hidden and defeated flags deliberately

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.

javascript
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;
  }
}

Expected result: The selected entry has the chosen flags. A hidden combat entry is absent for players.

How to verify: Compare player/GM tracker, then clear defeated before testing a full turn cycle.

Start or stop the disposable encounter

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.

javascript
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;
  }
}

Expected result: start: active true, round1. stop: active false, round0.

How to verify: Inspect encounterId and the native tracker after each intentional action.

Select a specific current participant

Start the encounter first, then use a participating identity. This selects its position without pretending that skipped turns ran normal forward effects.

javascript
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;
  }
}

Expected result: The chosen participant is current in the tracker.

How to verify: Compare current_name and the is_current flag against the selected ID.

Advance or return one turn

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.

javascript
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;
  }
}

Expected result: Current identity changes according to native order; previous returns the stored cursor when available.

How to verify: Try next as the current controlling player and a different player. Only the former is allowed. Going back does not resurrect expired conditions or undo periodic effects.

Advance or return one round

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.

javascript
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;
  }
}

Expected result: Round changes within native bounds; the turn resets to the first entry.

How to verify: Inspect round/turn and a timed condition from the token tutorial. Do not expect previous to restore consumed duration.

Remove only a tutorial combat participant

Stop the disposable encounter and remove its participants one at a time. This removes tracker membership, not the token or actor.

javascript
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;
  }
}

Expected result: The chosen identity leaves combat; native current/history handling remains consistent.

How to verify: Confirm the token is still present, then clean it through the separate token removal recipe if desired.

4. Understand and adapt the complete files

main.mjs renders the launcher and form. recipes.mjs owns behavior: change the chosen recipe’s run function while keeping its signature. The form passes real number, boolean and File values, blocks overlapping requests and renders using textContent. It does not evaluate user-provided code. api, host, assets and storage belong to the mount; signal cancels work on deactivation. The modal stays open during pending work and is released when the mount aborts. Each business function remains responsible for revisions, permissions and transaction sequencing.

manifest.json

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

javascript
import {recipes} from './recipes.mjs';
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
      const doc=root.ownerDocument;
      const button=doc.createElement('button');
      button.type='button';button.className='tutorial-launcher';
      button.textContent="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

javascript
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

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

text
MIT License

Copyright (c) 2026 Gravewright contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5. Permissions, failures and final test

Every write is an explicit Run action. Reads use recipient projections; writes keep native permissions and fresh operation UUIDs. Scene update, object update, token move/configuration and combat use their real revision fields. Some native selection commands have no expected revision parameter in this release; the examples do not invent one. On conflict the recipe rereads current state and stops. Review it, then intentionally run a new operation. Multiple API calls are separate transactions: a completed earlier call is not rolled back if a later read fails. Deactivation aborts scoped work; it cannot undo a server commit.

Test two participants, player ending their current turn, another player denied, all entries defeated, missing previous-turn history, stale encounter version and abort. Forward clocks can expire conditions or apply system effects. Previous turn/round changes the cursor; it does not undo persisted effects or restore expired conditions. Each participant identity is retained when reordering.

Public operations covered by recipe

APIPanel recipe
api.combat.addadd
api.combat.removeremove
api.combat.configureconfigure
api.combat.setInitiativeinitiative
api.combat.rollInitiativeroll
api.combat.toggletoggle
api.combat.moveUporder
api.combat.moveDownorder
api.combat.setTurnset-turn
api.combat.nextRoundrounds
api.combat.previousRoundrounds
api.combat.startstart-stop
api.combat.stopstart-stop
api.combat.nextTurnturns
api.combat.previousTurnturns

How to use AI to extend this project

text
Extend Combat workshop on Gravewright0.3.0 for [native encounter task]. Read combat/services.py, frontend.json and condition/effect clocks. Keep system-owned initiative/provider rules in the selected system package. Use actual encounter version and tokenId/actorId identities. Never retry conflict with a substituted version. Deliver complete module code, exact preparation, GM/current-player/other-player tests and clear forward-clock versus previous-cursor behavior.

All tutorials · API