How to create, draw and reset weighted roll tables
Build an original journal roll table, edit a weight without losing data and publish native draws with or without replacement.

The project you will build
Create a two-entry harbor-weather table, inspect its real schema, change a weight, draw until it is exhausted and reset it. This uses journal_type roll_table rather than a compendium pack or a system-specific random table.
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 roll-tables. 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.
roll-tables/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txtUse a disposable campaign as GM and a second player account for audience checks. Keep all entry results original. Use an authorized scene UUID only if the draw should target that scene; otherwise the native broadcast context is used.
2. Package, install and activate
From the project folder, run in PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../roll-tables-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../roll-tables-1.0.0.zipOn 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 Roll table tools. Close that menu and click Roll table tools. 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
Create a weighted original roll table
As GM, create journal_type roll_table and configure its two complete entries. Weights 3 and 1 give different probabilities. Without replacement, drawn entries stop being eligible until reset.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const created=await api.journals.create({title:v.title,journal_type:'roll_table'},{signal,requestId:crypto.randomUUID()});try{await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,visibility:'private',data:{withReplacement:v.replacement,resultVisibility:v.secret?'gm':'public',entries:[{id:'clear',name:'Clear sky',weight:3,result:'The beacon is visible across the bay.',active:true,drawn:false,sortOrder:10},{id:'fog',name:'Fog',weight:1,result:'The boats wait for the fog to lift.',active:true,drawn:false,sortOrder:20}]}},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Roll table '+created.journal_id+' exists; configuration failed: '+(error.code||error.message)+'. Repair this table before creating another.');}}Expected result: A table with totalWeight 4, independent entry IDs and the chosen result audience.
How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.
Read weights, results and draw flags
Use the table UUID. The projected roll_table contains entries and totalWeight; GM-only result text is removed for other members.
async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table journal.');return row;}Expected result: Authorized entries with weight, active, drawn and sortOrder.
How to verify: Give a player read access and compare a secret table. The player must receive empty result text and cannot roll it.
Change one weight while preserving every other entry
As GM, use the exact entry ID. Copy the complete projected table and alter one weight; totalWeight is derived and omitted from the submitted data.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required to preserve complete results.');if(!Number.isInteger(v.weight)||v.weight<1||v.weight>1000000)throw new Error('Invalid weight.');const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table.');const data=structuredClone(row.roll_table);if(!data.entries.some(entry=>entry.id===v.entryId))throw new Error('Entry not found.');data.entries=data.entries.map(entry=>entry.id===v.entryId?{...entry,weight:v.weight}:entry);delete data.totalWeight;await api.journals.update({journal_id:row.id,version:row.version,title:row.title,visibility:row.visibility,data},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}Expected result: The chosen weight changes and a fresh totalWeight is computed.
How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.
Draw and publish one table result
Run as GM. The native command uses a secure weighted draw, persists drawn when needed and publishes a chat result. Its payload uses mapId for an explicit scene.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');return api.journals.roll({journal_id:v.id,...(v.sceneId.trim()?{mapId:v.sceneId.trim()}:{})},{signal,requestId:crypto.randomUUID()});}Expected result: The chosen entry and message_id; native chat shows its authorized result.
How to verify: Without replacement, roll twice and verify distinct entries, then a third roll must fail until reset. Do not rerun after a lost response using a new UUID if the same draw is being retried.
Reset drawn flags without changing results
Use the exhausted table UUID. Reset changes drawn to false for every entry; it does not clear native chat or replace table text.
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');await api.journals.reset({journal_id:v.id},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}Expected result: The active entries are eligible again and totalWeight is restored.
How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.
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
{
"id": "tutorial.roll-tables",
"name": "Roll table tools",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Build an original journal roll table, edit a weight without losing data and publish native draws with or without replacement.",
"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="Roll table tools";
root.append(button);
for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
let modal,pending=false;
button.addEventListener('click',event=>{
event.stopPropagation();
if(modal?.isConnected) { modal.focus();return; }
const panel=doc.createElement('section');
panel.className="tutorial-workbench-roll-tables";
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:"create-table",
label:"Create a weighted original roll table",
description:"As GM, create journal_type roll_table and configure its two complete entries. Weights 3 and 1 give different probabilities. Without replacement, drawn entries stop being eligible until reset.",
fields:[
{
"name": "title",
"label": "Roll table title",
"type": "text",
"value": "Harbor weather",
"required": true
},
{
"name": "secret",
"label": "GM-only results",
"type": "checkbox",
"value": false
},
{
"name": "replacement",
"label": "Allow replacement",
"type": "checkbox",
"value": false
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');const created=await api.journals.create({title:v.title,journal_type:'roll_table'},{signal,requestId:crypto.randomUUID()});try{await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,visibility:'private',data:{withReplacement:v.replacement,resultVisibility:v.secret?'gm':'public',entries:[{id:'clear',name:'Clear sky',weight:3,result:'The beacon is visible across the bay.',active:true,drawn:false,sortOrder:10},{id:'fog',name:'Fog',weight:1,result:'The boats wait for the fog to lift.',active:true,drawn:false,sortOrder:20}]}},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Roll table '+created.journal_id+' exists; configuration failed: '+(error.code||error.message)+'. Repair this table before creating another.');}}
},
{
id:"read-table",
label:"Read weights, results and draw flags",
description:"Use the table UUID. The projected roll_table contains entries and totalWeight; GM-only result text is removed for other members.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
}
],
run:async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table journal.');return row;}
},
{
id:"set-weight",
label:"Change one weight while preserving every other entry",
description:"As GM, use the exact entry ID. Copy the complete projected table and alter one weight; totalWeight is derived and omitted from the submitted data.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
},
{
"name": "entryId",
"label": "Entry ID",
"type": "text",
"value": "fog",
"required": true
},
{
"name": "weight",
"label": "Weight, integer 1 to 1000000",
"type": "number",
"value": 2,
"required": true
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required to preserve complete results.');if(!Number.isInteger(v.weight)||v.weight<1||v.weight>1000000)throw new Error('Invalid weight.');const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='roll_table')throw new Error('Choose a roll_table.');const data=structuredClone(row.roll_table);if(!data.entries.some(entry=>entry.id===v.entryId))throw new Error('Entry not found.');data.entries=data.entries.map(entry=>entry.id===v.entryId?{...entry,weight:v.weight}:entry);delete data.totalWeight;await api.journals.update({journal_id:row.id,version:row.version,title:row.title,visibility:row.visibility,data},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}
},
{
id:"roll",
label:"Draw and publish one table result",
description:"Run as GM. The native command uses a secure weighted draw, persists drawn when needed and publishes a chat result. Its payload uses mapId for an explicit scene.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
},
{
"name": "sceneId",
"label": "Authorized scene UUID, optional",
"type": "text",
"value": "",
"required": false
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');return api.journals.roll({journal_id:v.id,...(v.sceneId.trim()?{mapId:v.sceneId.trim()}:{})},{signal,requestId:crypto.randomUUID()});}
},
{
id:"reset",
label:"Reset drawn flags without changing results",
description:"Use the exhausted table UUID. Reset changes drawn to false for every entry; it does not clear native chat or replace table text.",
fields:[
{
"name": "id",
"label": "Document UUID",
"type": "text",
"value": "",
"required": true
}
],
run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required.');await api.journals.reset({journal_id:v.id},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}
}
];styles.css
.gw-module-root[data-module-id="tutorial.roll-tables"] .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-roll-tables { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-roll-tables label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-roll-tables input, .tutorial-workbench-roll-tables select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-roll-tables pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-roll-tables 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. Permissions, failures and final test
Only GMs can roll or reset. A read-access player may inspect authorized metadata but receives no hidden result text for resultVisibility gm. With replacement false, two active entries allow two draws, then no entry remains. Reset restores eligibility without rerolling or clearing chat. Table edits preserve all entries and exact version; do not copy a filtered player projection back as a complete GM table. Handle a setup failure using its returned created UUID.
How to use AI to extend this project
Extend roll-tables on Gravewright 0.3.0 with [behavior]. Read normalize_roll_table_data, journals.types.apply and the public browser mapping. Keep weighted native draws, active/drawn flags, withReplacement, resultVisibility, GM-only roll/reset, exact update versions and retry UUIDs. Supply complete entries and test exhaustion, reset, private results and a missing scene.