· 5 years ago · Jul 24, 2020, 11:24 PM
1const splitCommand: (cmd: string) => string[] = require("shlex").split;
2
3interface CommandArguments {
4 command: string;
5 kw: { [key: string]: any };
6 args: string[];
7}
8
9function parseCommand(cmd: string) {
10 const lexed = splitCommand(cmd);
11 let result: CommandArguments = {
12 command: lexed[0],
13 kw: {},
14 args: [],
15 };
16 for (let i = 1; i < lexed.length; ++i) {
17 let match = lexed[i].match(/^--([^=]+)=?(.*)?/);
18 if (match) {
19 result.kw[match[1]] = match[2] || true;
20 } else {
21 result.args.push(lexed[i]);
22 }
23 }
24 return result;
25}
26
27function getArg<T>(
28 parsedOptions: CommandArguments,
29 name: string,
30 defaultValue: T
31) {
32 let value: T = parsedOptions.kw[name];
33 if (value === undefined || value === null) {
34 return defaultValue;
35 }
36 return value;
37}
38
39type NPCActionMap = { [key: string]: { [key: string]: string } };
40function enumerateNPCActions(
41 actions: NPCActionMap,
42 cb: (id: string, key: string, value: string) => void
43) {
44 Object.keys(actions).forEach((id) => {
45 const keys = Object.keys(actions[id]);
46 keys.forEach((key) => {
47 const value = actions[id][key];
48 cb(id, key, value);
49 });
50 });
51}
52
53function replaceAll(x: string, pattern: any, replacement: any) {
54 let nx = "";
55 while (true) {
56 let match = x.match(pattern);
57 if (match === null || match.index === undefined) {
58 nx += x;
59 return nx;
60 }
61 nx += x.slice(0, match.index);
62 nx += replacement(match);
63 x = x.slice(match.index + match[0].length);
64 }
65}
66
67function makeCrit(roll: string) {
68 const pattern = /(\d+)d(\d+)/i;
69 return replaceAll(roll, pattern, (match: RegExpMatchArray) => {
70 const lhs = match[1];
71 const rhs = match[2];
72 return `${(lhs as any) * 2}d${rhs}`;
73 });
74}
75
76function makeStyle(props: { [key: string]: string }) {
77 return Object.keys(props)
78 .map((key) => {
79 return `${key}: ${props[key]}`;
80 })
81 .join("; ");
82}
83
84const tableStyle = makeStyle({
85 border: "1px solid black",
86 padding: "5px",
87});
88
89function htmlTag(
90 tag: string,
91 contents: string,
92 options?: { [key: string]: string }
93) {
94 let mappedOptions = "";
95 if (options && Object.keys(options).length > 0) {
96 mappedOptions =
97 " " +
98 Object.keys(options)
99 .map((key) => {
100 return `${key}="${options[key]}"`;
101 })
102 .join(" ");
103 }
104 return `<${tag}${mappedOptions}>${contents}</${tag}>`;
105}
106
107function htmlTable(headers: string[], rows: string[][]) {
108 const mappedHeaders = headers.map((x) =>
109 htmlTag("th", x, { style: tableStyle })
110 );
111 const mappedRows = rows.map((row) =>
112 row.map((x) => htmlTag("td", x, { style: tableStyle }))
113 );
114 const headerTag = htmlTag("tr", mappedHeaders.join(""), {
115 style: tableStyle,
116 });
117 const rowTags = mappedRows.map((row) =>
118 htmlTag("tr", row.join(""), { style: tableStyle })
119 );
120 return htmlTag("table", headerTag + rowTags, { style: tableStyle });
121}
122
123interface CommandResult {
124 error?: string;
125}
126
127async function rollDice(roll: string): Promise<number> {
128 return new Promise<number>((resolve, reject) => {
129 const player = `player|${lastPlayerId}`;
130 sendChat(player, `/gmroll ${roll}`, (result) => {
131 let payload = JSON.parse(result[0].content);
132 resolve(payload.total);
133 });
134 });
135}
136
137async function multiRollDice(rolls: string[]): Promise<number[]> {
138 return Promise.all(rolls.map((x) => rollDice(x)));
139}
140
141function diceText(roll: number, value: number) {
142 let attrs = {};
143 if (roll === 1) {
144 attrs = { style: makeStyle({ color: "#A00" }) };
145 } else if (roll === 20) {
146 attrs = { style: makeStyle({ color: "#0A0" }) };
147 }
148 return htmlTag("span", value + "", attrs);
149}
150
151const plural = (value: number, singular: string, plural: string) =>
152 value + " " + (value === 1 ? singular : plural);
153
154async function groupAttack(
155 message: ApiChatEventData,
156 args: CommandArguments
157): Promise<CommandResult> {
158 if (!message.selected) {
159 return { error: "Requires selected token." };
160 }
161
162 const token = getObj("graphic", message.selected[0]._id);
163 if (!token) {
164 return { error: "Requires selected token." };
165 }
166
167 const character = getObj("character", token.get("represents"));
168 if (!character) {
169 return { error: "Selected token must be a character." };
170 }
171
172 let attrs = findObjs({ _characterid: character.id }) as Attribute[];
173 let action: string | undefined =
174 args.args.length > 0 ? (args.args[0] as string).toLowerCase() : undefined;
175 if (action === undefined) {
176 attrs.forEach((attr) => {
177 if (attr.get("name") === "npcactionlist") {
178 action = attr.get("current").split("|")[0];
179 }
180 });
181 }
182
183 let actions: NPCActionMap = {};
184 const pattern = /repeating_npcaction_([^_]+)_(.*)/;
185 attrs.forEach((attr) => {
186 const name: string = attr.get("name");
187 const match = name.match(pattern);
188 if (match) {
189 const attrId = match[1];
190 const subAttr = match[2];
191 actions[attrId] = actions[attrId] || {};
192 actions[attrId][subAttr] = attr.get("current");
193 }
194 });
195
196 let id: string | undefined = undefined;
197 enumerateNPCActions(actions, (actionID, key, value) => {
198 if (
199 key === "name" &&
200 (action === undefined || value.toLowerCase().indexOf(action) !== -1)
201 ) {
202 id = actionID;
203 }
204 });
205
206 if (id === undefined) {
207 return { error: "Couldn't find an action" };
208 }
209
210 const damage = actions[id]["attack_damage"];
211 const toHit = parseInt(actions[id]["attack_tohit"]);
212 const attackName = actions[id]["name"];
213
214 const headers = ["To-Hit", "Damage"];
215 let rows: string[][] = [];
216
217 let ac = getArg(args, "ac", 0);
218
219 const rowCount = getArg(args, "count", message.selected.length);
220 const hitRollDice = getArg(args, "roll", "1d20");
221 let hitRollPool: string[] = [];
222 for (let i = 0; i < rowCount; ++i) {
223 hitRollPool.push(hitRollDice);
224 }
225 let hitRolls = await multiRollDice(hitRollPool);
226 let damageRollPool: string[] = hitRolls.map((x) =>
227 x === 20 ? makeCrit(damage) : damage
228 );
229 let damageRolls = await multiRollDice(damageRollPool);
230 let totalDamage = 0;
231 let hits = 0;
232 let crits = 0;
233 for (let i = 0; i < rowCount; ++i) {
234 const roll = hitRolls[i];
235 const hit = roll + toHit;
236 let damageText = "Miss";
237 if (roll === 1) {
238 damageText = "Miss";
239 }
240 if (roll !== 1 && (!ac || hit >= ac || roll === 20)) {
241 const damage = damageRolls[i];
242 totalDamage += damage;
243 damageText = damage + "";
244 if (roll === 20) {
245 crits += 1;
246 }
247 hits += 1;
248 }
249
250 rows.push([diceText(roll, hit), damageText]);
251 }
252 const misses = damageRolls.length - hits;
253 hits -= crits;
254 let summary =
255 ac === 0
256 ? ""
257 : `Total damage: ${totalDamage} (${plural(
258 misses,
259 "miss",
260 "misses"
261 )}, ${plural(hits, "hit", "hits")}, ${plural(crits, "crit", "crits")})`;
262
263 const header = `${rowCount}x ${token.get("name")} ${attackName} attacks:`;
264 sendChat(
265 "Roll20MM",
266 `${header}<br>${htmlTable(headers, rows)}<br>${summary}`
267 );
268 return {};
269}
270
271async function groupSave(
272 message: ApiChatEventData,
273 args: CommandArguments
274): Promise<CommandResult> {
275 if (!message.selected) {
276 return { error: "Requires selected token." };
277 }
278
279 const makeSaveTable = (name: string): string[] => {
280 return [
281 `npc_${name.slice(0, 3)}_save`,
282 `${name}_save_bonus`,
283 `${name}_mod`,
284 ];
285 };
286 const saveTables = {
287 str: makeSaveTable("strength"),
288 dex: makeSaveTable("dexterity"),
289 con: makeSaveTable("constitution"),
290 wis: makeSaveTable("wisdom"),
291 int: makeSaveTable("intelligence"),
292 cha: makeSaveTable("charisma"),
293 };
294 const saveText: { [key: string]: string } = {
295 str: "Strength",
296 dex: "Dexterity",
297 con: "Constitution",
298 wis: "Wisdom",
299 int: "Intelligence",
300 cha: "Charisma",
301 };
302 const saves = Object.keys(saveTables);
303 const savesHelperText = `{${saves.join(", ")}}`;
304 if (args.args.length !== 1 || !saves.some((x) => x === args.args[0])) {
305 return { error: `!group-save takes one parameter: ${savesHelperText}` };
306 }
307 const selectedSave = args.args[0];
308 let saveTable: string[] = (saveTables as any)[selectedSave];
309
310 const dc = getArg(args, "dc", 0);
311
312 let tokens: Graphic[] = [];
313 let characters: Character[] = [];
314 const fixedCount = getArg(args, "count", null);
315 if (fixedCount !== null) {
316 if (message.selected.length !== 1) {
317 return { error: "You may only have one selected token with --count" };
318 }
319
320 const token = getObj("graphic", message.selected[0]._id);
321 if (!token) {
322 return { error: "Unable to get token for selection." };
323 }
324 for (let i = 0; i < fixedCount; ++i) {
325 tokens.push(token);
326 }
327 } else {
328 for (let i = 0; i < message.selected.length; ++i) {
329 const token = getObj("graphic", message.selected[i]._id);
330 if (!token) {
331 return { error: "Unable to get token for selection." };
332 }
333 tokens.push(token);
334 }
335 }
336
337 characters = tokens.map(
338 (token) => getObj("character", token.get("represents")) as Character
339 );
340 if (characters.some((x) => x === undefined)) {
341 return { error: "Unable to find character sheet for token" };
342 }
343
344 function lookupSave(
345 character: Character,
346 attributeOptions: string[]
347 ): number | null {
348 for (let i = 0; i < attributeOptions.length; ++i) {
349 let attrs = findObjs({
350 _characterid: character.id,
351 name: attributeOptions[i],
352 }) as Attribute[];
353 if (attrs.length > 0) {
354 const save = parseInt(attrs[0].get("current"));
355 if (!isNaN(save)) {
356 return save;
357 }
358 }
359 }
360 return null;
361 }
362
363 let saveModifiers: { [key: string]: number[] } = {};
364 for (let i = 0; i < characters.length; ++i) {
365 const name = characters[i].get("name");
366 let modifier = lookupSave(characters[i], saveTable);
367 if (modifier === null) {
368 return { error: `Failed to look up save for ${name}` };
369 }
370 if (!(name in saveModifiers)) {
371 saveModifiers[name] = [];
372 }
373 saveModifiers[name].push(modifier);
374 }
375
376 const rollDice = getArg(args, "roll", "1d20");
377 interface RollResult {
378 characterName: string;
379 roll: number;
380 modifier: number;
381 }
382 let saveRolls: RollResult[] = [];
383 await Promise.all(
384 Object.keys(saveModifiers)
385 .sort()
386 .map(async (characterName) => {
387 let modifiers = saveModifiers[characterName];
388 let rolls = await multiRollDice(modifiers.map((x) => rollDice));
389 for (let i = 0; i < modifiers.length; ++i) {
390 saveRolls.push({
391 characterName,
392 roll: rolls[i],
393 modifier: modifiers[i],
394 });
395 }
396 })
397 );
398
399 interface CharacterResult {
400 saves: number;
401 total: number;
402 }
403 let results: { [key: string]: CharacterResult } = {};
404 let tableRows: string[][] = saveRolls.map((rollInfo) => {
405 const total = rollInfo.roll + rollInfo.modifier;
406 results[rollInfo.characterName] = results[rollInfo.characterName] || {
407 saves: 0,
408 total: 0,
409 };
410 results[rollInfo.characterName].total += 1;
411 if (total >= dc) {
412 results[rollInfo.characterName].saves += 1;
413 }
414 return [rollInfo.characterName, diceText(rollInfo.roll, total)];
415 });
416 const table = htmlTable(["Character", "Roll"], tableRows);
417
418 const headline = `Group ${saveText[selectedSave]} saves:`;
419
420 let footer = "";
421 if (dc > 0) {
422 footer = Object.keys(results)
423 .sort()
424 .map((character) => {
425 let result = results[character];
426 if (result.total === 1) {
427 return `${character} ${result.saves === 1 ? "succeeded" : "failed"}.`;
428 } else {
429 return `${result.saves} / ${result.total} ${character} succeeded.`;
430 }
431 })
432 .join("<br>");
433 }
434
435 sendChat("Roll20MM", [headline, table, footer].join("<br>"));
436
437 return {};
438}
439
440const commands: {
441 [key: string]: (
442 message: ApiChatEventData,
443 args: CommandArguments
444 ) => Promise<CommandResult>;
445} = {
446 "!group-attack": groupAttack,
447 "!group-save": groupSave,
448};
449
450let lastPlayerId: string = "";
451
452on("ready", () => {
453 on("chat:message", (message) => {
454 let error: string | null = null;
455 if (message.type !== "api") {
456 return;
457 }
458 const apiMessage = message as ApiChatEventData;
459 if (!apiMessage) {
460 return;
461 }
462
463 if (apiMessage.playerid !== "API") {
464 lastPlayerId = apiMessage.playerid;
465 }
466
467 try {
468 const parsedOptions = parseCommand(apiMessage.content);
469 //log(parsedOptions);
470
471 if (parsedOptions.command in commands) {
472 commands[parsedOptions.command](message, parsedOptions).then(
473 (result) => {
474 if (result.error !== undefined) {
475 error = result.error;
476 }
477 }
478 );
479 }
480 } catch (err) {
481 error = err.message;
482 } finally {
483 let playerId = apiMessage.playerid;
484 if (playerId === "API") {
485 playerId = lastPlayerId;
486 }
487 const playerObj = getObj("player", playerId);
488 const player = playerObj && playerObj.get("_displayname");
489 if (error !== null && player) {
490 sendChat("Roll20MM", `/w "${player}" ${error}`);
491 }
492 }
493 });
494})