Ok! I believe I've got all the bugs worked out. =D Everything is as I posted above: Roll initiative for selected: !jws-init Sort the initiative (and roll things in): !jws-init --sort Clear the initiative: !jws-init --clear Additionally, I've added a command to force a recalculation: !jws-init --recalc This is only necessary in one edge case that would be a bit obnoxious to deal with programatically and is unlikely to come up in standard play. That case is that everything in the turn order has the same initiative value. In that case, since it's looking for a new turn where the initiative value is higher (0 or lower), it never finds it and thus doesn't recalculate things. When a recalculate happens, it will whisper a message to the GM so they know it's working: Let me know if you run into any issues! Edit : Updated to add dex on turns where init is less than 1, per PM. Edit 2 : Added guarding against multiple recalcs (multiple events, multiple API sandboxes, etc) Script: on('ready',()=>{
const markers = {
advantage: 'strong',
disadvantage: 'back-pain'
};
const scriptName = 'JWSInit';
const version = '0.1.0';
const schemaVersion = 0.1;
const lastUpdate = 1632019558;
/* eslint-disable no-unused-vars */
const getTurnArray = () => ( '' === Campaign().get('turnorder') ? [] : JSON.parse(Campaign().get('turnorder')));
const getTurnArrayFromPrev = (prev) => ( '' === prev.turnorder ? [] : JSON.parse(prev.turnorder));
const setTurnArray = (ta) => Campaign().set({turnorder: JSON.stringify(ta)});
const addTokenTurn = (id, pr) => setTurnArray([...getTurnArray(), {id,pr}]);
const addCustomTurn = (custom, pr) => setTurnArray([...getTurnArray(), {id:"-1",custom,pr}]);
const removeTokenTurn = (tid) => setTurnArray(getTurnArray().filter( (to) => to.id !== tid));
const removeCustomTurn = (custom) => setTurnArray(getTurnArray().filter( (to) => to.custom !== custom));
const clearTurnOrder = () => Campaign().set({turnorder:'[]'});
const packTo = (to) => [{id:'HEADER',pr:Number.MAX_SAFE_INTEGER},...to].reduce((m,t)=>{
if('-1'===t.id){
m[m.length-1].packed=[...(m[m.length-1].packed || []), t];
return m;
}
return [...m,t];
},[]);
const unpackTo = (pTo) => pTo.reduce((m,t)=>{
let packed = t.packed||[];
delete t.packed;
if('HEADER' === t.id){
return [...packed,...m];
}
return [...m,t,...packed];
},[]);
const sorter_asc = (a, b) => ('-1' === a.id || '-1' === b.id) ? 0 : a.pr - b.pr;
const sorter_desc = (a, b) => ('-1' === a.id || '-1' === b.id) ? 0 : b.pr - a.pr;
const sortTurnOrder = (sortBy = sorter_desc, preserveFirst=false) => {
let to = packTo(getTurnArray());
let first = to[0];
let newTo = to.sort(sortBy);
if(preserveFirst){
let idx = newTo.findIndex(e=>e===first);
newTo = [...newTo.slice(idx),...newTo.slice(0,idx)];
}
Campaign().set({turnorder: JSON.stringify(unpackTo(newTo))});
};
const sortTurnOrderDirect = (to, sortBy = sorter_desc, preserveFirst=false) => {
let first = to[0];
let newTo = to.sort(sortBy);
if(preserveFirst){
let idx = newTo.findIndex(e=>e===first);
newTo = [...newTo.slice(idx),...newTo.slice(0,idx)];
}
return unpackTo(newTo);
};
/* eslint-enable no-unused-vars */
const checkInstall = () => {
log(`-=> ${scriptName} v${version} <=- [${lastUpdate}]`);
if (
!state.hasOwnProperty(scriptName) ||
state[scriptName].version !== schemaVersion
) {
log(` > Updating Schema to v${schemaVersion} <`);
switch (state[scriptName] && state[scriptName].version) {
case 0.1:
/* break; // intentional dropthrough */ /* falls through */
case "UpdateSchemaVersion":
state[scriptName].version = schemaVersion;
break;
default:
state[scriptName] = {
version: schemaVersion,
isChanging: false,
options: {}
};
break;
}
}
};
checkInstall();
state[scriptName].isChanging = false;
const playerCanControl = (obj, playerid='any') => {
const playerInControlledByList = (list, playerid) => list.includes('all') || list.includes(playerid) || ('any'===playerid && list.length);
let players = obj.get('controlledby')
.split(/,/)
.filter(s=>s.length);
if(playerInControlledByList(players,playerid)){
return true;
}
if('' !== obj.get('represents') ) {
players = (getObj('character',obj.get('represents')) || {get: function(){return '';} } )
.get('controlledby').split(/,/)
.filter(s=>s.length);
return playerInControlledByList(players,playerid);
}
return false;
};
const checkTurnOrderChanged = (obj,prev) => {
let to=getTurnArray();
let toPrev=getTurnArrayFromPrev(prev);
if(to.length && to[0].id !== '-1' && (to[0].id !== (toPrev[0]||{}).id || to[0].pr !== (toPrev[0]||{}).pr)){
// if New round or on a token with initiative<1
let shouldRecalculate=false;
if(parseFloat(to[0].pr) < 1) {
shouldRecalculate=true;
} else {
let pTo = packTo(to);
shouldRecalculate = parseFloat(pTo.slice(-1)[0].pr) < parseFloat(to[0].pr);
}
if(shouldRecalculate){
setTimeout(()=>{
if(false === state[scriptName].isChanging){
state[scriptName].isChanging = true;
reprocessTurns();
setTimeout(()=>state[scriptName].isChanging = false,1000);
}
},
randomInteger(100)
);
}
}
};
const getAttrForChar = (()=>{
let cache = {};
on('change:attribute',(a)=>{
let cid = a.get('characterid');
let aname = a.get('name');
if(cache.hasOwnProperty(cid)){
if(cache[cid].hasOwnProperty(aname)){
cache[cid][aname] = a;
}
}
});
on('destroy:attribute',(a)=>{
let cid = a.get('characterid');
let aname = a.get('name');
if(cache.hasOwnProperty(cid)){
if(cache[cid].hasOwnProperty(aname)){
delete cache[cid][aname];
}
}
});
return (cid,aname) => {
if(cache.hasOwnProperty(cid)){
if(cache[cid].hasOwnProperty(aname)){
return cache[cid][aname];
}
}
let a = findObjs({type:'attribute',characterid: cid, name: aname})[0];
if(a){
cache[cid] = {...cache[cid],[aname]:a};
return a;
}
return;
};
})();
const s = {
icon: "max-width:3em;max-height:3em;float:left;",
container: "border: 2px solid #999;border-radius:.5em;background-color:#eef;padding:.5em;",
header: "font-weight: bold; border-bottom: 3px solid #aaa;",
subcon: "",
roll: "display: inline-block; margin: .2em; font-weight: bold; padding: 0; border-radius:100%; background-color: white; border: 1px solid black;max-width:2em;max-height:2em;width:2em;height:2em;text-align:center;line-height:2em;font-weight:bold;",
rollCrit: "color: #247305",
rollFail: "color: #730505",
pr: "display: inline-block; margin: .2em; padding:0.25em; border-radius: .2em; background-color: #ccffcc; border: 1px solid black; min-width: 1em;text-align:center;",
label: "font-weight:bold; display:inline-block;",
clear: "clear:both;",
smallOutput: "display:inline-block;border-top: 1px dashed #999; border-bottom:1px dashed #999; background-color: #ccc; font-weight:bold; font-size: .8em; font-variant: small-caps; padding: 0 2em; text-align: center; width: 100%;"
};
const f = {
icon: (img) => `<img src="${img}" style="${s.icon}">`,
container: (...o) => `<div style="${s.container}">${o.join('')}</div>`,
header: (...o) => `<div style="${s.header}">${o.join('')}</div>`,
subcon: (...o) => `<div style="${s.subcon}">${o.join('')}</div>`,
roll: (n) => `<div style="${s.roll}${20===n ? s.rollCrit : (1===n ? s.rollFail : '')}">${n}</div>`,
pr: (n) => `<div style ="${s.pr}">${n}</div>${n>25 ? f.pr(n-20) : ''}`,
clear: () => `<div style="${s.clear}"></div>`,
label: (l) => `<div style="${s.label}">${l}:</div>`,
smallOutput: (o) => `<div style="${s.smallOutput}">${o}</div>`
};
const showTokenRoll = (token,roll,pr,pub) => {
let msg = f.container(
f.icon(token.get('imgsrc')),
f.header(token.get('name')),
f.subcon(
f.label("Roll"),
f.roll(roll),
f.label("Initiative"),
f.pr(pr)
),
f.clear()
);
sendChat('',`${pub ? '': '/w gm '}${msg}`);
};
const addTurnForToken = (token) => {
let to = getTurnArray().filter(t=>t.id !== token.id);
let roll = randomInteger(20);
switch(roll){
case 20: token.set(`status_${markers.advantage}`, true); break;
case 1: token.set(`status_${markers.disadvantage}`,true); break;
}
let cid = token.get('represents');
let wis = parseInt((getAttrForChar(cid,'wisdom_mod') || {get:()=>0}).get('current'));
let dex = parseInt((getAttrForChar(cid,'dexterity_mod') || {get:()=>0}).get('current'));
let pr = roll + wis + dex;
to.push({id:token.id,pr});
if(pr>25){
to.push({id:token.id,pr: (pr-20)});
}
to = sortTurnOrderDirect(to,sorter_desc,true);
showTokenRoll(token,roll,pr,playerCanControl(token));
setTurnArray(to);
};
// When we start a new round, recalculate the turns
const reprocessTurns = () => {
sendChat('',`/w gm ${f.smallOutput('Recalculating Initiative...')}`);
let tokenIds = [];
let turnsToAdd = [];
getTurnArray().reverse()
.filter(t=>{
if('-1' === t.id){
return true;
} else if (tokenIds.includes(t.id)){
return false;
}
tokenIds.push(t.id);
return true;
})
.reverse()
.forEach(t=>{
if('-1' !== t.id){
let token = getObj('graphic',t.id);
t.pr = parseFloat(t.pr);
if(token){
token.set({
[`status_${markers.advantage}`]: false,
[`status_${markers.disadvantage}`]: false
});
let dex = parseInt((getAttrForChar(token.get('represents'),'dexterity_mod') || {get:()=>0}).get('current'));
if(t.pr<1){
t.pr = t.pr + 20 + dex;
turnsToAdd.push({...t});
} else {
t.pr = t.pr + dex;
turnsToAdd.push({...t});
if(t.pr>25){
turnsToAdd.push({...t, pr: (t.pr-20)});
}
}
}
} else {
turnsToAdd.push(t);
}
})
;
let to = sortTurnOrderDirect(turnsToAdd);
let setTo = ()=>{
setTurnArray(to);
};
setTimeout(setTo,0);
};
on('chat:message',msg=>{
if('api'===msg.type && /^!jws-init(\b\s|$)/i.test(msg.content)){
let tokens = (msg.selected || [])
.map(o=>getObj('graphic',o._id))
.filter(g=>undefined !== g)
;
let pig = playerIsGM(msg.playerid);
let sort = false;
let skip = false;
let args = msg.content.split(/\s+--/).slice(1);
args.forEach(a=>{
let cmds = a.split(/\s+/);
switch(cmds[0].toLowerCase()){
case 'sort':
sort = pig;
break;
case 'clear': {
if(pig){
clearTurnOrder();
skip = true;
}
}
break;
case 'recalc': {
if(pig){
reprocessTurns();
skip = true;
}
}
break;
}
});
if(skip){
return;
}
const burndown = () => {
let t = tokens.shift();
if(t) {
addTurnForToken(t);
setTimeout(burndown,0);
} else {
if(sort){
sortTurnOrder();
}
}
};
burndown();
} else if('api'===msg.type && /^!eot(\b\s|$)/i.test(msg.content)){
setTimeout(()=>checkTurnOrderChanged(Campaign(),{turnorder:JSON.stringify([{id:-1}])}),1000);
}
});
on( 'change:campaign:turnorder',
(obj,prev)=>setTimeout(()=>checkTurnOrderChanged(Campaign(),prev),1000)
);
});