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-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 ,
isNpc : opts . isNpc || false ,
conditions : opts . conditions || [],
isActive : opts . isActive !== undefined ? opts . isActive : true ,
2026-07-06 10:31:46 -04:00
deathSaves : opts . deathSaves || 0 ,
deathFails : opts . deathFails || 0 ,
2026-07-04 17:56:14 -04:00
isStable : opts . isStable || false ,
2026-06-28 16:57:43 -04:00
isDying : opts . isDying || false ,
};
}
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 ,
isNpc : false ,
}),
roll : { roll : initiativeRoll , mod : modifier , total : finalInitiative },
};
}
function buildMonsterParticipant ({ name , maxHp , initMod , isNpc }) {
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 ,
type : 'monster' ,
initiative : finalInitiative ,
maxHp : hp ,
currentHp : hp ,
isNpc : isNpc || false ,
}),
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 10:31:46 -04:00
async function applyHpChange ( encounter , participantId , changeType , amount , 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 10:31:46 -04:00
if ( isNaN ( amount ) || amount === 0 ) return encounter ; // no-op, no write
2026-06-28 16:57:43 -04:00
let newHp = participant . currentHp ;
if ( changeType === 'damage' ) newHp = Math . max ( 0 , participant . currentHp - amount );
else if ( changeType === 'heal' ) newHp = Math . min ( participant . maxHp , participant . currentHp + amount );
const wasDead = participant . currentHp === 0 ;
const isDead = newHp === 0 ;
const wasResurrected = wasDead && newHp > 0 ;
const updatedParticipants = ( encounter . participants || []). map ( p => {
if ( p . id !== participantId ) return p ;
const updates = { ... p , currentHp : newHp };
if ( isDead && ! wasDead ) {
2026-07-03 17:59:41 -04:00
updates . isActive = false ;
2026-06-28 16:57:43 -04:00
updates . deathSaves = p . deathSaves || 0 ;
updates . isDying = false ;
}
if ( wasResurrected ) {
2026-07-03 17:59:41 -04:00
updates . isActive = true ;
2026-06-28 16:57:43 -04:00
updates . deathSaves = 0 ;
updates . isDying = false ;
}
return updates ;
});
const hpLine = ` ${ participant . currentHp } → ${ newHp } HP` ;
2026-07-06 10:31:46 -04:00
const deathSuffix = ( isDead && ! wasDead ) ? ( participant . type === 'character' ? ' — Unconscious' : ' — Defeated' ) : '' ;
2026-06-28 16:57:43 -04:00
const resurSuffix = wasResurrected ? ' — Revived' : '' ;
const message = changeType === 'damage'
? ` ${ participant . name } took ${ amount } damage ( ${ hpLine } ) ${ deathSuffix } `
: ` ${ participant . name } healed for ${ amount } ( ${ hpLine } ) ${ resurSuffix } ` ;
2026-07-06 10:31:46 -04:00
const patch = { participants : updatedParticipants };
// undo: inverse amount (damage→heal, heal→damage). expandUndo uses sign.
const undoAmount = changeType === 'damage' ? amount : - amount ;
const log = {
type : changeType ,
participantId , participantName : participant . name ,
message ,
delta : { amount , from : participant . currentHp , to : newHp },
undo : { amount : undoAmount },
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
// DEATH_SAVE — returns { enc, status, isDying } so caller knows terminal state.
async function deathSave ( encounter , participantId , type , n , 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-04 17:56:14 -04:00
if ( type !== 'success' && type !== 'fail' ) {
throw new Error ( `deathSave type must be 'success' or 'fail', got " ${ type } ".` );
}
2026-07-04 17:22:52 -04:00
const name = participant . name ;
2026-07-06 10:31:46 -04:00
const current = type === 'success' ? ( participant . deathSaves || 0 ) : ( participant . deathFails || 0 );
2026-07-04 17:56:14 -04:00
const next = current === n ? n - 1 : n ;
const field = type === 'success' ? 'deathSaves' : 'deathFails' ;
const updates = { [ field ] : next };
let status = 'pending' ;
const newSuccesses = type === 'success' ? next : ( participant . deathSaves || 0 );
const newFails = type === 'fail' ? next : ( participant . deathFails || 0 );
2026-07-06 10:31:46 -04:00
if ( newSuccesses >= 3 ) { updates . isStable = true ; updates . isDying = false ; status = 'stable' ; }
else if ( newFails >= 3 ) { updates . isStable = false ; updates . isDying = true ; status = 'dead' ; }
else { updates . isStable = false ; updates . isDying = false ; }
2026-06-28 16:57:43 -04:00
const updatedParticipants = ( encounter . participants || []). map ( p =>
2026-07-04 17:56:14 -04:00
p . id === participantId ? { ... p , ... updates } : p
2026-06-28 16:57:43 -04:00
);
2026-07-06 10:31:46 -04:00
const message = status === 'stable' ? ` ${ name } stabilized (3 death saves)`
: status === 'dead' ? ` ${ name } failed 3 death saves — dead`
: ` ${ name } death ${ type } : ${ next } /3` ;
const patch = { participants : updatedParticipants };
const oldValues = { [ field ] : participant [ field ] || 0 , isStable : participant . isStable || false , isDying : participant . isDying || false };
const log = {
type : 'death_save' ,
participantId , participantName : name ,
message ,
delta : { type , count : next , status },
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 );
return { enc , status , isDying : status === 'dead' };
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 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-04 17:12:13 -04:00
if ( encounter . isStarted && encounter . currentTurnParticipantId ) {
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-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 );
participants . splice ( newTargetIndex , 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 ,
toggleParticipantActive , applyHpChange , deathSave , toggleCondition ,
reorderParticipants , endEncounter ,
activateDisplay , clearDisplay , toggleHidePlayerHp ,
2026-06-28 16:57:43 -04:00
};