2026-06-28 16:57:43 -04:00
// @ttrpg/shared — turn.js
2026-07-06 10:31:46 -04:00
// Turn-order + combat actions. WRITES OWN LOGS (single source of truth).
// Every mutating func is async, takes ctx {storage, encPath, logPath,
// displayPath}, persists encounter patch + log entry itself. UI, replay,
// anything calling these funcs = identical writes. Impossible to forget log.
2026-06-28 16:57:43 -04:00
//
2026-07-06 10:31:46 -04:00
// Pure helpers (generateId, build*, sort, slot) stay sync — no I/O.
//
// return value: NEW encounter state (immutable). Callers use for local state
// sync; persistence already done inside.
2026-06-28 16:57:43 -04:00
// ----------------------------------------------------------------------------
2026-07-06 10:31:46 -04:00
// Constants
2026-06-28 16:57:43 -04:00
// ----------------------------------------------------------------------------
const DEFAULT_MAX_HP = 10 ;
const DEFAULT_INIT_MOD = 0 ;
const MONSTER_DEFAULT_INIT_MOD = 2 ;
// ----------------------------------------------------------------------------
2026-07-06 10:31:46 -04:00
// Pure utilities (no I/O)
2026-06-28 16:57:43 -04:00
// ----------------------------------------------------------------------------
const generateId = () =>
( typeof crypto !== 'undefined' && crypto . randomUUID )
? crypto . randomUUID ()
: `id_ ${ Date . now () } _ ${ Math . random (). toString ( 36 ). slice ( 2 , 10 ) } ` ;
const rollD20 = () => Math . floor ( Math . random () * 20 ) + 1 ;
const formatInitMod = ( mod ) => {
if ( mod === undefined || mod === null ) return 'N/A' ;
return mod >= 0 ? `+ ${ mod } ` : ` ${ mod } ` ;
};
2026-07-04 15:40:39 -04:00
// SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md).
2026-06-28 16:57:43 -04:00
const sortParticipantsByInitiative = ( participants , originalOrder ) => {
return [... participants ]. sort (( a , b ) => {
if ( a . initiative === b . initiative ) {
const indexA = originalOrder . findIndex ( p => p . id === a . id );
const indexB = originalOrder . findIndex ( p => p . id === b . id );
return indexA - indexB ;
}
return b . initiative - a . initiative ;
});
};
2026-07-04 15:40:39 -04:00
function slotIndexForInit ( list , init ) {
for ( let i = 0 ; i < list . length ; i ++ ) {
if ( init > list [ i ]. initiative ) return i ;
}
return list . length ;
}
2026-07-06 16:39:11 -04:00
function withDeathStatusConditions ( participant , updates ) {
const status = updates . status !== undefined ? updates . status : participant . status ;
const current = participant . conditions || [];
let conditions = current ;
if ( status === 'dying' || status === 'stable' ) {
conditions = current . includes ( 'unconscious' ) ? current : [... current , 'unconscious' ];
} else if ( status === 'conscious' || status === 'dead' ) {
conditions = current . filter ( c => c !== 'unconscious' );
}
return { ... updates , conditions };
}
2026-07-01 16:00:00 -04:00
const syncTurnOrder = ( participants ) => ({
turnOrderIds : participants . map ( p => p . id ),
});
2026-07-01 14:22:02 -04:00
const nextActiveAfter = ( order , fromPos , isActive ) => {
const n = order . length ;
if ( n === 0 ) return { nextId : null , wrapped : false };
for ( let step = 1 ; step < n ; step ++ ) {
const idx = ( fromPos + step ) % n ;
const id = order [ idx ];
if ( isActive ( id )) return { nextId : id , wrapped : idx <= fromPos };
}
2026-07-06 10:31:46 -04:00
return { nextId : null , wrapped : false };
2026-07-01 14:22:02 -04:00
};
2026-06-28 16:57:43 -04:00
const computeTurnOrderAfterRemoval = ( encounter , removedId , updatedParticipants ) => {
if ( ! encounter . isStarted ) return {};
2026-07-01 16:00:00 -04:00
const updates = {};
2026-06-28 16:57:43 -04:00
if ( encounter . currentTurnParticipantId === removedId ) {
2026-07-01 16:00:00 -04:00
const removedPos = ( encounter . turnOrderIds || []). indexOf ( removedId );
2026-07-01 14:22:02 -04:00
const isActive = id => updatedParticipants . find ( p => p . id === id && p . isActive );
2026-07-01 16:00:00 -04:00
const { nextId , wrapped } = nextActiveAfter ( encounter . turnOrderIds || [], removedPos , isActive );
2026-07-01 14:22:02 -04:00
updates . currentTurnParticipantId = nextId ;
if ( nextId && wrapped ) updates . round = ( encounter . round || 1 ) + 1 ;
2026-06-28 16:57:43 -04:00
}
return updates ;
};
2026-07-06 10:31:46 -04:00
// ----------------------------------------------------------------------------
// Snapshot helper (lean — analyzer + viewer use this only)
// ----------------------------------------------------------------------------
function snapshotOf ( enc ) {
return {
round : enc . round ?? 0 ,
currentTurnParticipantId : enc . currentTurnParticipantId ?? null ,
turnOrderIds : [...( enc . turnOrderIds || [])],
activeIds : ( enc . participants || []). filter ( p => p . isActive ). map ( p => p . id ),
};
}
// Build LEAN log entry. Stores mutation delta only — no roster arrays,
// no payload duplication, no redo wrapper. Undo = id-based inverse.
// log object now carries: { type, message, participantId, delta, undo }.
// delta: type-specific scalars (amount, condition, init, changedFields...)
// undo: type-specific inverse (full participant obj for remove; id scalars
// for others). Client expandUndo() turns this into a patch at click.
function buildEntry ( enc , encPath , log ) {
if ( ! log ) return null ;
const e = {
ts : Date . now (),
type : log . type ,
message : log . message || '' ,
encounterId : enc . id || null ,
encounterName : enc . name || null ,
encounterPath : encPath ,
participantId : log . participantId || null ,
participantName : log . participantName || null ,
undone : false ,
snapshot : snapshotOf ( enc ),
};
if ( log . delta ) e . delta = log . delta ;
if ( log . undo ) e . undo = log . undo ;
return e ;
}
// Execute: apply patch to encounter doc + write lean log. Returns newEnc.
async function commit ( enc , patch , log , ctx ) {
const newEnc = { ... enc , ...( patch || {}) };
const logPromise = ( log && ctx . logPath )
? ctx . storage . addDoc ( ctx . logPath , buildEntry ( newEnc , ctx . encPath , log ))
: Promise . resolve ();
await Promise . all ([
ctx . storage . updateDoc ( ctx . encPath , patch ),
logPromise ,
]);
return newEnc ;
}
// expandUndo: turn lean `undo` (id-based) into full patch for server merge.
// Caller passes CURRENT encounter doc (from subscription) so roster ops can
// rebuild arrays. Returns { encounterPath, updates, redo } shaped for legacy
// transactionalUndo contract. redo = forward delta applied fresh.
function expandUndo ( entry , currentEnc ) {
const u = entry . undo ;
if ( ! u || ! currentEnc ) return null ;
const encPath = entry . encounterPath ;
const parts = currentEnc . participants || [];
const snap = entry . snapshot || {};
// snapshot captured POST-action state; redo = re-apply forward from pre.
// pre snapshot derivable from undo when scalar; roster ops need stored obj.
switch ( entry . type ) {
case 'add_participant' :
case 'add_participants' : {
const added = Array . isArray ( u . participants ) ? u . participants : [ u . participant ];
const ids = new Set ( added . filter ( Boolean ). map ( p => p . id ));
const turnState = {};
if ( u . currentTurnParticipantId !== undefined ) turnState . currentTurnParticipantId = u . currentTurnParticipantId ;
if ( u . turnOrderIds ) turnState . turnOrderIds = u . turnOrderIds ;
return {
encounterPath : encPath ,
updates : { participants : parts . filter ( p => ! ids . has ( p . id )), ... turnState },
redo : { participants : [... parts , ... added . filter ( p => ! parts . some ( x => x . id === p . id ))] },
};
}
case 'remove_participant' : {
const p = u . participant ;
const turnState = {};
if ( u . currentTurnParticipantId !== undefined ) turnState . currentTurnParticipantId = u . currentTurnParticipantId ;
if ( u . turnOrderIds ) turnState . turnOrderIds = u . turnOrderIds ;
let restoredParts = parts ;
if ( p ) {
const idx = typeof u . slotIdx === 'number' ? Math . min ( u . slotIdx , parts . length ) : parts . length ;
restoredParts = [... parts . slice ( 0 , idx ), p , ... parts . slice ( idx )];
}
return {
encounterPath : encPath ,
updates : { participants : restoredParts , ... turnState },
redo : { participants : parts . filter ( x => x . id !== ( p && p . id )) },
};
}
case 'damage' :
case 'heal' : {
const amt = u . amount || 0 ;
const pid = entry . participantId ;
const cur = parts . find ( p => p . id === pid );
if ( ! cur ) return null ;
const newHp = Math . max ( 0 , Math . min ( cur . maxHp , cur . currentHp + amt ));
return {
encounterPath : encPath ,
updates : { participants : parts . map ( p => p . id === pid ? { ... p , currentHp : newHp } : p ) },
redo : { participants : parts . map ( p => p . id === pid ? { ... p , currentHp : cur . currentHp - amt } : p ) },
};
}
case 'add_condition' :
case 'remove_condition' : {
const cond = u . condition ;
const pid = entry . participantId ;
const cur = parts . find ( p => p . id === pid );
if ( ! cur ) return null ;
const has = ( cur . conditions || []). includes ( cond );
const next = has ? ( cur . conditions || []). filter ( c => c !== cond ) : [...( cur . conditions || []), cond ];
return {
encounterPath : encPath ,
updates : { participants : parts . map ( p => p . id === pid ? { ... p , conditions : next } : p ) },
redo : { participants : parts . map ( p => p . id === pid ? { ... p , conditions : ! has ? [...( p . conditions || []), cond ] : ( p . conditions || []). filter ( c => c !== cond ) } : p ) },
};
}
case 'reactivate' :
case 'deactivate' : {
const pid = entry . participantId ;
const wasActive = u . wasActive ;
const turnState = {};
if ( u . currentTurnParticipantId !== undefined ) turnState . currentTurnParticipantId = u . currentTurnParticipantId ;
if ( u . turnOrderIds ) turnState . turnOrderIds = u . turnOrderIds ;
return {
encounterPath : encPath ,
updates : { participants : parts . map ( p => p . id === pid ? { ... p , isActive : wasActive } : p ), ... turnState },
redo : { participants : parts . map ( p => p . id === pid ? { ... p , isActive : ! wasActive } : p ) },
};
}
case 'reorder' : {
// undo: swap dragged/target back. Stored old order.
return {
encounterPath : encPath ,
updates : { participants : u . participants || parts , ...( u . turnOrderIds ? { turnOrderIds : u . turnOrderIds } : {}) },
redo : { participants : parts , turnOrderIds : snap . turnOrderIds || currentEnc . turnOrderIds || [] },
};
}
case 'update_participant' : {
const pid = entry . participantId ;
const oldVals = u . oldValues || {};
const newVals = u . newValues || {};
return {
encounterPath : encPath ,
updates : { participants : parts . map ( p => p . id === pid ? { ... p , ... oldVals } : p ) },
redo : { participants : parts . map ( p => p . id === pid ? { ... p , ... newVals } : p ) },
};
}
case 'death_save' : {
const pid = entry . participantId ;
const oldVals = u . oldValues || {};
return {
encounterPath : encPath ,
updates : { participants : parts . map ( p => p . id === pid ? { ... p , ... oldVals } : p ) },
redo : { participants : parts },
};
}
case 'next_turn' :
case 'auto_end' : {
return {
encounterPath : encPath ,
updates : { currentTurnParticipantId : u . currentTurnParticipantId , round : u . round , ...( u . turnOrderIds ? { turnOrderIds : u . turnOrderIds } : {}) },
redo : { currentTurnParticipantId : snap . currentTurnParticipantId , round : snap . round , turnOrderIds : snap . turnOrderIds || [] },
};
}
case 'pause' :
case 'resume' : {
return {
encounterPath : encPath ,
updates : { isPaused : u . isPaused },
redo : { isPaused : ! u . isPaused },
};
}
case 'start_encounter' :
case 'end_encounter' : {
return {
encounterPath : encPath ,
updates : u ,
redo : { isStarted : entry . type === 'start_encounter' , isPaused : false , currentTurnParticipantId : snap . currentTurnParticipantId , round : snap . round , turnOrderIds : snap . turnOrderIds || [] },
};
}
default :
return null ;
}
}
// ----------------------------------------------------------------------------
// Participant factories (pure — no write)
2026-06-28 16:57:43 -04:00
// ----------------------------------------------------------------------------
function makeParticipant ( opts ) {
return {
id : opts . id || generateId (),
name : opts . name ,
2026-07-06 10:31:46 -04:00
type : opts . type ,
2026-06-28 16:57:43 -04:00
originalCharacterId : opts . originalCharacterId || null ,
initiative : opts . initiative ,
maxHp : opts . maxHp ,
currentHp : opts . currentHp ,
conditions : opts . conditions || [],
isActive : opts . isActive !== undefined ? opts . isActive : true ,
2026-07-06 16:39:11 -04:00
status : opts . status || ( opts . currentHp === 0 ? 'dying' : 'conscious' ),
deathSaveSuccesses : opts . deathSaveSuccesses || 0 ,
deathSaveFailures : opts . deathSaveFailures || 0 ,
2026-06-28 16:57:43 -04:00
};
}
function buildCharacterParticipant ( character ) {
const initiativeRoll = rollD20 ();
const modifier = character . defaultInitMod || 0 ;
const finalInitiative = initiativeRoll + modifier ;
const maxHp = character . defaultMaxHp || DEFAULT_MAX_HP ;
return {
participant : makeParticipant ({
name : character . name ,
type : 'character' ,
originalCharacterId : character . id ,
initiative : finalInitiative ,
maxHp ,
currentHp : maxHp ,
}),
roll : { roll : initiativeRoll , mod : modifier , total : finalInitiative },
};
}
2026-07-06 16:39:11 -04:00
function buildMonsterParticipant ({ name , maxHp , initMod , asNpc }) {
2026-06-28 16:57:43 -04:00
const initiativeRoll = rollD20 ();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD ;
const finalInitiative = initiativeRoll + modifier ;
const hp = maxHp || DEFAULT_MAX_HP ;
return {
participant : makeParticipant ({
name ,
2026-07-06 16:39:11 -04:00
type : asNpc ? 'npc' : 'monster' ,
2026-06-28 16:57:43 -04:00
initiative : finalInitiative ,
maxHp : hp ,
currentHp : hp ,
}),
roll : { roll : initiativeRoll , mod : modifier , total : finalInitiative },
};
}
// ----------------------------------------------------------------------------
2026-07-06 10:31:46 -04:00
// Combat actions — async. Write encounter + log inside. Return newEnc.
// ctx = { storage, encPath, logPath, displayPath }
2026-06-28 16:57:43 -04:00
// ----------------------------------------------------------------------------
2026-07-06 10:31:46 -04:00
async function startEncounter ( encounter , ctx ) {
2026-06-28 16:57:43 -04:00
if ( ! encounter . participants || encounter . participants . length === 0 ) {
throw new Error ( 'Add participants first.' );
}
2026-07-01 16:00:00 -04:00
const sortedParticipants = sortParticipantsByInitiative ( encounter . participants || [], encounter . participants );
const firstActive = sortedParticipants . find ( p => p . isActive );
2026-07-06 10:31:46 -04:00
if ( ! firstActive ) throw new Error ( 'No active participants.' );
const patch = {
isStarted : true , isPaused : false , round : 1 ,
participants : sortedParticipants ,
currentTurnParticipantId : firstActive . id ,
turnOrderIds : sortedParticipants . map ( p => p . id ),
};
const log = {
type : 'start_encounter' ,
participantId : firstActive . id , participantName : firstActive . name ,
message : `Combat started: " ${ encounter . name } " — ${ firstActive . name } 's turn (Round 1)` ,
delta : { round : 1 , firstTurnId : firstActive . id },
undo : {
isStarted : encounter . isStarted ?? false ,
isPaused : encounter . isPaused ?? false ,
round : encounter . round ?? 0 ,
currentTurnParticipantId : encounter . currentTurnParticipantId ?? null ,
turnOrderIds : [...( encounter . turnOrderIds || [])],
2026-06-28 16:57:43 -04:00
},
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function nextTurn ( encounter , ctx ) {
2026-06-28 16:57:43 -04:00
if ( ! encounter . isStarted || encounter . isPaused ) {
throw new Error ( 'Encounter not running.' );
}
if ( ! encounter . currentTurnParticipantId || ! encounter . turnOrderIds || encounter . turnOrderIds . length === 0 ) {
throw new Error ( 'No active turn.' );
}
const activePsInOrder = encounter . turnOrderIds
. map ( id => encounter . participants . find ( p => p . id === id && p . isActive ))
. filter ( Boolean );
if ( activePsInOrder . length === 0 ) {
2026-07-06 10:31:46 -04:00
const patch = { isStarted : false , isPaused : false , currentTurnParticipantId : null , round : encounter . round };
const log = { type : 'auto_end' , message : `Combat auto-ended: no active participants` , undo : { currentTurnParticipantId : encounter . currentTurnParticipantId , round : encounter . round , isStarted : true , isPaused : false } };
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
let nextRound = encounter . round ;
2026-07-01 14:22:02 -04:00
const order = encounter . turnOrderIds || [];
const fromPos = order . indexOf ( encounter . currentTurnParticipantId );
const isActive = id => {
const p = encounter . participants . find ( x => x . id === id );
return !! p && p . isActive ;
};
const { nextId , wrapped } = nextActiveAfter ( order , fromPos , isActive );
2026-07-06 10:31:46 -04:00
if ( ! nextId ) throw new Error ( 'Could not determine next participant.' );
2026-07-01 14:22:02 -04:00
if ( wrapped ) nextRound += 1 ;
const nextParticipant = encounter . participants . find ( p => p . id === nextId );
2026-07-06 10:31:46 -04:00
const patch = { currentTurnParticipantId : nextParticipant . id , round : nextRound , turnOrderIds : encounter . turnOrderIds };
const log = {
type : 'next_turn' ,
participantId : nextParticipant . id , participantName : nextParticipant . name ,
message : ` ${ nextParticipant . name } 's turn (Round ${ nextRound } )` ,
delta : { wrapped , prevRound : encounter . round },
undo : { currentTurnParticipantId : encounter . currentTurnParticipantId , round : encounter . round , turnOrderIds : [... encounter . turnOrderIds ] },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function togglePause ( encounter , ctx ) {
if ( ! encounter || ! encounter . isStarted ) throw new Error ( 'Encounter not started.' );
2026-06-28 16:57:43 -04:00
const newPausedState = ! encounter . isPaused ;
2026-07-06 10:31:46 -04:00
const patch = { isPaused : newPausedState , turnOrderIds : encounter . turnOrderIds };
const log = {
type : newPausedState ? 'pause' : 'resume' ,
message : `Combat ${ newPausedState ? 'paused' : 'resumed' } : " ${ encounter . name } "` ,
delta : { paused : newPausedState },
undo : { isPaused : encounter . isPaused ?? false },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function addParticipant ( encounter , participant , ctx ) {
2026-06-29 16:25:39 -04:00
if (( encounter . participants || []). some ( p => p . id === participant . id )) {
throw new Error ( `Participant with id " ${ participant . id } " already exists in encounter.` );
}
2026-07-04 15:40:39 -04:00
const base = [...( encounter . participants || [])];
const idx = slotIndexForInit ( base , participant . initiative );
base . splice ( idx , 0 , participant );
2026-07-06 10:31:46 -04:00
const patch = { participants : base , ... syncTurnOrder ( base ) };
const log = {
type : 'add_participant' ,
participantId : participant . id , participantName : participant . name ,
message : ` ${ participant . name } added to encounter (Initiative: ${ participant . initiative } )` ,
delta : { init : participant . initiative , maxHp : participant . maxHp , ptype : participant . type },
undo : { participant , currentTurnParticipantId : encounter . currentTurnParticipantId , turnOrderIds : [...( encounter . turnOrderIds || [])] },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function addParticipants ( encounter , newParticipants , ctx ) {
2026-06-28 16:57:43 -04:00
const updatedParticipants = [...( encounter . participants || []), ... newParticipants ];
2026-07-04 17:25:22 -04:00
const names = newParticipants . map ( p => p . name ). join ( ', ' );
2026-07-06 10:31:46 -04:00
const patch = { participants : updatedParticipants };
const log = {
type : 'add_participants' ,
message : `Added ${ newParticipants . length } participant ${ newParticipants . length === 1 ? '' : 's' } : ${ names } ` ,
delta : { count : newParticipants . length },
undo : { participants : newParticipants },
2026-07-04 17:25:22 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function updateParticipant ( encounter , participantId , updatedData , ctx ) {
2026-07-04 15:40:39 -04:00
const existing = encounter . participants || [];
const target = existing . find ( p => p . id === participantId );
if ( ! target ) throw new Error ( `Participant " ${ participantId } " not found.` );
const merged = { ... target , ... updatedData };
2026-07-06 10:31:46 -04:00
let patch ;
const oldValues = {};
for ( const k of Object . keys ( updatedData )) oldValues [ k ] = target [ k ];
2026-07-04 15:40:39 -04:00
if ( ! ( 'initiative' in updatedData ) || updatedData . initiative === target . initiative ) {
const sameSlot = existing . map ( p => p . id === participantId ? merged : p );
2026-07-04 17:25:22 -04:00
const fields = Object . keys ( updatedData ). join ( ', ' );
2026-07-06 10:31:46 -04:00
patch = { participants : sameSlot , ... syncTurnOrder ( sameSlot ) };
const log = {
type : 'update_participant' ,
participantId , participantName : target . name ,
message : ` ${ target . name } updated ( ${ fields } )` ,
delta : { changedFields : Object . keys ( updatedData ) },
undo : { oldValues , newValues : { ... updatedData } },
2026-07-04 17:25:22 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
} else {
const without = existing . filter ( p => p . id !== participantId );
const idx = slotIndexForInit ( without , merged . initiative );
without . splice ( idx , 0 , merged );
patch = { participants : without , ... syncTurnOrder ( without ) };
const log = {
type : 'update_participant' ,
participantId , participantName : target . name ,
2026-07-04 17:25:22 -04:00
message : ` ${ target . name } updated (initiative: ${ merged . initiative } )` ,
2026-07-06 10:31:46 -04:00
delta : { changedFields : Object . keys ( updatedData ), initiative : merged . initiative },
undo : { oldValues , newValues : { ... updatedData } },
};
return commit ( encounter , patch , log , ctx );
}
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function removeParticipant ( encounter , participantId , ctx ) {
2026-06-28 16:57:43 -04:00
const updatedParticipants = ( encounter . participants || []). filter ( p => p . id !== participantId );
2026-07-01 16:00:00 -04:00
const advUpdates = computeTurnOrderAfterRemoval ( encounter , participantId , updatedParticipants );
const turnUpdates = encounter . isStarted ? { ... syncTurnOrder ( updatedParticipants ), ... advUpdates } : {};
2026-06-28 16:57:43 -04:00
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
2026-07-06 10:31:46 -04:00
const slotIdx = ( encounter . participants || []). findIndex ( p => p . id === participantId );
const patch = { participants : updatedParticipants , ... turnUpdates };
const log = {
type : 'remove_participant' ,
participantId , participantName : participant ? participant . name : null ,
message : ` ${ participant ? participant . name : 'Participant' } removed from encounter` ,
delta : { dead : true },
undo : { participant , slotIdx , currentTurnParticipantId : encounter . currentTurnParticipantId , turnOrderIds : [...( encounter . turnOrderIds || [])] },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function toggleParticipantActive ( encounter , participantId , ctx ) {
2026-06-28 16:57:43 -04:00
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
if ( ! participant ) throw new Error ( 'Participant not found.' );
const newIsActive = ! participant . isActive ;
const updatedParticipants = ( encounter . participants || []). map ( p =>
p . id === participantId ? { ... p , isActive : newIsActive } : p
);
2026-07-06 10:31:46 -04:00
// Toggle active changes only active state + mirrored order. It must NOT
// advance turn or increment round; DM can deactivate current, then click Next.
// Design: "Toggle active: No position change. Skip in rotation only."
const turnUpdates = encounter . isStarted ? syncTurnOrder ( updatedParticipants ) : {};
const patch = { participants : updatedParticipants , ... turnUpdates };
const log = {
type : newIsActive ? 'reactivate' : 'deactivate' ,
participantId , participantName : participant . name ,
message : ` ${ participant . name } ${ newIsActive ? 'reactivated' : 'deactivated' } ` ,
delta : { revived : newIsActive },
undo : { wasActive : participant . isActive , currentTurnParticipantId : encounter . currentTurnParticipantId , turnOrderIds : [...( encounter . turnOrderIds || [])] },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 16:39:11 -04:00
async function applyHpChange ( encounter , participantId , changeType , amount , optionsOrCtx , maybeCtx ) {
const ctx = maybeCtx || optionsOrCtx ;
const options = maybeCtx ? ( optionsOrCtx || {}) : {};
2026-06-28 16:57:43 -04:00
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
if ( ! participant ) throw new Error ( 'Participant not found.' );
2026-07-06 16:39:11 -04:00
if ( isNaN ( amount ) || amount === 0 ) return encounter ;
if ( amount < 0 ) {
amount = Math . abs ( amount );
changeType = changeType === 'damage' ? 'heal' : 'damage' ;
}
const oldValues = {
currentHp : participant . currentHp ,
status : participant . status || ( participant . currentHp === 0 ? 'dying' : 'conscious' ),
deathSaveSuccesses : participant . deathSaveSuccesses || 0 ,
deathSaveFailures : participant . deathSaveFailures || 0 ,
isActive : participant . isActive ,
};
let updates = {};
let message = '' ;
let logDelta = { amount , from : participant . currentHp };
const status = oldValues . status ;
if ( changeType === 'damage' ) {
if ( participant . currentHp === 0 ) {
if ( status === 'dead' ) {
if ( participant . type === 'monster' && participant . isActive !== false ) {
const updatedParticipants = ( encounter . participants || []). map ( p =>
p . id === participantId ? { ... p , isActive : false } : p
);
const patch = { participants : updatedParticipants };
const log = {
type : 'deactivate_dead_monster' ,
participantId ,
participantName : participant . name ,
message : ` ${ participant . name } marked inactive (dead monster)` ,
delta : { status : 'dead' , isActive : false },
undo : { oldValues },
};
return commit ( encounter , patch , log , ctx );
}
return encounter ;
}
const add = options . isCriticalHit ? 2 : 1 ;
const failures = ( status === 'stable' ? 0 : ( participant . deathSaveFailures || 0 )) + add ;
if ( failures >= 3 ) {
updates = { currentHp : 0 , status : 'dead' , deathSaveSuccesses : 0 , deathSaveFailures : 0 , ...( participant . type === 'monster' ? { isActive : false } : {}) };
} else {
updates = { currentHp : 0 , status : 'dying' , deathSaveSuccesses : 0 , deathSaveFailures : failures };
}
message = ` ${ participant . name } took ${ amount } damage while ${ status === 'stable' ? 'stable' : 'dying' } ` ;
logDelta = { ... logDelta , to : 0 , status : updates . status , deathSaveFailures : updates . deathSaveFailures };
} else if ( amount >= participant . currentHp + participant . maxHp ) {
updates = { currentHp : 0 , status : 'dead' , deathSaveSuccesses : 0 , deathSaveFailures : 0 , ...( participant . type === 'monster' ? { isActive : false } : {}) };
message = `Massive damage! ${ participant . name } instantly killed` ;
logDelta = { ... logDelta , to : 0 , status : 'dead' , massive : true };
} else {
const newHp = Math . max ( 0 , participant . currentHp - amount );
if ( newHp === 0 ) {
const zeroStatus = participant . type === 'monster' ? 'dead' : 'dying' ;
updates = { currentHp : 0 , status : zeroStatus , deathSaveSuccesses : 0 , deathSaveFailures : 0 , ...( zeroStatus === 'dead' ? { isActive : false } : {}) };
} else {
updates = { currentHp : newHp , status : 'conscious' };
}
message = ` ${ participant . name } took ${ amount } damage ( ${ participant . currentHp } → ${ newHp } HP)` ;
logDelta = { ... logDelta , to : newHp , status : updates . status };
2026-06-28 16:57:43 -04:00
}
2026-07-06 16:39:11 -04:00
} else if ( changeType === 'heal' ) {
if ( status === 'dead' ) return encounter ;
const newHp = Math . min ( participant . maxHp , Math . max ( 1 , participant . currentHp + amount ));
updates = { currentHp : newHp , status : 'conscious' , deathSaveSuccesses : 0 , deathSaveFailures : 0 };
message = ` ${ participant . name } healed for ${ amount } ( ${ participant . currentHp } → ${ newHp } HP)` ;
logDelta = { ... logDelta , to : newHp , status : 'conscious' , deathSavesCleared : true };
} else {
throw new Error ( `Unknown HP change type: ${ changeType } ` );
}
const updatedParticipants = ( encounter . participants || []). map ( p =>
p . id === participantId ? { ... p , ... withDeathStatusConditions ( p , updates ) } : p
);
2026-07-06 10:31:46 -04:00
const patch = { participants : updatedParticipants };
const undoAmount = changeType === 'damage' ? amount : - amount ;
const log = {
type : changeType ,
participantId , participantName : participant . name ,
message ,
2026-07-06 16:39:11 -04:00
delta : logDelta ,
undo : { amount : undoAmount , oldValues },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 16:39:11 -04:00
async function deathSave ( encounter , participantId , outcome , ctx ) {
2026-06-28 16:57:43 -04:00
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
if ( ! participant ) throw new Error ( 'Participant not found.' );
2026-07-06 16:39:11 -04:00
const statusBefore = participant . status || ( participant . currentHp === 0 ? 'dying' : 'conscious' );
if ( statusBefore !== 'dying' ) throw new Error ( 'Death saves only apply while dying.' );
if ( ! [ 'success' , 'fail' , 'nat1' , 'nat20' ]. includes ( outcome )) {
throw new Error ( `deathSave outcome must be success, fail, nat1, or nat20; got " ${ outcome } ".` );
2026-07-04 17:56:14 -04:00
}
2026-07-06 16:39:11 -04:00
const oldValues = {
currentHp : participant . currentHp ,
status : statusBefore ,
deathSaveSuccesses : participant . deathSaveSuccesses || 0 ,
deathSaveFailures : participant . deathSaveFailures || 0 ,
};
let updates ;
if ( outcome === 'nat20' ) {
updates = { currentHp : 1 , status : 'conscious' , deathSaveSuccesses : 0 , deathSaveFailures : 0 };
} else {
const successes = ( participant . deathSaveSuccesses || 0 ) + ( outcome === 'success' ? 1 : 0 );
const failures = ( participant . deathSaveFailures || 0 ) + ( outcome === 'fail' ? 1 : outcome === 'nat1' ? 2 : 0 );
if ( failures >= 3 ) updates = { currentHp : 0 , status : 'dead' , deathSaveSuccesses : 0 , deathSaveFailures : 0 };
else if ( successes >= 3 ) updates = { currentHp : 0 , status : 'stable' , deathSaveSuccesses : 0 , deathSaveFailures : 0 };
else updates = { currentHp : 0 , status : 'dying' , deathSaveSuccesses : successes , deathSaveFailures : failures };
}
2026-07-04 17:22:52 -04:00
const name = participant . name ;
2026-07-06 16:39:11 -04:00
const message = outcome === 'nat20' ? `Nat 20! ${ name } restored to 1 HP, conscious`
: outcome === 'nat1' ? `Nat 1! ${ name } takes 2 death save failures`
: updates . status === 'stable' ? ` ${ name } stabilized (3 death saves)`
: updates . status === 'dead' ? ` ${ name } failed 3 death saves — dead`
: ` ${ name } death save ${ outcome } ` ;
2026-06-28 16:57:43 -04:00
const updatedParticipants = ( encounter . participants || []). map ( p =>
2026-07-06 16:39:11 -04:00
p . id === participantId ? { ... p , ... withDeathStatusConditions ( p , updates ) } : p
2026-06-28 16:57:43 -04:00
);
2026-07-06 10:31:46 -04:00
const patch = { participants : updatedParticipants };
const log = {
type : 'death_save' ,
participantId , participantName : name ,
message ,
2026-07-06 16:39:11 -04:00
delta : { outcome , status : updates . status , deathSaveSuccesses : updates . deathSaveSuccesses , deathSaveFailures : updates . deathSaveFailures },
2026-07-06 10:31:46 -04:00
undo : { oldValues },
2026-07-04 17:22:52 -04:00
};
2026-07-06 10:31:46 -04:00
const enc = await commit ( encounter , patch , log , ctx );
2026-07-06 16:39:11 -04:00
return { enc , status : updates . status };
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function toggleCondition ( encounter , participantId , conditionId , ctx ) {
2026-06-28 16:57:43 -04:00
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
if ( ! participant ) throw new Error ( 'Participant not found.' );
const wasActive = ( participant . conditions || []). includes ( conditionId );
const updatedParticipants = ( encounter . participants || []). map ( p => {
if ( p . id !== participantId ) return p ;
const current = p . conditions || [];
const next = wasActive ? current . filter ( c => c !== conditionId ) : [... current , conditionId ];
return { ... p , conditions : next };
});
2026-07-06 10:31:46 -04:00
const patch = { participants : updatedParticipants };
const log = {
type : wasActive ? 'remove_condition' : 'add_condition' ,
participantId , participantName : participant . name ,
message : ` ${ participant . name } ${ wasActive ? 'lost' : 'gained' } ${ conditionId } ` ,
delta : { condition : conditionId , added : ! wasActive },
undo : { condition : conditionId },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 16:39:11 -04:00
async function stabilizeParticipant ( encounter , participantId , ctx ) {
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
if ( ! participant ) throw new Error ( 'Participant not found.' );
const status = participant . status || ( participant . currentHp === 0 ? 'dying' : 'conscious' );
if ( status === 'conscious' || participant . currentHp > 0 ) throw new Error ( 'Cannot stabilize conscious participant.' );
if ( status === 'dead' ) throw new Error ( 'Cannot stabilize dead participant.' );
if ( status === 'stable' ) return encounter ;
const updates = { currentHp : 0 , status : 'stable' , deathSaveSuccesses : 0 , deathSaveFailures : 0 };
const updatedParticipants = ( encounter . participants || []). map ( p =>
p . id === participantId ? { ... p , ... withDeathStatusConditions ( p , updates ) } : p
);
const patch = { participants : updatedParticipants };
const oldValues = {
currentHp : participant . currentHp ,
status ,
deathSaveSuccesses : participant . deathSaveSuccesses || 0 ,
deathSaveFailures : participant . deathSaveFailures || 0 ,
};
const log = {
type : 'stabilize' ,
participantId ,
participantName : participant . name ,
message : ` ${ participant . name } stabilized at 0 HP` ,
delta : { status : 'stable' },
undo : { oldValues },
};
return commit ( encounter , patch , log , ctx );
}
async function reviveParticipant ( encounter , participantId , ctx ) {
const participant = ( encounter . participants || []). find ( p => p . id === participantId );
if ( ! participant ) throw new Error ( 'Participant not found.' );
const status = participant . status || ( participant . currentHp === 0 ? 'dying' : 'conscious' );
if ( status !== 'dead' ) return encounter ;
const updates = { currentHp : 0 , status : 'stable' , deathSaveSuccesses : 0 , deathSaveFailures : 0 , isActive : true };
const updatedParticipants = ( encounter . participants || []). map ( p =>
p . id === participantId ? { ... p , ... withDeathStatusConditions ( p , updates ) } : p
);
const patch = { participants : updatedParticipants };
const oldValues = {
currentHp : participant . currentHp ,
status ,
deathSaveSuccesses : participant . deathSaveSuccesses || 0 ,
deathSaveFailures : participant . deathSaveFailures || 0 ,
isActive : participant . isActive ,
};
const log = {
type : 'revive' ,
participantId ,
participantName : participant . name ,
message : ` ${ participant . name } revived to 0 HP (stable, unconscious)` ,
delta : { status : 'stable' , currentHp : 0 },
undo : { oldValues },
};
return commit ( encounter , patch , log , ctx );
}
2026-07-06 10:31:46 -04:00
async function reorderParticipants ( encounter , draggedId , targetId , ctx ) {
2026-06-28 16:57:43 -04:00
const participants = [...( encounter . participants || [])];
2026-07-03 17:59:41 -04:00
const dragged = participants . find ( p => p . id === draggedId );
const target = participants . find ( p => p . id === targetId );
if ( ! dragged || ! target ) throw new Error ( 'Dragged or target item not found.' );
2026-07-06 10:31:46 -04:00
if ( draggedId === targetId ) return encounter ; // no-op, no write
if ( dragged . initiative !== target . initiative ) return encounter ; // cross-init blocked
2026-07-03 17:59:41 -04:00
const draggedIndex = participants . findIndex ( p => p . id === draggedId );
2026-07-06 16:39:11 -04:00
if ( encounter . isStarted && ! encounter . isPaused && encounter . currentTurnParticipantId ) {
2026-07-04 17:12:13 -04:00
const pointerIdx = participants . findIndex ( p => p . id === encounter . currentTurnParticipantId );
const targetIdx0 = participants . findIndex ( p => p . id === targetId );
if ( pointerIdx >= 0 ) {
const crosses = ( a , b ) => ( a <= pointerIdx && b > pointerIdx ) || ( a > pointerIdx && b <= pointerIdx );
2026-07-06 10:31:46 -04:00
if ( crosses ( draggedIndex , targetIdx0 )) return encounter ; // cross-pointer blocked
2026-07-04 17:12:13 -04:00
}
}
2026-07-06 16:39:11 -04:00
const targetIndex = participants . findIndex ( p => p . id === targetId );
2026-06-28 16:57:43 -04:00
const [ removedItem ] = participants . splice ( draggedIndex , 1 );
2026-07-01 16:00:00 -04:00
const newTargetIndex = participants . findIndex ( p => p . id === targetId );
2026-07-06 16:39:11 -04:00
const insertIndex = draggedIndex < targetIndex ? newTargetIndex + 1 : newTargetIndex ;
participants . splice ( insertIndex , 0 , removedItem );
2026-07-06 10:31:46 -04:00
const patch = { participants , ... syncTurnOrder ( participants ) };
const log = {
type : 'reorder' ,
participantId : draggedId , participantName : removedItem . name ,
message : ` ${ removedItem . name } reordered before ${ ( participants . find ( p => p . id === targetId ) || { } ).name || targetId}` ,
delta : { draggedId , targetId },
undo : { participants : encounter . participants || [], turnOrderIds : encounter . turnOrderIds || [], currentTurnParticipantId : encounter . currentTurnParticipantId ?? null },
2026-07-04 17:22:52 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-06 10:31:46 -04:00
async function endEncounter ( encounter , ctx ) {
const patch = { isStarted : false , isPaused : false , currentTurnParticipantId : null , round : 0 , turnOrderIds : [] };
const log = {
type : 'end_encounter' ,
message : `Combat ended: " ${ encounter . name } "` ,
delta : {},
undo : { isStarted : encounter . isStarted ?? false , isPaused : encounter . isPaused ?? false , round : encounter . round ?? 0 , currentTurnParticipantId : encounter . currentTurnParticipantId ?? null , turnOrderIds : [...( encounter . turnOrderIds || [])] },
2026-06-28 16:57:43 -04:00
};
2026-07-06 10:31:46 -04:00
return commit ( encounter , patch , log , ctx );
2026-06-28 16:57:43 -04:00
}
2026-07-03 18:26:02 -04:00
// ----------------------------------------------------------------------------
2026-07-06 10:31:46 -04:00
// Display lifecycle — write activeDisplay doc, no combat log.
// ctx.displayPath required.
2026-07-03 18:26:02 -04:00
// ----------------------------------------------------------------------------
2026-07-06 10:31:46 -04:00
async function activateDisplay ({ campaignId , encounterId }, ctx ) {
await ctx . storage . updateDoc ( ctx . displayPath , { activeCampaignId : campaignId , activeEncounterId : encounterId });
2026-07-03 18:26:02 -04:00
}
2026-07-06 10:31:46 -04:00
async function clearDisplay ( ctx ) {
await ctx . storage . updateDoc ( ctx . displayPath , { activeCampaignId : null , activeEncounterId : null });
2026-07-03 18:26:02 -04:00
}
2026-07-06 10:31:46 -04:00
async function toggleHidePlayerHp ( currentValue , ctx ) {
await ctx . storage . updateDoc ( ctx . displayPath , { hidePlayerHp : ! currentValue });
2026-07-03 18:26:02 -04:00
}
2026-06-28 16:57:43 -04:00
module . exports = {
2026-07-06 10:31:46 -04:00
DEFAULT_MAX_HP , DEFAULT_INIT_MOD , MONSTER_DEFAULT_INIT_MOD ,
generateId , rollD20 , formatInitMod ,
sortParticipantsByInitiative , syncTurnOrder , computeTurnOrderAfterRemoval ,
snapshotOf , buildEntry , expandUndo ,
makeParticipant , buildCharacterParticipant , buildMonsterParticipant ,
startEncounter , nextTurn , togglePause ,
addParticipant , addParticipants , updateParticipant , removeParticipant ,
2026-07-06 16:39:11 -04:00
toggleParticipantActive , applyHpChange , deathSave , stabilizeParticipant , reviveParticipant , toggleCondition ,
2026-07-06 10:31:46 -04:00
reorderParticipants , endEncounter ,
activateDisplay , clearDisplay , toggleHidePlayerHp ,
2026-06-28 16:57:43 -04:00
};