|
| 1 | +// |
| 2 | +// DupCharToken |
| 3 | +// |
| 4 | +// This script will duplicate a character sheet and token giving the new characters and tokens an identifying number and |
| 5 | +// linking each new token to it's own individual new character sheet. |
| 6 | +// This script is useful for those who want to create multiple copies of monsters because they prefer multiple character sheets to linking tokens as Mooks. |
| 7 | +// Using an argument of "clean" causes it to clean up (delete) numbered tokens and characters such as this script creates. |
| 8 | +// |
| 9 | +// Examples: |
| 10 | +// If you select a token named "Skel" that is linked to a character named "Skeleton", and in the chat window enter |
| 11 | +// !DupCharToken 5 |
| 12 | +// It will make 5 new characters, identical to the original, named "Skeleton 1" through "Skeleton 5", |
| 13 | +// and make 5 new tokens, identical to the original and stacked in the exact same location, named "Skel 1" through "Skel 5". |
| 14 | +// Each token will be linked to its corresponding character sheet in the exact same manor as the originals were linked. |
| 15 | +// |
| 16 | +// If later on you select any token named "Skel" or "Skel (number)" and enter |
| 17 | +// !DupCharToken clean |
| 18 | +// It will delete all tokens where the name is "Skel (any number)" and all characters named "Skeleton (any number)". |
| 19 | +// The original token and character (without a number) will not be deleted. |
| 20 | +// |
| 21 | +// |
| 22 | +// Known bugs/issues: |
| 23 | +// 1 - If the avatar image of the character is not in a user library (i.e: it's a marketplace image or from the Monster Manual, or the like), |
| 24 | +// due to limits placed upon the API, it can't set an avatar image. You will have to do that yourself, or (better yet) |
| 25 | +// copy the image to your own library on roll20, and set the token to be copied to use the image in your library. |
| 26 | +// Two ways to copy the image to your own library on roll20 are as follows: |
| 27 | +// A) download an image and then upload it manually, either by downloading the set, |
| 28 | +// or placing a copy on the VTT, pressing "z" and right clicking the preview. |
| 29 | +// or |
| 30 | +// B) Right click the image in your marketplace collection (in the art library) and choosing "Copy to Library" or "Copy to Folder" |
| 31 | +// B is probably faster, unless you are working with a whole set. |
| 32 | +// After downloading the image to your own Roll20 library, make sure the Character and Token to be copied uses that image. |
| 33 | +// 2 - It takes a while to make copies, If making lots of copies of characters with lots of attributes, it might be several minutes between the |
| 34 | +// message that the copy has started, and the message that it has ended. |
| 35 | +// This is OK except that the character copy is not done until the ending message comes. |
| 36 | +// 3 - Version 01.00 had unknown problems with the Roll20 script library, where the script could not be added or imported from the script library. |
| 37 | +// The script would work if it was gotten from GitHub and pasted into an API screen, but did not work from the script library. |
| 38 | +// It is hoped that version 1.01 will fix this, but if not, get it from the script library. |
| 39 | +// |
| 40 | +// |
| 41 | +// This script is based upon a script by "The Aaron". |
| 42 | +// https://app.roll20.net/forum/post/5687127/duplicate-character-sheet-plus-linked-token-script/?pageforid=5687127#post-5687127 |
| 43 | +// It has been modified to it's current form by Chris Dickey (Roll20 user "Chris D"). |
| 44 | +// Version 01.00 Dec 2018 Chris D. Original release. |
| 45 | +// Version 1.01 Nov 2019 Chris D. After all characters have been created, do loops to read attributes and abilities once and create for each new character. |
| 46 | +// Version 1.02 Jan 2020 Chris D. Fixed bug where script would crash API if clean was run with no tokens selected. |
| 47 | +// Version 1.03 Mar 2020 Chris D. Tokens dragged from the journal would not be correctly linked. Moved setDefaultTokenForCharacter to occur after attributes are copied. |
| 48 | +// Update 1.031 May 2020 Chris D. Minor bugfix with copying bio and gmnotes. |
| 49 | +// Update 1.04 Sep 2020 Chris D. Fixed the bug where it would timeout, killing api sandbox, if copying too many attributes. Implemented burndown method. |
| 50 | +// Update 1.05 Apr 2022 Chris D. Fixed bug where thread that was doing setDefaultTokenForCharacter() was doing it before other threads were done and |
| 51 | +// new tokens dragged out are not correctly linked. setDefaultTokenForCharacter() should now be guarantied to be last. |
| 52 | +// |
| 53 | +// |
| 54 | +// Commands: |
| 55 | +// !DupCharToken (Number of tokens to create) (Starting number) |
| 56 | +// !DupCharToken clean |
| 57 | +// Number to create is the number of characters and tokens to copy/create. It defaults to 1. |
| 58 | +// Starting number is the number to start numbering the tokens and characters from. Defaults to 1. |
| 59 | +// So !dupCharacter 5 3 would start numbering at 3 ending at number 7. |
| 60 | +// |
| 61 | +// You can add the following text to a macro |
| 62 | +// !dupCharToken ?{How many Duplicates|1} ?{Starting Number|1} |
| 63 | + |
| 64 | + |
| 65 | +on('ready',()=>{ |
| 66 | + 'use strict'; |
| 67 | + |
| 68 | + on('chat:message',(msg)=>{ |
| 69 | + 'use strict'; |
| 70 | + if( 'api' === msg.type && /^!dupCharToken\b/i.test( msg.content ) && playerIsGM( msg.playerid )) { |
| 71 | + try { |
| 72 | + let start = new Date().getTime(); |
| 73 | + function sChat( txt ) { |
| 74 | + sendChat('DupCharToken', '/w "' + msg.who.replace(" (GM)","") + '" <div style="color: #993333;font-weight:bold;">' + txt + '</div>', null, {noarchive:true}); |
| 75 | + } |
| 76 | + |
| 77 | + if( /\bclean\b/i.test( msg.content ) || /\bdelete\b/i.test( msg.content )) { // We are not duplicating, we are cleaning up characters and tokens that we created previously. |
| 78 | + let tnames = [], |
| 79 | + cnames = []; |
| 80 | + if( !msg.selected || msg.selected.length == 0 ) |
| 81 | + sChat( "Please select exactly one token representing the character to duplicate. Script usage is !DupCharToken (Number of tokens to create) (Starting number) or !DupCharToken clean"); |
| 82 | + else { |
| 83 | + _.each( msg.selected, function( sel ) { // for each token selected, find base token name and base character name. add unique ones to list. |
| 84 | + let tokenObj = getObj('graphic', sel._id); |
| 85 | + if ( _.isUndefined( tokenObj ) ) |
| 86 | + sChat( "Token is invalid in some way." ); |
| 87 | + else { |
| 88 | + let tn = tokenObj.get( "name" ).replace( /\s+\d+\s*$/, ""); // one or more whitespaces, one or more digits, zero or more whitespaces, then the end of line get trimmed off. |
| 89 | + if( tnames.indexOf( tn ) == -1 ) |
| 90 | + tnames.push( tn ); |
| 91 | + let charObj = getObj('character', tokenObj.get('represents')); |
| 92 | + if ( _.isUndefined( charObj ) ) |
| 93 | + sChat( "Token is not correctly linked to a good character." ); |
| 94 | + else { |
| 95 | + let cn = charObj.get( "name" ).replace( /\s+\d+\s*$/, ""); // one or more whitespaces, one or more digits, zero or more whitespaces, then the end of line get trimmed off. |
| 96 | + if( cnames.indexOf( cn ) == -1 ) |
| 97 | + cnames.push( cn ); |
| 98 | + } } |
| 99 | + }); // end each selected token |
| 100 | + |
| 101 | + function getRegEx( arr ) { // skel, ork. |
| 102 | + let re = "^("; |
| 103 | + arr.forEach( function(nm ) { re += nm + "|"; }); |
| 104 | + return new RegExp ( re.slice( 0, -1) + ")\\s\\d+\\s*$" ); |
| 105 | + }; |
| 106 | + if( tnames.length ) { // search for token names. for each found token, delete. |
| 107 | + let reg = getRegEx( tnames ), |
| 108 | + d = []; |
| 109 | + _.each(findObjs({type:"graphic", _subtype: "token" }),( tObj )=>{ |
| 110 | + if( reg.test( tObj.get( 'name' ) ) ) { |
| 111 | + d.push( tObj.get( 'name' ) ); |
| 112 | + tObj.remove(); |
| 113 | + } |
| 114 | + }); |
| 115 | + sChat( "Deleted tokens: " + d.join() + "." ); |
| 116 | + } |
| 117 | + |
| 118 | + if( cnames.length ) { // search for character names, for each, delete. |
| 119 | + let reg = getRegEx( cnames ), |
| 120 | + d = []; |
| 121 | + _.each(findObjs({type:"character" }),( cObj )=>{ |
| 122 | + if( reg.test( cObj.get( 'name' ) ) ) { |
| 123 | + d.push( cObj.get( 'name' ) ); |
| 124 | + cObj.remove(); |
| 125 | + } |
| 126 | + }); |
| 127 | + sChat( "Deleted characters: " + d.join() + "." ); |
| 128 | + } } // End clean |
| 129 | + } else if( !msg.selected || msg.selected.length !== 1 ) |
| 130 | + sChat( "Please select exactly one token representing the character to duplicate. Script usage is !DupCharToken (Number of tokens to create) (Starting number) or !DupCharToken clean"); |
| 131 | + else { // Not cleaning up, presumably duplicating. |
| 132 | + let num = 1, |
| 133 | + startnum = 1; |
| 134 | + let cl = msg.content.split( /\s+/ ); |
| 135 | + if( cl.length > 1 ) { |
| 136 | + if( parseInt( cl[ 1 ] ) ) |
| 137 | + num = parseInt( cl[ 1 ] ); |
| 138 | + else { |
| 139 | + sChat( "Script usage is !DupCharToken (Number of tokens to create) (Starting number) or !DupCharToken clean"); |
| 140 | + return; |
| 141 | + } |
| 142 | + if( cl.length > 2 && parseInt( cl[ 2 ] ) ) |
| 143 | + startnum = parseInt( cl[ 2 ] ); |
| 144 | + } |
| 145 | + |
| 146 | + let tokenObj = getObj('graphic', msg.selected[ 0 ]._id); |
| 147 | + if ( _.isUndefined( tokenObj ) ) |
| 148 | + sChat( "Token is invalid in some way." ); |
| 149 | + else { // Good command line. |
| 150 | + let charArray = [], |
| 151 | + tokenArray = [], |
| 152 | + links = []; |
| 153 | + |
| 154 | + function getLink( n, lnk ) { |
| 155 | + let l = tokenObj.get( lnk ); |
| 156 | + if( l && l != "" ) |
| 157 | + links[ n ] = l; |
| 158 | + } |
| 159 | + getLink( 0, "bar1_link" ); |
| 160 | + getLink( 1, "bar2_link" ); |
| 161 | + getLink( 2, "bar3_link" ); |
| 162 | + |
| 163 | + let charObj = getObj('character', tokenObj.get('represents')); |
| 164 | + if ( _.isUndefined( charObj ) ) |
| 165 | + sChat( "Token is not correctly linked to a good character." ); |
| 166 | + else { // Everything is good. We are duplicating. |
| 167 | + let oldCid = charObj.id; |
| 168 | + let tmpC = JSON.stringify( charObj ).replace( /\,"bio"\:.*?\,/gi, ',"bio":"",' ).replace( /\,"gmnotes"\:.*?\,/gi, ',"gmnotes":"",' ), |
| 169 | + tmpT = JSON.stringify( tokenObj ); |
| 170 | + |
| 171 | + for( let i = startnum; i < (startnum + num); ++i) { // We are going to want to duplicate both the character and token this many times. |
| 172 | + let newC = JSON.parse( tmpC ); // Simple true copy of object. |
| 173 | + delete newC._id; |
| 174 | + newC.name= charObj.get( 'name' ) + " " + i; |
| 175 | + |
| 176 | + let parts = newC.avatar.match( /(.*\/images\/.*)(thumb|med|original|max)([^?]*)(\?[^?]+)?$/ ); |
| 177 | + if(parts) |
| 178 | + newC.avatar = parts[ 1 ] + 'thumb' + parts[ 3 ] + (parts[ 4 ] ? parts[ 4 ] : `?${Math.round(Math.random()*9999999)}` ); |
| 179 | + else |
| 180 | + newC.avatar = ""; |
| 181 | + |
| 182 | + let newT = JSON.parse( tmpT ); |
| 183 | + delete newT._id; |
| 184 | + newT.name = tokenObj.get( 'name' ) + " " + i; |
| 185 | + parts = newT.imgsrc.match(/(.*\/images\/.*)(thumb|med|original|max)([^?]*)(\?[^?]+)?$/); |
| 186 | + if(parts) |
| 187 | + newT.imgsrc = parts[ 1 ] + 'thumb' + parts[ 3 ] + (parts[ 4 ] ? parts[ 4 ] : `?${Math.round(Math.random()*9999999)}` ); |
| 188 | + else |
| 189 | + newT.imgsrc = ""; |
| 190 | + |
| 191 | + let newCObj = createObj('character', newC ); |
| 192 | + let newTObj = createObj('graphic', newT ); |
| 193 | + newTObj.set( 'represents', newCObj.id ); |
| 194 | + charArray.push( newCObj ); |
| 195 | + tokenArray.push( newTObj ); |
| 196 | + } // End For each character to create. |
| 197 | + |
| 198 | + // Note, burndown is a method to make sure that the thread returns control to the operating system frequently enough that the op sys does |
| 199 | + // not think it is stuck in an infinite loop. we call setTimeout after processing just one attribute or ability. |
| 200 | + // it first does all of attBurndown, and then AbBurndown, then charBurndown. |
| 201 | + let attQueue = findObjs({_type:'attribute', _characterid:oldCid }), // create the queue we'll be processing asynchronously |
| 202 | + bio, gmnotes; |
| 203 | + const attBurndown = () => { // create the function that will process the next element of the queue |
| 204 | + if( attQueue.length ) { |
| 205 | + let a = attQueue.shift(); |
| 206 | + let sa = JSON.parse( JSON.stringify( a )); // Deep copy of the attribute. |
| 207 | + let lnk = links.indexOf( sa._id ); // Bar x is linked to the old attributes ID. Link it to the new one. |
| 208 | + delete sa._id; |
| 209 | + delete sa._type; |
| 210 | + for( let i = 0; i < charArray.length; ++i ) { |
| 211 | + delete sa._characterid; |
| 212 | + sa._characterid = charArray[ i ].id; |
| 213 | + if( lnk > -1 ) { |
| 214 | + let newA = createObj( 'attribute', sa ); |
| 215 | + tokenArray[ i ].set( "bar" + (lnk + 1).toString() + "_link", newA.id); // Link the new token to the new attribute. |
| 216 | + } else |
| 217 | + createObj('attribute', sa); |
| 218 | + }; |
| 219 | + setTimeout( attBurndown, 0 ); |
| 220 | + } else // Have finished the last attribute, now do the abilities. |
| 221 | + setTimeout( abBurndown, 0 ); |
| 222 | + }; // end attBurndown |
| 223 | + |
| 224 | + let abQueue = findObjs({ _type: 'ability', _characterid: oldCid }); // create the queue we'll be processing asynchronously |
| 225 | + const abBurndown = () => { // create the function that will process the next element of the queue |
| 226 | + if( abQueue.length ) { |
| 227 | + let a = abQueue.shift(); |
| 228 | + let sa = JSON.parse( JSON.stringify( a )); |
| 229 | + delete sa._id; |
| 230 | + delete sa._type; |
| 231 | + for( let i = 0; i < charArray.length; ++i ) { |
| 232 | + delete sa._characterid; |
| 233 | + sa._characterid = charArray[ i ].id; |
| 234 | + createObj( 'ability', sa); |
| 235 | + }; |
| 236 | + setTimeout( abBurndown, 0); |
| 237 | + } else // Have finished the last ability, now do the attributes. |
| 238 | + setTimeout( charBurndown, 0 ); |
| 239 | + }; // end abBurndown; |
| 240 | + |
| 241 | + const charBurndown = () => { // For each character. This is done last as it burns down the character and token arrays. |
| 242 | + if( charArray.length ) { |
| 243 | + let c = charArray.shift(), |
| 244 | + t = tokenArray.shift(); |
| 245 | + if( !_.isNull( bio ) && !_.isUndefined( bio) && bio != "null" && bio != "undefined" && typeof bio === 'string' && bio.trim() !== "" ) |
| 246 | + c.set( 'bio', bio ); |
| 247 | + if( !_.isNull( gmnotes ) && !_.isUndefined( gmnotes) && gmnotes != "null" && gmnotes != "undefined" && typeof gmnotes === 'string' && gmnotes.trim() !== "" ) |
| 248 | + c.set( 'gmnotes', gmnotes ); |
| 249 | + setDefaultTokenForCharacter( c, t ); |
| 250 | + toFront( t ); |
| 251 | + setTimeout( charBurndown, 0); |
| 252 | + } else // Have finished everything. This is the last thread executing. |
| 253 | + sChat( "Done duplicating: " + charObj.get( "name" ) + " " + num + " times. It took " + Math.round(((new Date().getTime()) - start) / 1000) + " seconds."); |
| 254 | + }; // end charBurndown |
| 255 | + |
| 256 | + // The above routines have not executed yet. Get some info and execute them. |
| 257 | + charObj.get("bio", function( b ) { |
| 258 | + bio = b; |
| 259 | + charObj.get("gmnotes", function( g ) { |
| 260 | + gmnotes = g; |
| 261 | + setTimeout( attBurndown, 0); // start the execution of all the burnnowns by doing the first element |
| 262 | + }); }); |
| 263 | + |
| 264 | + sChat( "Working on duplicating: " + charObj.get( "name" ) + " " + num + " times." ); // This main thread terminates way before the burndown threads. |
| 265 | + } |
| 266 | + } // End tokenObj is OK. |
| 267 | + } // End have one token selected. |
| 268 | + } catch(err) { |
| 269 | + log( msg); |
| 270 | + log( "DupCharToken.js error caught: " + err ); |
| 271 | + } |
| 272 | + } // End msg if for this script. |
| 273 | + }); // End on Chat. |
| 274 | +}); // End on Ready |
0 commit comments