formatting.js

  1. /**
  2. * Provides a number of functions for formatting text, cards and decks on Slack using the Slack API and Markdown.
  3. * @module formating
  4. * @author Dominic Shelton.
  5. */
  6. 'use strict';
  7. /**
  8. * @var {object} colours
  9. * The faction colours are loaded from a JSON file.
  10. */
  11. var colours = require('./colours.json');
  12. /**
  13. * @var {string[]|string[][]} packs
  14. * The pack and cycle names are loaded from a JSON file.
  15. */
  16. var packs = require('./datapacks.json');
  17. /**
  18. * @var {module} alliances
  19. * The {@link alliances} module is required for calculating the decklist influence total.
  20. */
  21. var alliances = require('./alliances.js');
  22. /**
  23. * @var {string} thumbsURL
  24. * The URL where the card thumbnails are hosted, loaded from the environment variable THUMBS_URL
  25. */
  26. var thumbsURL = process.env.THUMBS_URL;
  27. /**
  28. * @var {object} messages
  29. * Various text messages conveniently combined into one object.
  30. */
  31. var messages = {
  32. noCardHits: [
  33. "The run was successful but you didn't access _\u200b[cards]\u200b_.",
  34. "I was unable to find _\u200b[cards]\u200b_ in any of my remotes.",
  35. "Despite 822 Medium counters, _\u200b[cards]\u200b_ wasn't found.",
  36. "The Near-Earth Hub couldn't locate _\u200b[cards]\u200b_."
  37. ],
  38. noDeckHits: [
  39. "The archetype of that deck would be _\u200bnon-existant\u200b_."
  40. ],
  41. helpBrackets:
  42. "Search for a card by (partial) name, or acronym, or a decklist by its netrunnerdb link e.g.\`\`\`[sneakdoor] [hiemdal] [etf]\n[netrunnerdb\u200b.com/en/decklist/17055/example]\`\`\`",
  43. helpDeck:
  44. "Search for a decklist by its netrunnerdb link or ID number e.g.\`\`\`[command] 12345, [command] netrunnerdb\u200b.com/en/decklist/17055/example\`\`\`",
  45. helpCard:
  46. "Search for a card by (partial) name, approximation or acronym e.g.\`\`\`[command]sneakdoor, [command]hiemdal, [command]etf\`\`\`"
  47. };
  48. /**
  49. * @var {string[][]} headings
  50. * Headings used in Decklists, in the order they appear in the decklist output.
  51. */
  52. var headings = [
  53. ['Event', 'Hardware', 'Resource', 'Agenda', 'Asset', 'Upgrade', 'Operation'],
  54. ['Icebreaker', 'Program', 'Barrier', 'Code Gate', 'Sentry', 'Multi', 'Other']
  55. ];
  56. /**
  57. * @var {string[][]} stats
  58. * An array of pairs of card stats and corresponding emoji, in the order they should appear in card descriptions.
  59. */
  60. var stats = [
  61. ['baselink', ' :_link:'],
  62. ['cost', ':_credit:'],
  63. ['memoryunits', ' :_mu:'],
  64. ['strength', ' str'],
  65. ['trash', ' :_trash:'],
  66. ['advancementcost', ' :_advance:'],
  67. ['minimumdecksize', ' :_deck:'],
  68. ['influencelimit', '•'],
  69. ['agendapoints', ' :_agenda:']
  70. ];
  71. /**
  72. * @func cardHelpMessage
  73. * @param command {string} The Slack command that was used to invoke the help request, used in the examples returned. Can be left blank to invoke the fallback brackets help text.
  74. * @return {object} A Slack API message object of a help response message.
  75. */
  76. module.exports.cardHelpMessage = cardHelpMessage;
  77. function cardHelpMessage(command) {
  78. if (command) {
  79. return {
  80. text: messages.helpCard.replace(/\[command\]/g, command + ' ')
  81. };
  82. }
  83. return {
  84. text: messages.helpBrackets
  85. };
  86. };
  87. /**
  88. * @func deckHelpMessage
  89. * @param command {string} The Slack command that was used to invoke the help request, used in the examples returned.
  90. * @return {object} A Slack API message object of a help response message.
  91. */
  92. module.exports.deckHelpMessage = deckHelpMessage;
  93. function deckHelpMessage(command) {
  94. return {
  95. text: messages.helpDeck.replace(/\[command\]/g, command)
  96. };
  97. };
  98. /**
  99. * Generates a message indicating that cards weren't found, by concatenating the names with commas and 'or'
  100. * @func cardNoHitsMessage
  101. * @param cards {string[]} An array of card titles that weren't found.
  102. * @return {string} A randomized Slack API message object stating that the given cards weren't found.
  103. */
  104. module.exports.cardNoHitsMessage = cardNoHitsMessage;
  105. function cardNoHitsMessage(cards) {
  106. var text;
  107. if (cards.length >= 2) {
  108. text = cards.slice(0, cards.length - 1).join(', ');
  109. text += ' or ' + cards[cards.length - 1];
  110. } else {
  111. text = cards[0];
  112. }
  113. var message = getCardNoHitsMessage(text);
  114. return {
  115. text: message
  116. };
  117. };
  118. /**
  119. * Generates a message indicating that a deck wasn't found.
  120. * @func deckNoHitsMessage
  121. * @return {string} A randomized Slack API message object stating that the deck wasn't found.
  122. */
  123. module.exports.deckNoHitsMessage = deckNoHitsMessage;
  124. function deckNoHitsMessage() {
  125. var r = Math.floor(Math.random() * messages.noDeckHits.length);
  126. return {
  127. text: messages.noDeckHits[r]
  128. };
  129. };
  130. /**
  131. * Applies Slack markdown formatting to the given title to bolden and optionally apply the link.
  132. * @func formatTitle
  133. * @param title {string} The text to display as the title.
  134. * @param [url] {string} The URL to link the title to.
  135. * @return {string} A string containing the title with Slack markdown formatting applied.
  136. */
  137. module.exports.formatTitle = formatTitle;
  138. function formatTitle(title, url) {
  139. title = '*\u200b' + title + '\u200b*';
  140. if (url && url !== '') {
  141. return formatLink(title, url);
  142. }
  143. return title;
  144. };
  145. /**
  146. * @func formatDecklist
  147. * @param decklist {object} The decklist to be converted into a Slack message.
  148. * @return {object} A Slack API message object containing the decklist or an error message.
  149. */
  150. module.exports.formatDecklist = (decklist) => {
  151. // Initialise the return object.
  152. var o = {text: '', attachments:[{mrkdwn_in: ['pretext', 'fields']}]};
  153. var faction = decklist.cards.Identity[0].card.faction;
  154. var usedInfluence = 0;
  155. var mwlDeduction = 0;
  156. var decksize = 0;
  157. var agendapoints = 0;
  158. var fields = [];
  159. // Initialise the newestCard var to a card that is guaranteed to be in every deck.
  160. var newestCard = parseInt(decklist.cards.Identity[0].card.code);
  161. o.text = formatTitle(decklist.name, decklist.url);
  162. o.text += ' - _\u200b' + decklist.creator + '\u200b_';
  163. for (let column in headings) {
  164. // Create the columns as Slack 'fields'.
  165. fields[column] = {title: '', value: '', short: true};
  166. for (let heading in headings[column]) {
  167. var type = headings[column][heading];
  168. // Check if the deck actually contains cards of the specified heading
  169. if (decklist.cards[type]) {
  170. var typeTotal = 0;
  171. var text = '';
  172. // Iterate through all the cards of the type in the decklist.
  173. for (let i in decklist.cards[type]) {
  174. var card = decklist.cards[type][i];
  175. var code = parseInt(card.card.code);
  176. typeTotal += card.quantity;
  177. text += '\n' + card.quantity;
  178. text += ' × ' + formatLink(card.card.title, card.card.url);
  179. decksize += card.quantity;
  180. // Check that the card is not newer than the previous newest card.
  181. if (code > newestCard) {
  182. newestCard = code;
  183. }
  184. if (card.card.agendapoints) {
  185. agendapoints += card.card.agendapoints * card.quantity;
  186. }
  187. // Add MWL star if required.
  188. if (card.card.mwl)
  189. {
  190. var mwl = card.quantity * card.card.mwl;
  191. text += ' ' + '☆'.repeat(mwl);
  192. mwlDeduction += mwl;
  193. }
  194. // Add influence dots after the card name if required.
  195. if (card.card.faction !== faction) {
  196. var inf = card.quantity * card.card.factioncost;
  197. if (alliances[card.card.code]) {
  198. inf *= alliances[card.card.code](decklist);
  199. }
  200. text += ' ' + influenceDots(inf);
  201. usedInfluence += inf;
  202. }
  203. }
  204. // If this is not the first heading, add padding after the previous.
  205. if (heading != 0) {
  206. fields[column].value += '\n\n';
  207. }
  208. fields[column].value += formatTitle(type) + ' (' + typeTotal + ')';
  209. fields[column].value += text;
  210. }
  211. }
  212. }
  213. o.attachments[0].color = colours[faction];
  214. o.attachments[0].fields = fields;
  215. // The identity of the decklist is displayed before the decklist.
  216. o.attachments[0].pretext = formatLink(decklist.cards.Identity[0].card.title,
  217. decklist.cards.Identity[0].card.url);
  218. o.attachments[0].pretext += '\n' + decksize + ' :_deck: (min ';
  219. o.attachments[0].pretext += decklist.cards.Identity[0].card.minimumdecksize;
  220. o.attachments[0].pretext += ') - ' + usedInfluence + '/';
  221. if (decklist.cards.Identity[0].card.influencelimit) {
  222. o.attachments[0].pretext += decklist.cards.Identity[0].card.influencelimit - mwlDeduction + '•';
  223. if (mwlDeduction > 0) {
  224. o.attachments[0].pretext += '(' + decklist.cards.Identity[0].card.influencelimit + '-';
  225. o.attachments[0].pretext += mwlDeduction + '☆)';
  226. }
  227. } else {
  228. o.attachments[0].pretext += '∞•';
  229. }
  230. if (decklist.cards.Identity[0].card.side !== 'Runner') {
  231. o.attachments[0].pretext += ' - ' + agendapoints + ' :_agenda:';
  232. }
  233. o.attachments[0].pretext += '\nCards up to ' + getPack(newestCard);
  234. return o;
  235. };
  236. /**
  237. * @func formatCards
  238. * @param [cards] {object[]} The cards to be converted into a Slack message.
  239. * @param [cards] {object[]} The cards that weren't found and should be mentioned in an error message.
  240. * @return {object} A Slack API Message object either containing the cards as attachments or containing an error message that the cards couldn't be found, or both.
  241. */
  242. module.exports.formatCards = (cards, missing) => {
  243. var o;
  244. // If there are cards that could not be found, tell the user.
  245. if (missing && missing.length > 0) {
  246. o = cardNoHitsMessage(missing);
  247. o.attachments = [];
  248. } else {
  249. o = {text:'', attachments:[]};
  250. }
  251. // Display the cards that were found either way.
  252. for (var i = 0; i < cards.length; i++) {
  253. var a = {pretext: '', mrkdwn_in: ['pretext', 'text']};
  254. var faction = cards[i].faction;
  255. var title = cards[i].title;
  256. if (cards[i].uniqueness){
  257. title = '◆ ' + title;
  258. }
  259. // If the Slack message is blank, put the title of the first card there.
  260. // The Slack API won't display messages with blank text even when there are attachments.
  261. if (o.text === '') {
  262. o.text = formatTitle(title, cards[0].url);
  263. } else {
  264. a.pretext = formatTitle(title, cards[i].url) + '\n';
  265. }
  266. // Append the rest of the card details to the attachment.
  267. a.pretext += '*\u200b' + cards[i].type;
  268. if (cards[i].subtype ) {
  269. a.pretext += ':\u200b* ' + cards[i].subtype;
  270. } else {
  271. a.pretext += '\u200b*';
  272. }
  273. a.pretext += ' - ' + getFactionEmoji(faction);
  274. if (cards[i].factioncost) {
  275. a.pretext += influenceDots(cards[i].factioncost);
  276. }
  277. if (cards[i].mwl) {
  278. a.pretext += '☆';
  279. }
  280. a.pretext += '\n';
  281. var first = true;
  282. // For some cards, the cost is actually a rez cost, this should be reflected in the emoji.
  283. if (cards[i].type === 'Asset' || cards[i].type === 'Upgrade' || cards[i].type === 'ICE') {
  284. stats[1][1] = ' :_rez:';
  285. } else {
  286. stats[1][1] = ':_credit:';
  287. }
  288. // Iterate through the possible card stats adding them to the text where present.
  289. for (var j = 0; j < stats.length; j++) {
  290. if (cards[i][stats[j][0]] || cards[i][stats[j][0]] === 0) {
  291. if (!first) {
  292. a.pretext += ' - ';
  293. }
  294. a.pretext += cards[i][stats[j][0]] + stats[j][1];
  295. first = false;
  296. // Special case for draft IDs with infinite influence limit
  297. } else if (cards[i].type === 'Identity' && stats[j][0] === 'influencelimit' && !cards[i].influencelimit) {
  298. a.pretext += ' - ∞•';
  299. }
  300. }
  301. // Replace memory units with the slack emoji
  302. a.pretext = a.pretext.replace(/(\d|X)\s*:_mu:/gi, function (x) {
  303. return x.replace(/(.).*/, ':_$1mu:').toLowerCase();
  304. });
  305. a.pretext += ' - ' + getPack(parseInt(cards[i].code));
  306. a.color = colours[faction];
  307. // Add the card text to the attachment if present.
  308. if (cards[i].text) {
  309. a.text = formatText(cards[i].text);
  310. }
  311. a.thumb_url = thumbsURL + cards[i].code + '.png';
  312. o.attachments.push(a);
  313. }
  314. return o;
  315. };
  316. /**
  317. * Replace all the html and nrdb text with the Slack equivalent.
  318. * @func formatText
  319. * @param text {string} The body of text to convert to Slack formatting.
  320. * @return {string} The text, formatted in Slack markdown syntax.
  321. */
  322. module.exports.formatText = formatText;
  323. function formatText(text) {
  324. if (!text) return text;
  325. text = text.replace(/\r\n/g, '\n');
  326. // NRDB symbols to Slack emoji.
  327. text = text.replace(/\[Credits\]/g, ':_credit:');
  328. text = text.replace(/\[Recurring\ Credits\]/g, ':_recurringcredit:');
  329. text = text.replace(/\[Click\]/g, ':_click:');
  330. text = text.replace(/\ *\[Link\]/g, ' :_link:');
  331. text = text.replace(/\[Trash\]/g, ':_trash:');
  332. text = text.replace(/\[Subroutine\]/g, ':_subroutine:');
  333. // Individual mu emoji for numbers and 'Xmu'
  334. text = text.replace(/(\d|X)\s*\[Memory\ Unit\]/gi, function (x) {
  335. return x.replace(/(.).*/, ':_$1mu:').toLowerCase();
  336. });
  337. text = text.replace(/\[Memory Unit\]/g, ':_mu:');
  338. // HTML bold to Slack bold
  339. text = text.replace(/<strong>/g, '*\u200b');
  340. text = text.replace(/<\/strong>/g, '\u200b*');
  341. // HTML superscript to unicode superscript since Slack markdown doesn't support it.
  342. text = text.replace(/<sup>(?:\d|X)+<\/sup>/gi, function(x){
  343. x = x.replace(/<sup>|<\/sup>/g, '');
  344. x = x.replace(/X/i,'ˣ');
  345. x = x.replace(/\d/g, function(d){
  346. return ['⁰','¹','²','³','⁴','⁵','⁶','⁷','⁸','⁹'][parseInt(d)];
  347. });
  348. return x;
  349. });
  350. // Replace nrdb faction symbols with Slack emoji
  351. text = text.replace(/\[(jinteki|weyland-consortium|nbn|haas-bioroid)\]/, (a, x) => {
  352. return ":_" + getFactionEmoji(x) + ":";
  353. });
  354. text = text.replace(/&/g, '&amp;');
  355. text = text.replace(/</g, '&lt;');
  356. text = text.replace(/>/g, '&gt;');
  357. return text;
  358. };
  359. function influenceDots(influence) {
  360. return '•'.repeat(influence);
  361. }
  362. function getPack(code) {
  363. var cycle = packs[Math.floor(code/1000)];
  364. if (Array.isArray(cycle)) {
  365. cycle = cycle[Math.floor(((code % 1000) - 1) / 20)];
  366. }
  367. return '_\u200b' + cycle + '\u200b_';
  368. }
  369. function formatLink(text, url) {
  370. return '<' + url + '|' + text + '>';
  371. }
  372. function getFactionEmoji(faction) {
  373. return ':_' + faction.replace(/\s.*/, '').toLowerCase() + ':';
  374. }
  375. function getCardNoHitsMessage(text) {
  376. var r = Math.floor(Math.random() * messages.noCardHits.length);
  377. return messages.noCardHits[r].replace(/\[cards\]/g, text);
  378. }