This got kind of big...
Help is available with:
!summon-card --help
You can set up layouts using graphics with a particular name (doesn't need to be visible, just visible here for illustration), then summon cards to those locations:

!summon-card {{
--deck
core earth
--card|loc:1:fit|exact core earth 1
--card|loc:2:fit core earth 2
--card|loc:3:fit core earth 3
--card|loc:4:fit core earth 4
--card|loc:5:fit core earth 5
--card|loc:6:fit core earth 6
}}

Full details below:
SummonCard v0.1.1
SummonCard allows dealing cards from a deck to the table easily, particularly to predefined patterns.
Commands
!summon-card --helpShow this help.
!summon-card --deck <deck name fragment> --card <card name fragment> ...This command lets you summon a card from a deck.
- --deck <deck name fragment> -- Choose the deck to summon a card from. deck name fragment can be any subset of a deck's name, ignoring case and punctuation.
- --card <card name fragment> -- Specify which card to summon. card name fragment follows the same rules as for decks above. --card must always follow a --deck. You can specify as many --card arguments as you like to summon more than one card from the deck.
!summon-card --deck Monsters --card beholder
Note: You can keep adding additional --deck and --card arguments to summon multiple cards from multiple decks.
!summon-card --deck Monsters --card beholder --card dragon --deck Loot --card Chest
Note: You can create multi-line commands by enclosing the arguments after !summon-card
in {{
and }}
.
!summon-card {{
--deck Monsters
--card beholder
--card dragon
--deck Loot
--card Chest
}}
Note: You can use inline rolls as part of your command
!summon-card --deck Monsters --card beholder --card dragon --deck Loot --card Chest [[1d3]]
All cards that match a given card name fragment will be summoned. The following would summon all of Dragon, Dragon Turtle, and Dracolich:
!summon-card {{
--deck Monsters
--card dra
}}
Card Options--card can have several suffixes attached to it, separated by the |
character. Each suffix can have zero or more arguments separated by the :
character. There cannot be any spaces within the suffixes and arguments.
loc:<label>[:fit] -- Specifies a location where a card should be summoned. A location is a token on the same page with a name beginning with card:
. The name should have no spaces in it. Anything following card:
is the label for that location. The graphic will be used to set the summon location and rotation. You can futher append :fit to the label in order to size the card to the same size as the location graphic.
Here the Beholder is summoned to location 3 (a graphic with the name card:3
), while a chest is summoned to the location treasure (a graphic with the name card:treasure
) and scaled to the size of the location graphic.
!summon-card {{
--deck Monsters
--card|loc:3 beholder
--deck Loot
--card|loc:treasure:fit Chest
}}
Number based locations can be combined with inline rolls for random placement.
!summon-card {{
--deck Monsters
--card|loc:[[1d6]] beholder
}}
show:<face | back> -- Specifies whether the face or back of the card should be shown.
Note: Because of a bug with the API, this does not work for Marketplace cards.
Here the Beholder is summoned and showing the card back, while a chest is summoned and shows the card face.
!summon-card {{
--deck Monsters
--card|back beholder
--deck Loot
--card|face Chest
}}
exact -- Forces a second match against the specified name, to prevent a partial match on other similar cards that contain this name as a subset.
Here the Rogue 1
card would have summoned Rogue 10
, Rogue 11
, etc. as well.
!summon-card {{
--deck Monsters
--card|exact Rogue 1
--card Rogue 10
}}
num:<number> -- Summon a number of duplicate cards.
Here 10 Wound Markers are placed in a stack.
!summon-card {{
--deck Markers
--card|num:10 Wound
}}
All of these suffixes can be applied togther.
Here 10 Wound Markers are placed in a stack.
!summon-card {{
--deck Markers
--card|loc:pool|num:10|exact|show:face Wound
}}
The Code:
on('ready',()=>{
const version = '0.1.1';
const lastUpdate = 1567451144;
const styles = {
deck: `font-size: .8em;font-weight: bold;`,
msg: `border: 1px solid #999; background-color: #eee; padding: .5em;`
};
const s = (n)=>` style="${styles[n]||''}" `;
const f = {
deck: (d) => `<li ${s('deck')}>${d}</li>`,
decks: (ds) => `<ul ${s('decks')}>${ds.map(f.deck).join('')}</ul>`,
msg: (txt) => `<div ${s('msg')}>${txt}</div>`,
key: (k) => `<code ${s('key')}>${k}</code>`,
arg: (k) => `<code ${s('arg')}>${k}</code>`
};
const isCleanImgsrc = (imgsrc) => /(.*\/images\/.*)(thumb|med|original|max)([^?]*)(\?[^?]+)?$/.test(imgsrc);
const getCleanImgsrc = (imgsrc) => {
let parts = imgsrc.match(/(.*\/images\/.*)(thumb|med|original|max)([^?]*)(\?[^?]+)?$/);
if(parts) {
return parts[1]+'thumb'+parts[3]+(parts[4]?parts[4]:`?${Math.round(Math.random()*9999999)}`);
}
return;
};
const ch = (c) => {
const entities = {
'<' : 'lt',
'>' : 'gt',
"'" : '#39',
'@' : '#64',
'{' : '#123',
'|' : '#124',
'}' : '#125',
'[' : '#91',
']' : '#93',
'"' : 'quot',
'*' : 'ast',
'/' : 'sol',
' ' : 'nbsp'
};
if( entities.hasOwnProperty(c) ){
return `&${entities[c]};`;
}
return '';
};
const _h = {
outer: (...o) => `<div style="border: 1px solid black; background-color: white; padding: 3px 3px;">${o.join(' ')}</div>`,
title: (t,v) => `<div style="font-weight: bold; border-bottom: 1px solid black;font-size: 130%;">${t} v${v}</div>`,
subhead: (...o) => `<b>${o.join(' ')}</b>`,
minorhead: (...o) => `<u>${o.join(' ')}</u>`,
optional: (...o) => `${ch('[')}${o.join(` ${ch('|')} `)}${ch(']')}`,
required: (...o) => `${ch('<')}${o.join(` ${ch('|')} `)}${ch('>')}`,
header: (...o) => `<div style="padding-left:10px;margin-bottom:3px;">${o.join(' ')}</div>`,
section: (s,...o) => `${_h.subhead(s)}${_h.inset(...o)}`,
paragraph: (...o) => `<p>${o.join(' ')}</p>`,
items: (o) => `<li>${o.join('</li><li>')}</li>`,
ol: (...o) => `<ol>${_h.items(o)}</ol>`,
ul: (...o) => `<ul>${_h.items(o)}</ul>`,
grid: (...o) => `<div style="padding: 12px 0;">${o.join('')}<div style="clear:both;"></div></div>`,
cell: (o) => `<div style="width: 130px; padding: 0 3px; float: left;">${o}</div>`,
inset: (...o) => `<div style="padding-left: 10px;padding-right:20px">${o.join(' ')}</div>`,
pre: (...o) =>`<div style="border:1px solid #e1e1e8;border-radius:4px;padding:8.5px;margin-bottom:9px;font-size:12px;white-space:normal;word-break:normal;word-wrap:normal;background-color:#f7f7f9;font-family:monospace;overflow:auto;">${o.join(' ')}</div>`,
preformatted: (...o) =>_h.pre(o.join('<br>').replace(/\s/g,ch(' '))),
code: (...o) => `<code>${o.join(' ')}</code>`,
attr: {
bare: (o)=>`${ch('@')}${ch('{')}${o}${ch('}')}`,
selected: (o)=>`${ch('@')}${ch('{')}selected${ch('|')}${o}${ch('}')}`,
target: (o)=>`${ch('@')}${ch('{')}target${ch('|')}${o}${ch('}')}`,
char: (o,c)=>`${ch('@')}${ch('{')}${c||'CHARACTER NAME'}${ch('|')}${o}${ch('}')}`
},
bold: (...o) => `<b>${o.join(' ')}</b>`,
italic: (...o) => `<i>${o.join(' ')}</i>`,
font: {
command: (...o)=>`<b><span style="font-family:serif;">${o.join(' ')}</span></b>`
}
};
const showHelp = (who) =>{
let msg = _h.outer(
_h.title('SummonCard',version),
_h.header(
_h.paragraph('SummonCard allows dealing cards from a deck to the table easily, particularly to predefined patterns.')
),
_h.subhead('Commands'),
_h.inset(
_h.font.command(
`!summon-card`,
`--help`
),
_h.paragraph('Show this help.')
),
_h.inset(
_h.font.command(
`!summon-card`,
`--deck ${_h.required('deck name fragment')}`,
`--card ${_h.required('card name fragment')}`,
`...`
),
_h.paragraph('This command lets you summon a card from a deck.'),
_h.ul(
`${_h.bold(`--deck ${_h.required('deck name fragment')}`)} -- Choose the deck to summon a card from. ${_h.bold('deck name fragment')} can be any subset of a deck's name, ignoring case and punctuation.`,
`${_h.bold(`--card ${_h.required('card name fragment')}`)} -- Specify which card to summon. ${_h.bold('card name fragment')} follows the same rules as for decks above. ${_h.bold('--card')} must always follow a ${_h.bold('--deck')}. You can specify as many ${_h.bold('--card')} arguments as you like to summon more than one card from the deck.`
),
_h.inset(
_h.preformatted(
`!summon-card --deck Monsters --card beholder`
)
),
_h.paragraph(`${_h.bold('Note:')} You can keep adding additional ${_h.bold('--deck')} and ${_h.bold('--card')} arguments to summon multiple cards from multiple decks.`),
_h.inset(
_h.preformatted(
`!summon-card --deck Monsters --card beholder --card dragon --deck Loot --card Chest`
)
),
_h.paragraph(`${_h.bold('Note:')} You can create multi-line commands by enclosing the arguments after ${_h.code('!summon-card')} in ${_h.code('{{')} and ${_h.code('}}')}.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Monsters`,
` --card beholder`,
` --card dragon`,
` --deck Loot`,
` --card Chest`,
`}}`
)
),
_h.paragraph(`${_h.bold('Note:')} You can use inline rolls as part of your command`),
_h.inset(
_h.preformatted(
`!summon-card --deck Monsters --card beholder --card dragon --deck Loot --card Chest ${ch('[')}${ch('[')}1d3${ch(']')}${ch(']')}`
)
),
_h.paragraph(`All cards that match a given ${_h.bold('card name fragment')} will be summoned. The following would summon all of ${_h.bold('Dragon')}, ${_h.bold('Dragon Turtle')}, and ${_h.bold('Dracolich')}:`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Monsters`,
` --card dra`,
`}}`
)
),
_h.section('Card Options',
_h.paragraph(`${_h.bold('--card')} can have several suffixes attached to it, separated by the ${_h.code('|')} character. Each suffix can have zero or more arguments separated by the ${_h.code(':')} character. There cannot be any spaces within the suffixes and arguments.`),
_h.paragraph(`${_h.bold(`loc:${_h.required('label')}${_h.optional(':fit')}`)} -- Specifies a location where a card should be summoned. A location is a token on the same page with a name beginning with ${_h.code('card:')}. The name should have no spaces in it. Anything following ${_h.code('card:')} is the label for that location. The graphic will be used to set the summon location and rotation. You can futher append ${_h.bold(':fit')} to the label in order to size the card to the same size as the location graphic.`),
_h.paragraph(`Here the Beholder is summoned to location 3 (a graphic with the name ${_h.code('card:3')}), while a chest is summoned to the location treasure (a graphic with the name ${_h.code('card:treasure')}) and scaled to the size of the location graphic.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Monsters`,
` --card|loc:3 beholder`,
` --deck Loot`,
` --card|loc:treasure:fit Chest`,
`}}`
)
),
_h.paragraph(`Number based locations can be combined with inline rolls for random placement.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Monsters`,
` --card|loc:${ch('[')}${ch('[')}1d6${ch(']')}${ch(']')} beholder`,
`}}`
)
),
_h.paragraph(`${_h.bold(`show:${_h.required('face','back')}`)} -- Specifies whether the face or back of the card should be shown.`),
_h.paragraph(`${_h.bold('Note:')} Because of a bug with the API, this does not work for Marketplace cards.`),
_h.paragraph(`Here the Beholder is summoned and showing the card back, while a chest is summoned and shows the card face.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Monsters`,
` --card|back beholder`,
` --deck Loot`,
` --card|face Chest`,
`}}`
)
),
_h.paragraph(`${_h.bold(`exact`)} -- Forces a second match against the specified name, to prevent a partial match on other similar cards that contain this name as a subset.`),
_h.paragraph(`Here the ${_h.code('Rogue 1')} card would have summoned ${_h.code('Rogue 10')}, ${_h.code('Rogue 11')}, etc. as well.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Monsters`,
` --card|exact Rogue 1`,
` --card Rogue 10`,
`}}`
)
),
_h.paragraph(`${_h.bold(`num:${_h.required('number')}`)} -- Summon a number of duplicate cards.`),
_h.paragraph(`Here 10 Wound Markers are placed in a stack.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Markers`,
` --card|num:10 Wound`,
`}}`
)
),
_h.paragraph(`All of these suffixes can be applied togther.`),
_h.paragraph(`Here 10 Wound Markers are placed in a stack.`),
_h.inset(
_h.preformatted(
`!summon-card {{`,
` --deck Markers`,
` --card|loc:pool|num:10|exact|show:face Wound`,
`}}`
)
)
)
)
);
sendChat('',`/w "${who}" ${msg}`);
};
const keyFormat = (s)=>s.toLowerCase().replace(/[^a-z0-9]/g,'');
const lookupDecks = (()=>{
let decks = findObjs({
type: 'deck'
}).reduce( (m,d) => (m[d.id]=d) && m, {});
let lookup = Object.keys(decks).reduce( (m,k) => (m[keyFormat(decks[k].get('name'))]=k) && m, {});
on('add:deck',(d)=>{
decks[d.id]=d;
lookup[keyFormat(d.get('name'))]=d.id;
});
on('change:deck',(d,p)=>{
if(d.get('name') !== p.name){
delete lookup[keyFormat(p.name)];
lookup[keyFormat(d.get('name'))]=d.id;
}
});
on('destroy:deck',(d)=>{
delete decks[d.id];
delete lookup[keyFormat(d.get('name'))];
});
return (nameFragment) => {
let key = keyFormat(nameFragment);
return Object.keys(lookup).filter( k => -1 !== k.indexOf(key)).map(k => decks[lookup[k]]);
};
})();
const lookupCards = (()=>{
let cards = findObjs({
type: 'card'
}).reduce( (m,c) => {
let did = c.get('deckid');
m[did] = m[did]||{};
m[did][c.id]=c;
return m;
},{});
let lookup = Object.keys(cards).reduce( (memo, did) => {
memo[did]=Object.keys(cards[did]).reduce( (m,k) => (m[keyFormat(cards[did][k].get('name'))]=k) && m, {});
return memo;
},{});
on('add:card',(c)=>{
if('card'===c.get('type')){
let did = c.get('deckid');
cards[did] = cards[did]||{};
cards[did][c.id]=c;
lookup[did]=lookup[did]||{};
lookup[did][keyFormat(c.get('name'))]=c.id;
}
});
on('change:card',(c,p)=>{
if('card'===c.get('type') && c.get('name') !== c.name){
let did = c.get('deckid');
delete lookup[did][keyFormat(p.name)];
lookup[did][keyFormat(c.get('name'))]=c.id;
}
});
on('destroy:card',(c)=>{
if('card'===c.get('type')){
let did = c.get('deckid');
delete cards[did][c.id];
delete lookup[did][keyFormat(c.get('name'))];
}
});
return (deckid, nameFragment) => {
let key = keyFormat(nameFragment);
return Object.keys(lookup[deckid]).filter( k => -1 !== k.indexOf(key)).map(k => cards[deckid][lookup[deckid][k]]);
};
})();
const getPageForPlayer = (playerid) => {
let player = getObj('player',playerid);
if(playerIsGM(playerid)){
return player.get('lastpage');
}
let psp = Campaign().get('playerspecificpages');
if(psp[playerid]){
return psp[playerid];
}
return Campaign().get('playerpageid');
};
const range = (n) => [...Array(n).keys()];
const sendError = (who, msg) => sendChat('',`/w "${who}" ${f.msg(msg)}`);
const getLocations = (pageid) => findObjs({type:'graphic', pageid})
.filter(g=>/^card:/i.test(g.get('name')))
.reduce( (m,l) => (m[l.get('name').replace(/^card:/i,'')]=l) && m, {});
const processInlinerolls = (msg) => {
if(_.has(msg,'inlinerolls')){
return _.chain(msg.inlinerolls)
.reduce(function(m,v,k){
let ti=_.reduce(v.results.rolls,function(m2,v2){
if(_.has(v2,'table')){
m2.push(_.reduce(v2.results,function(m3,v3){
m3.push(v3.tableItem.name);
return m3;
},[]).join(', '));
}
return m2;
},[]).join(', ');
m['$[['+k+']]']= (ti.length && ti) || v.results.total || 0;
return m;
},{})
.reduce(function(m,v,k){
return m.replace(k,v);
},msg.content)
.value();
} else {
return msg.content;
}
};
const fixedPlayCardToTable = (cardid, options) => {
let card = getObj('card',cardid);
if(card){
let deck = getObj('deck',card.get('deckid'));
if(deck){
if(!isCleanImgsrc(deck.get('avatar')) && !isCleanImgsrc(card.get('avatar'))){
// marketplace-marketplace:
playCardToTable(cardid, options);
} else if (isCleanImgsrc(deck.get('avatar')) && isCleanImgsrc(card.get('avatar'))){
let pageid = options.pageid || Campaign().get('playerpageid');
let page = getObj('page',pageid);
if(page){
let imgs=[getCleanImgsrc(card.get('avatar')),getCleanImgsrc(deck.get('avatar'))];
let currentSide = options.hasOwnProperty('currentSide')
? options.currentSide
: ('faceup' === deck.get('cardsplayed')
? 0
: 1
);
let width = options.width || parseInt(deck.get('defaultwidth')) || 140;
let height = options.height || parseInt(deck.get('defaultheight')) || 210;
let left = options.left || (parseInt(page.get('width'))*70)/2;
let top = options.top || (parseInt(page.get('height'))*70)/2;
createObj( 'graphic', {
subtype: 'card',
cardid: card.id,
pageid: page.id,
currentSide: currentSide,
imgsrc: imgs[currentSide],
sides: imgs.map(i => encodeURIComponent(i)).join('|'),
left,top,width,height,
layer: 'objects',
isdrawing: true,
controlledby: 'all',
gmnotes: `cardid:${card.id}`
});
} else {
sendError('gm',`Specified pageid does not exists.`);
}
} else {
sendError('gm',`Can't create cards for a deck mixing Marketplace and User Library images.`);
}
} else {
sendError('gm',`Cannot find deck for card ${card.get('name')}`);
}
} else {
sendError('gm',`Cannot find card for id ${cardid}`);
}
};
on('chat:message', msg => {
if( 'api' === msg.type && /^!summon-card\b/i.test(msg.content)){
let who = (getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname');
let pageid = getPageForPlayer(msg.playerid);
let args = processInlinerolls(msg)
.replace(/<br\/>\n/g, ' ')
.replace(/(\{\{(.*?)\}\})/g," $2 ")
.split(/\s+--/);
if(args.find(n=>/^help(\b|$)/i.test(n))){
showHelp(who);
return;
}
let locs = getLocations(pageid);
let currentDeck;
args.forEach( a => {
let params = a.trim().split(/\s+/);
let cmd = params[0].split(/\|/);
switch(cmd[0].toLowerCase()){
case 'deck': {
let dn = params.slice(1).join(' ');
let ds = lookupDecks(dn);
if(1 === ds.length){
currentDeck = ds[0];
} else if(ds.length>1){
let min = ds.filter(d=>d.get('name').trim().toLowerCase()===dn.trim().toLowerCase());
if(1===min.length){
currentDeck = min[0];
} else {
sendError(who,`Too many decks matching ${f.key(dn)}: ${f.decks(ds.map(d=>d.get('name')))}`);
currentDeck = undefined;
}
} else {
sendError(who,`No decks matching ${f.key(dn)}`);
currentDeck = undefined;
}
}
break;
case 'card': {
let cn = params.slice(1).join(' ');
if(currentDeck){
let cs = lookupCards(currentDeck.id,cn);
if(cs.length){
let opts = () => ({pageid});
let mutator = () => {};
let place = (card) => {
let o = opts();
if(o.hasOwnProperty('currentSide') && ! isCleanImgsrc(card.get('avatar'))) {
sendError(who,`Can't adjust side on Marketplace Decks currently. Change the <b>Played Facing</b> setting for the deck.`);
delete o.currentSide;
}
fixedPlayCardToTable(card.id,o);
setTimeout(()=>{
//findObjs(Object.assign({},o,{type:'graphic',subtype:'card',cardid:card.id}))
findObjs(Object.assign({},o,{type:'graphic'}))
.filter(g => ('card'===g.get('subtype') && card.id === g.get('cardid')) || (g.get('gmnotes').includes(`cardid:${card.id}`)))
.forEach(g=>mutator(g));
},100);
};
cmd.slice(1).forEach(c=>{
let parts = c.split(/:/);
switch(parts[0]){
case 'num': {
place = (()=>{
let cnt = parseInt(parts[1])||1;
let oldPlace = place;
return (card) => range(cnt).forEach(()=>oldPlace(card));
})();
}
break;
case 'exact': {
place = (()=>{
let oldPlace = place;
return (card) => {
if(card.get('name').trim().toLowerCase() === cn.trim().toLowerCase()){
oldPlace(card);
}
};
})();
}
break;
case 'loc':
if(locs.hasOwnProperty(parts[1])){
let sizeOpts = {};
if('fit'===parts[2]){
let loc = locs[parts[1]];
sizeOpts = {
width: loc.get('width'),
height: loc.get('height')
};
}
opts = (()=>{
let loc = locs[parts[1]];
let oldOpts = opts;
return (o) =>Object.assign(
{},
sizeOpts,
{
left: loc.get('left'),
top: loc.get('top')
}, (oldOpts(o) || {})
);
})();
mutator = (()=>{
let loc = locs[parts[1]];
let oldMutator = mutator;
return (g) => {
oldMutator(g);
g.set({
rotation: loc.get('rotation')
});
};
})();
}
break;
case 'show':
if(['face','back'].includes(parts[1].toLowerCase())){
opts = (()=>{
let side=('face'===parts[1].toLowerCase() ? 0 : 1);
let oldOpts = opts;
return (o) =>{
let oo = oldOpts(o) || {};
oo.currentSide = side;
return oo;
};
})();
}
break;
}
});
cs.forEach(c=>place(c));
}
} else {
sendError(who,`No deck set to find card ${f.key(cn)}. Use ${f.arg('--deck [name]')} to specify a deck first.`);
}
}
break;
}
});
}
});
});
Update v0.1.1 -- Wrote my own playCardToTable() which can create pseudo cards for User Library image decks. It can't create card objects, merely multisided tokens that behave like cards. There are some differences, but it should work for most use cases until the real playCardToTable() function is fixed.