HomeDeveloper docsHow to create quest automation and quest-board actions
Implementation tutorials 13 min read

How to create quest automation and quest-board actions

Create and read a real quest, advance objectives, assign actors and manage every board relationship.

Installed Quest and board tools module after running its create-quest recipe. Gravewright 0.3.0, English interface.
Installed Quest and board tools module after running its create-quest recipe. Gravewright 0.3.0, English interface. · Open full screenshot ↗

The project you will build

Build a module for an original harbor quest and job board. Run Create quest, copy its ID, read objective IDs, mark completion and assign a character. Create a board, link the quest, create another through the board, pin and reorder both, then unlink without deleting content.

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 quests. 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
quests/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Use a disposable campaign as GM. Obtain a readable actor UUID from Actors. For sharing, use the access and presentation recipes in journal management; board visibility and player-visible quest statuses both matter.

2. Package, install and activate

From the project folder, run in PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../quests-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../quests-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 Quest and board tools. Close that menu and click Quest and board 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 complete original quest

As GM, enter a title, summary and objective. Create allocates an empty quest; the next independent command fills its complete original data. If setup fails, the error includes the created ID so you can repair that document.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required for the complete seed.');const created=await api.journals.create({title:v.title,journal_type:'quest'},{signal,requestId:crypto.randomUUID()});try{const data={status:'available',public:{summary:v.summary},gm:{},objectives:[{id:'beacon_platform',text:v.objective,completed:false,optional:false,visibleToPlayers:true,sortOrder:10}],rewards:[],tags:['tutorial'],assignees:[]};await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,visibility:'private',data},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Quest '+created.journal_id+' was created; setup failed: '+(error.code||error.message)+'. Inspect it before creating another.');}}

Expected result: A private available quest with one visible objective and its UUID/version.

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 quest fields and objective IDs

Paste the created journal_id. Copy quest.objectives[].id for the completion action; the GM projection also includes gm notes and secrets that players never receive.

javascript
async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest')throw new Error('Choose a quest.');return row;}

Expected result: The authorized quest projection, not a raw ORM document.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Advance a quest status with its revision

Choose one of the six status values. This reads the current version once, performs one command and reads the result. A conflict is reported rather than retried.

javascript
async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest'||!['draft','available','active','completed','failed','archived'].includes(v.status))throw new Error('Choose a quest and valid status.');await api.journals.setStatus({journal_id:row.id,version:row.version,status:v.status},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}

Expected result: The same quest with the requested status and a newer version.

How to verify: Move an available quest to draft and compare its visibility through a player’s board.

Complete or reopen an objective

Use the ID from the read recipe, not the objective label. Completed is a boolean. The server prevents players from choosing hidden objective IDs.

javascript
async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest')throw new Error('Choose a quest.');await api.journals.setObjective({journal_id:row.id,version:row.version,objective_id:v.objectiveId,completed:v.completed},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}

Expected result: Only the chosen objective completion flag changes.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Assign a readable character without dropping existing assignments

Use an actor UUID from the Actors directory or actor API. The recipe keeps visible assignees and adds one unique ID; the server preserves assignments hidden from non-GM editors.

javascript
async ({api,signal},v)=>{await api.actors.get(v.actorId,{}, {signal});const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest')throw new Error('Choose a quest.');await api.journals.assignCharacters({journal_id:row.id,version:row.version,assignees:[...new Set([...(row.quest.assignees||[]),v.actorId])]},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}

Expected result: quest.assignees includes the chosen character, within the 32-character limit.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Create an empty quest board

Create a quest_board document. Its data is empty; links to quests are separate server-owned board entries. Copy its ID for the next actions.

javascript
async ({api,signal},v)=>{const created=await api.journals.create({title:v.title,journal_type:'quest_board'},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}

Expected result: An empty board with board_entries and a document UUID.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Paste the board UUID. The native board-create command creates an available quest in the board folder and links it in one server operation.

javascript
async ({api,signal},v)=>{const board=await api.journals.get(v.id,{}, {signal});if(board.type!=='quest_board')throw new Error('Choose a quest board.');const result=await api.journals.createBoardQuest({journal_id:board.id,title:v.title},{signal,requestId:crypto.randomUUID()});return {result,board:await api.journals.get(board.id,{}, {signal})};}

Expected result: created_quest_id plus the updated board entries.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Use the board UUID and an existing readable quest UUID. This adds a board relationship; it does not copy quest content.

javascript
async ({api,signal},v)=>{await api.journals.addQuest({journal_id:v.id,quest_id:v.questId},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}

Expected result: The quest appears once in board_entries.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Pin or unpin a board card

Use a quest already linked to the chosen board. Pinned is boolean; pinned entries are displayed before ordinary entries.

javascript
async ({api,signal},v)=>{await api.journals.pinQuest({journal_id:v.id,quest_id:v.questId,pinned:v.pinned},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}

Expected result: The chosen entry changes pinned, preserving quest data.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Reverse the complete board order

As GM, read every entry and submit every quest ID exactly once in reversed sort_order. A player’s filtered list is not a complete order. The native command validates the set and rejects concurrent membership changes.

javascript
async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('Use GM to read the complete board order.');const board=await api.journals.get(v.id,{}, {signal});if(board.type!=='quest_board')throw new Error('Choose a quest board.');const ids=[...board.board_entries].sort((a,b)=>a.sort_order-b.sort_order).map(entry=>entry.quest_id).reverse();await api.journals.reorderQuests({journal_id:board.id,ordered_quest_ids:ids},{signal,requestId:crypto.randomUUID()});return api.journals.get(board.id,{}, {signal});}

Expected result: All sort_order positions change; pinned grouping still has display priority.

How to verify: Open the native directory or document and compare its server state. Compare GM and player views; only authorized content may appear.

Choose the board and quest relationship to remove. This is not journal deletion.

javascript
async ({api,signal},v)=>{await api.journals.removeQuest({journal_id:v.id,quest_id:v.questId},{signal,requestId:crypto.randomUUID()});return {board:await api.journals.get(v.id,{}, {signal}),quest:await api.journals.get(v.questId,{}, {signal})};}

Expected result: The board no longer links it, while the original quest remains readable.

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

json
{
  "id": "tutorial.quests",
  "name": "Quest and board tools",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Create and read a real quest, advance objectives, assign actors and manage every board relationship.",
  "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="Quest and board 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-quests";
        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:"create-quest",
  label:"Create a complete original quest",
  description:"As GM, enter a title, summary and objective. Create allocates an empty quest; the next independent command fills its complete original data. If setup fails, the error includes the created ID so you can repair that document.",
  fields:[
  {
    "name": "title",
    "label": "Original quest title",
    "type": "text",
    "value": "Light the Harbor Beacon",
    "required": true
  },
  {
    "name": "summary",
    "label": "Public summary",
    "type": "text",
    "value": "Bring the lighthouse back into service.",
    "required": true
  },
  {
    "name": "objective",
    "label": "Visible objective",
    "type": "text",
    "value": "Reach the beacon platform",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('GM required for the complete seed.');const created=await api.journals.create({title:v.title,journal_type:'quest'},{signal,requestId:crypto.randomUUID()});try{const data={status:'available',public:{summary:v.summary},gm:{},objectives:[{id:'beacon_platform',text:v.objective,completed:false,optional:false,visibleToPlayers:true,sortOrder:10}],rewards:[],tags:['tutorial'],assignees:[]};await api.journals.update({journal_id:created.journal_id,version:created.version,title:v.title,visibility:'private',data},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}catch(error){throw new Error('Quest '+created.journal_id+' was created; setup failed: '+(error.code||error.message)+'. Inspect it before creating another.');}}
},
{
  id:"read-quest",
  label:"Read quest fields and objective IDs",
  description:"Paste the created journal_id. Copy quest.objectives[].id for the completion action; the GM projection also includes gm notes and secrets that players never receive.",
  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!=='quest')throw new Error('Choose a quest.');return row;}
},
{
  id:"status",
  label:"Advance a quest status with its revision",
  description:"Choose one of the six status values. This reads the current version once, performs one command and reads the result. A conflict is reported rather than retried.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "status",
    "label": "draft/available/active/completed/failed/archived",
    "type": "text",
    "value": "active",
    "required": true
  }
],
  run:async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest'||!['draft','available','active','completed','failed','archived'].includes(v.status))throw new Error('Choose a quest and valid status.');await api.journals.setStatus({journal_id:row.id,version:row.version,status:v.status},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}
},
{
  id:"objective",
  label:"Complete or reopen an objective",
  description:"Use the ID from the read recipe, not the objective label. Completed is a boolean. The server prevents players from choosing hidden objective IDs.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "objectiveId",
    "label": "Objective ID",
    "type": "text",
    "value": "beacon_platform",
    "required": true
  },
  {
    "name": "completed",
    "label": "Completed",
    "type": "checkbox",
    "value": true
  }
],
  run:async ({api,signal},v)=>{const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest')throw new Error('Choose a quest.');await api.journals.setObjective({journal_id:row.id,version:row.version,objective_id:v.objectiveId,completed:v.completed},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}
},
{
  id:"assign",
  label:"Assign a readable character without dropping existing assignments",
  description:"Use an actor UUID from the Actors directory or actor API. The recipe keeps visible assignees and adds one unique ID; the server preserves assignments hidden from non-GM editors.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "actorId",
    "label": "Readable actor UUID to assign",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,signal},v)=>{await api.actors.get(v.actorId,{}, {signal});const row=await api.journals.get(v.id,{}, {signal});if(row.type!=='quest')throw new Error('Choose a quest.');await api.journals.assignCharacters({journal_id:row.id,version:row.version,assignees:[...new Set([...(row.quest.assignees||[]),v.actorId])]},{signal,requestId:crypto.randomUUID()});return api.journals.get(row.id,{}, {signal});}
},
{
  id:"create-board",
  label:"Create an empty quest board",
  description:"Create a quest_board document. Its data is empty; links to quests are separate server-owned board entries. Copy its ID for the next actions.",
  fields:[
  {
    "name": "title",
    "label": "Quest board title",
    "type": "text",
    "value": "Harbor jobs",
    "required": true
  }
],
  run:async ({api,signal},v)=>{const created=await api.journals.create({title:v.title,journal_type:'quest_board'},{signal,requestId:crypto.randomUUID()});return api.journals.get(created.journal_id,{}, {signal});}
},
{
  id:"board-create-quest",
  label:"Create and link a quest through the board",
  description:"Paste the board UUID. The native board-create command creates an available quest in the board folder and links it in one server operation.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "title",
    "label": "New linked quest title",
    "type": "text",
    "value": "Repair the harbor bell",
    "required": true
  }
],
  run:async ({api,signal},v)=>{const board=await api.journals.get(v.id,{}, {signal});if(board.type!=='quest_board')throw new Error('Choose a quest board.');const result=await api.journals.createBoardQuest({journal_id:board.id,title:v.title},{signal,requestId:crypto.randomUUID()});return {result,board:await api.journals.get(board.id,{}, {signal})};}
},
{
  id:"board-add",
  label:"Link an existing quest",
  description:"Use the board UUID and an existing readable quest UUID. This adds a board relationship; it does not copy quest content.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "questId",
    "label": "Existing quest UUID",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,signal},v)=>{await api.journals.addQuest({journal_id:v.id,quest_id:v.questId},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}
},
{
  id:"board-pin",
  label:"Pin or unpin a board card",
  description:"Use a quest already linked to the chosen board. Pinned is boolean; pinned entries are displayed before ordinary entries.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "questId",
    "label": "Linked quest UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "pinned",
    "label": "Pinned",
    "type": "checkbox",
    "value": true
  }
],
  run:async ({api,signal},v)=>{await api.journals.pinQuest({journal_id:v.id,quest_id:v.questId,pinned:v.pinned},{signal,requestId:crypto.randomUUID()});return api.journals.get(v.id,{}, {signal});}
},
{
  id:"board-reorder",
  label:"Reverse the complete board order",
  description:"As GM, read every entry and submit every quest ID exactly once in reversed sort_order. A player’s filtered list is not a complete order. The native command validates the set and rejects concurrent membership changes.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,context,signal},v)=>{if(context.role!=='gm')throw new Error('Use GM to read the complete board order.');const board=await api.journals.get(v.id,{}, {signal});if(board.type!=='quest_board')throw new Error('Choose a quest board.');const ids=[...board.board_entries].sort((a,b)=>a.sort_order-b.sort_order).map(entry=>entry.quest_id).reverse();await api.journals.reorderQuests({journal_id:board.id,ordered_quest_ids:ids},{signal,requestId:crypto.randomUUID()});return api.journals.get(board.id,{}, {signal});}
},
{
  id:"board-remove",
  label:"Unlink a card while preserving its quest",
  description:"Choose the board and quest relationship to remove. This is not journal deletion.",
  fields:[
  {
    "name": "id",
    "label": "Document UUID",
    "type": "text",
    "value": "",
    "required": true
  },
  {
    "name": "questId",
    "label": "Quest UUID to unlink",
    "type": "text",
    "value": "",
    "required": true
  }
],
  run:async ({api,signal},v)=>{await api.journals.removeQuest({journal_id:v.id,quest_id:v.questId},{signal,requestId:crypto.randomUUID()});return {board:await api.journals.get(v.id,{}, {signal}),quest:await api.journals.get(v.questId,{}, {signal})};}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.quests"] .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-quests { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-quests label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-quests input, .tutorial-workbench-quests select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-quests pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-quests 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

Creation plus setup uses separate commands, so a setup failure names the created UUID instead of pretending nothing happened. Repair that object before clicking Create again. Status/objective/assignment edits send the exact read version and never retry conflicts automatically. Board ordering requires every linked quest ID exactly once, including drafts visible to GM. The server enforces ownership and hides private quest fields. Verify as a player after changing draft/available status and explicit grants.

How to use AI to extend this project

text
Extend the quests tutorial on Gravewright 0.3.0 with [workflow]. Inspect journals/services.py, types.py, data.py and frontend.json. Preserve native quest status values, objective IDs/booleans, assignee limits and board relationship payloads. Never delete a quest to unlink it. Deliver complete recipes, expose partial creation failures and test GM/player projections, hidden objectives, exact versions and board ordering conflicts.

All tutorials · API