Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×

Paid Gig: API Creation -- Modified Initiative Rolling

Hello, I'm looking for some help porting my oddly-specific initiative system into Roll20, and I recognize that this would take skills I don't have, so I'm happy to pay for this. I don't know if what I can afford will end up making it worth it to anyone to do, but I'm happy to explore. I don't think I can justify putting more than $200.00 US into this, so please feel free to tell me if I'm way off base in hoping someone might do this work for that amount! A friend and I once spent way too many hours building out this system in a google sheet, so I have the basics of a model thought out, I just don't have the skills or experience to build it in Roll20 (though I can read code and a lifetime ago did a bunch of coding in a dinosaur language). Here are the basics of the init system: Both Wisdom and Dex modifiers are added to init. Dex mod is added again every successive round. If init score gets above 25, you go once at the top of the round and once at [initScore] - 20, with the lower score becoming your new init for calculating future round inits If init score gets below 1 (negative Dex mod), you don't go that turn, going the next turn at [initScore] + 20. No worries if the init score has to still be displayed as a negative number.) Nat 20/Nat 1 gets and "Adv/Disadv" maker thrown on it for the first round. Please let me know if you think this is so underpaid that I should yank it, or if you might be interested! Thanks for checking it out! -- Jeremy PS: No need to get into whether or not this is a good initiative system! I've been down that path and don't want to take up this space with that discussion!!
1630987587
Victor B.
Pro
Sheet Author
API Scripter
Seriously?  I'm not sure what system you are running, but there's no API that will support that.  It's so out there.  
1630987808

Edited 1630987822
Victor B.
Pro
Sheet Author
API Scripter
Bug TheArron.  He might be able to do this without cost to you.  What bizarre system are you running?  
1630988949

Edited 1630992655
GiGs
Pro
Sheet Author
API Scripter
This does sound very doable, if complex. There's no API that currently supports it, but one could be written. I think you're offering maybe double what the work should cost, though. If Aaron (our local genius scriptomancer) hasnt written a solution in his tea break by the time you read this, PM me and we can talk. What does your last point mean: Nat 20/Nat 1 gets and "Adv/Disadv" maker thrown on it for the first round. Do you mean a marker is used to indicate the character has advantage or disadvantage that round? Just clarifying that it doesn't affect the initiative total.
1630991782
The Aaron
Roll20 Production Team
API Scripter
I wrote about half of it while a watching anime yesterday. The hardest part is figuring out when to do the recalculation. I think what I've settled on is if the turn order changes, the new turn is a token, and it has a higher initiative than the last token turn, do a recalculate. That was what I was going to poke at next. 
1630993006
GiGs
Pro
Sheet Author
API Scripter
The Aaron said: The hardest part is figuring out when to do the recalculation. I hadnt thought about the complexity there. Would it be too complex just to check if the currently acting token has the lowest initiative? Do the recalculation and start the next turn then?
@GIGs: yeah, that's definitely not clear enough! What I mean is that if your initiative roll is a natural 20, you get advantage on your first attack, and if it's a natural 1, you get disadvantage on your first attack. And I get that that could be horribly complicated, so I'd be happy to even get some marker shown on the token or some other non-mechanical indication of that. @TheAaron: WOW!! I didn't quite follow the conditions you laid out for the calculation. The one GiGs laid out makes sense to me, but I'm clearly not looking at it from the inside. I know I've seen at least one API tool out there that recalculates init every round so I (maybe foolishly) assumed that this could just happen at "the end of a round." Anywho, let me know if you need any more info! 
1631157579
The Aaron
Roll20 Production Team
API Scripter
@GIGs: you have to do the recalculate either at the end of the last token in the round, or at the beginning of the first token in the round.  We're basically talking about the same thing, just the point of view of when you act.  You only get events for when the Turn Order changes, which means Leading Edge Activation on turns.  The distinction of token turns is because some people use custom turn entries to mark spell durations or other things, so you'd need to exclude them from your calculations (which is actually pretty easy).
1631167299

Edited 1631392715
The Aaron
Roll20 Production Team
API Scripter
Ok, here's a pretty basic script that I think meets all your needs.  The basic command is: !jws-init This will add any selected tokens to the turn order using the rolling mechanics you specified above.  It will show the rolls for players publicly and whisper the rolls for NPCs to the GM. In the event that a token gets above a 25 for initiative, it will show the slots where they act: If a token rolls a 1 or a 20, it will add a marker to the token (just change the name to whatever you want at the top of the script).  When turns get recalculated, it will remove the markers from all the tokens. There are 2 optional GM-only arguments.  By default, rolled tokens are injected wherever they should go in the turn order, even in the middle of a round, allowing you to "roll in" more monsters before waiting on the next round. You can cause a sort with: !jws-init --sort If you have any tokens selected, it will roll those in and then sort, otherwise it just sorts immediately. You can also clear the turn order with: !jws-init --clear When a new round is detected, as laid out in the prior post, it will remove duplicate turns (retaining the lower numbered one), then add the dex mod to the remaining turns and finally create additional turns for those people over 25.  Lastly, it will sort the turn order. Note: If a token has a 0 or lower initiative when its turn comes up, that triggers a recalculation, effectively skipping their turn. In the event that a token has a 0 or lower initiative when a recalculation happens, 20 will be added to their initiative in lieu of adding dexterity. I tested this some, but the API seemed to get sluggish toward the end of the night and I'm not sure if there are issues with the script or Roll20, so start giving it a try and I'll try and narrow down any bugs tomorrow or so. Code: // See the lower code block!
Holy crimoles! That's amazing. I'm slammed at work today but will give this a shot as soon as I can free up some time. Thank you so freakin' much!
1631202294
The Aaron
Roll20 Production Team
API Scripter
No worries, it was a fun little challenge.  I think there's a bug in the recalculate where it handles negative number initiatives, but I'm going to poke on it more later tonight.
Hmm. I made a copy of a game, plugged the code in and tested. It's mostly working, but... every few rounds it advances everyone's init by two rounds worth of Dex modifier. I *think* I noticed times when it didn't advance at all, but I was clicking through quickly at the time and wasn't sure and my few attempts to re-create that have been unsuccessful.    
1631392660

Edited 1632020276
The Aaron
Roll20 Production Team
API Scripter
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) ); });
Roll20, please hire The Aaron. =D
1632020471
The Aaron
Roll20 Production Team
API Scripter
Jeremy is a gentleman and a scholar, and I was pleased to help him out with this! 
Thanks for jumping in there to test, Aaron. Very much appreciated -- does exactly what I was looking for. Happy to support folks doing great work like this -- and who knows, maybe some other folks will enjoy my wacky init system!!