Ok Scott, I got carried away again... Some tens of hours of work later, I have the plugins compiling as little tiny blobs that import all the shared behavior from a single library script. There was a lot of stuff I needed to make this work while still keeping all the source code in many many TypeScript source files for source control purposes. This was a fun experiment to learn more TypeScript, but otherwise I just wasted some thousands of dollars in time :) Here is the resulting "anonymous" plugin in its entirety (compiled JavaScript output.) Note you may still not enjoy reading it, because it is written in a declarative style rather than the straight script style. That is so that I can keep upgrading the parser and make it better and more survivable without having to change every place in every script where it implements some command. This is particularly important in the "rewards" plugin which has a pretty big command language. /* * der20 anonymous plugin DER20 DEVELOPMENT BUILD * * Copyright 2018 Ammo Goettsch * * Discord: ammo#7063 * Roll20: <a href="https://app.roll20.net/users/2990964/ammo" rel="nofollow">https://app.roll20.net/users/2990964/ammo</a> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="nofollow">http://www.apache.org/licenses/LICENSE-2.0</a> * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var der20_anonymous = { version: 'DER20 DEVELOPMENT BUILD' }; ( function () { const match = new Error ( 'beginning of file marker on line 4' ). stack . match ( /apiscript.js: ( \d + ) / ); const markerLine = match ? parseInt ( match [ 1 ], 10 ) : 4 // adjust for license file and 2 version string lines let preambleLength = 18 + 2 ; if ( markerLine < preambleLength ) { // license file is not prepended yet and we are running alone for testing return markerLine - 4 ; } der20_anonymous . scriptOffset = markerLine - ( 4 + preambleLength ); })(); on ( 'ready' , function () { // we only use simple decorations on properties, so we can use this simple implementation function __decorate ( decorators , target , key ) { for ( var i = decorators . length - 1 ; i >= 0 ; i --) { let decorator = decorators [ i ]; if ( decorator !== undefined ) { decorator ( target , key ); } } }; let exports = {}; "use strict" ; /* ========================================================================= * src/plugins/anonymous/set.ts */ var defaultToken ; function setDefaultToken ( token ) { defaultToken = token ; } class SetCommand extends der20_library . SelectedTokensSimpleCommand { handleToken ( token , parserContext , tokenIndex ) { let character = token . character ; if ( character === undefined ) { return new der20_library . Success ( `selected token does not represent any journal entry and won't be changed` ); } if (! character . isNpc ()) { return new der20_library . Success ( `' ${ character . name } ' is not an NPC/Monster and won't be changed` ); } let anonymousName = character . attribute ( 'npc_type' ). get ( 'current' ); if ( anonymousName . length > 0 ) { anonymousName = anonymousName . split ( / [ ,( ] / )[ 0 ]; } else { anonymousName = '' ; } if ( defaultToken === undefined ) { token . raw . set ({ name: anonymousName , showname: true , showplayers_name: true }); return new der20_library . Success ( `token for ' ${ character . name } ' changed to show only creature type; anonymous character to use as icon is not configured` ); } let midnight = new Date (); midnight . setHours ( 0 , 0 , 0 , 0 ); let cacheDefeat = ` ${ midnight . valueOf () / 1000 } ` ; let anonymousIcon = ` ${ defaultToken } ? ${ cacheDefeat } ` ; debug . log ( `setting token to: ${ anonymousIcon } ` ); token . raw . set ({ imgsrc: anonymousIcon , name: anonymousName , showname: true , showplayers_name: true }); debug . log ( `result after set: ${ token . image . url } ` ); if ( anonymousIcon !== token . image . url ) { return new der20_library . Failure ( new Error ( `token for ' ${ character . name } ' could not change image; the anonymous character has a marketplace image or otherwise restricted image` )); } return new der20_library . Success ( `token for ' ${ character . name } ' changed to show only creature type and anonymous icon` ); } } /* ========================================================================= * src/plugins/anonymous/character.ts */ class CharacterConfiguration extends der20_library . ConfigurationString { handleEndOfCommand ( context ) { // no item selected, present a dialog let dialog = new context . dialog ( ` ${ context . command } ` ); dialog . addTitle ( `Choose a character to provide the anonymous icon:` ); dialog . addSeparator (); let names = der20_library . Der20Character . all (). map (( character ) => { return { label: character . name , result: character . name }; }); dialog . addSelectionGroup ( 'character' , context . rest , names ); return new der20_library . DialogResult ( library_5 . DialogResult . Destination . Caller , dialog . render ()); } parse ( line , context ) { const imageKey = 'CharacterConfiguration_image' ; let imageSource = context . asyncVariables [ imageKey ]; if ( imageSource !== undefined ) { setDefaultToken ( imageSource . url ); // XXX we need some way to display this message even if verbose is not set, without introducing a new result type return new der20_library . Change ( `loaded anonymous icon from character ' ${ this . value () } '` ); } let result = super . parse ( line , context ); if (! result . isSuccess ()) { return result ; } let source = der20_library . Der20Character . byName ( this . value ()); if ( source === undefined ) { return new der20_library . Failure ( new Error ( `plugin requires a character named ' ${ this . value () } ' to provide default token` )); } return new der20_library . Asynchronous ( 'loading default token' , imageKey , source . imageLoad ()); } load ( json , context ) { super . load ( json , context ); let source = der20_library . Der20Character . byName ( this . value ()); if ( source === undefined ) { context . addMessage ( `plugin requires a character named ' ${ this . value () } ' to provide default token` ); return ; } context . addAsynchronousLoad ( source . imageLoad (), ( value ) => { setDefaultToken ( value . url ); }); } } /* ========================================================================= * src/plugins/anonymous/images.ts */ function makeImageSourceURL ( imageSource ) { if ( imageSource . includes ( '?' )) { return thumbify ( imageSource ); } let midnight = new Date (); midnight . setHours ( 0 , 0 , 0 , 0 ); let cacheDefeat = ` ${ midnight . valueOf () / 1000 } ` ; return thumbify ( ` ${ imageSource } ? ${ cacheDefeat } ` ); } function thumbify ( imageSource ) { return imageSource . replace ( / \/ [ a-z ] + \. png ( \? [ 0-9 ] + ) $ / , '/thumb.png$1' ); } /* ========================================================================= * src/plugins/anonymous/reveal.ts */ class RevealCommand extends der20_library . SelectedTokensSimpleCommand { handleToken ( token , context , tokenIndex ) { let character = token . character ; if ( character === undefined ) { return new der20_library . Success ( `selected token does not represent any journal entry and won't be changed` ); } if (! character . isNpc ()) { return new der20_library . Success ( `' ${ character . name } ' is not an NPC/Monster and won't be changed` ); } // because of request fan-out (selected tokens) we may have many images for which we are waiting const imageKey = `RevealCommand_image_ ${ tokenIndex } ` ; let imageSource = context . asyncVariables [ imageKey ]; if ( imageSource === undefined ) { return new der20_library . Asynchronous ( `loading default token info from ' ${ character . name } '` , imageKey , character . imageLoad ()); } token . raw . set ({ imgsrc: makeImageSourceURL ( imageSource . url ), name: character . name , showname: true , showplayers_name: true }); return new der20_library . Success ( `setting token to show its default name and image from ' ${ character . name } '` ); } } /* ========================================================================= * src/plugins/anonymous/configuration.ts */ class Configuration { constructor () { // standard plugin options this . options = new der20_library . Options (); // name of a character in the journal that will provide its default token image for anonymous tokens this . character = new CharacterConfiguration ( 'Anonymous' ); // set the selected tokens as anonymous this . set = new SetCommand (); // reveal the selected tokens' real identites this . reveal = new RevealCommand (); } // on reset (built in command provided by plugin), clear global state handleChange ( token ) { if ( token === 'reset' ) { setDefaultToken ( undefined ); } } } __decorate ([ der20_library . keyword ( 'option' ) ], Configuration . prototype , "options" , void 0 ); /* ========================================================================= * src/plugins/anonymous/main.ts */ new der20_library . Plugin ( 'anonymous' , Configuration ). start (); // redirect output to library console = console || {}; console . log = der20_library . consoleOutput ; debug = debug || {}; debug . log = der20_library . debugOutput ; // publish any exports we have Object . assign ( der20_anonymous , exports ); });