From 091bea3aed8252eb7b65bc8c20a93b863443fa99 Mon Sep 17 00:00:00 2001 From: Kira Date: Sun, 11 Jan 2026 11:22:29 -0500 Subject: [PATCH] separate upgrade png logic from execution logic --- commands/utility/updateUpgrades.js | 194 +++----------------------- tasks/autoUpdateUpgrades.js | 24 ++++ utils/UpgradeRenderer.js | 214 +++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 178 deletions(-) create mode 100644 tasks/autoUpdateUpgrades.js create mode 100644 utils/UpgradeRenderer.js diff --git a/commands/utility/updateUpgrades.js b/commands/utility/updateUpgrades.js index c27f06f..2551ea3 100644 --- a/commands/utility/updateUpgrades.js +++ b/commands/utility/updateUpgrades.js @@ -1,10 +1,8 @@ const { SlashCommandBuilder } = require('discord.js'); const torn = require('../../torn.js'); -const config = require('../../config.json'); const fs = require('fs'); const path = require('path'); -// eslint-disable-next-line no-unused-vars -const { createCanvas, registerFont } = require('canvas'); +const renderer = require('../../utils/UpgradeRenderer.js'); module.exports = { data: new SlashCommandBuilder() @@ -12,184 +10,24 @@ module.exports = { .setDescription('Generate the faction upgrades PNG'), async execute(interaction) { - await interaction.deferReply(); // give more time for image generation - - const data = await torn.faction.upgrades(); - - // Build lines with group metadata (core / peace / war) - const lines = []; // { text: string, group: 'core'|'peace'|'war'|null } - lines.push({ text: 'Core Upgrades', group: 'core' }); - let armoryNames = []; - for (const upgrade of data.upgrades.core.upgrades) { - if (upgrade.name && String(upgrade.name).toLowerCase().includes('armory')) { - armoryNames.push(upgrade.name.replace(/\s+armory$/i, '')); - } else { - lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'core' }); - } - } - if (armoryNames.length) { - lines.push({ text: ` Armory: ${armoryNames.join(', ')}`, group: 'core' }); - } - lines.push({ text: '', group: null }); - - lines.push({ text: 'Peace Upgrades', group: 'peace' }); - for (const branch of data.upgrades.peace) { - lines.push({ text: ` ${branch.name}`, group: 'peace' }); - for (const upgrade of branch.upgrades) { - lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'peace' }); - } - } - lines.push({ text: '', group: null }); - - lines.push({ text: 'War Upgrades', group: 'war' }); - for (const branch of data.upgrades.war) { - lines.push({ text: ` ${branch.name}`, group: 'war' }); - for (const upgrade of branch.upgrades) { - lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'war' }); - } - } - - // Image rendering settings - const padding = 24; - const maxWidth = 1100; - const fontSize = 18; - const fontFamily = 'Sans'; - const fontSpec = `${fontSize}px ${fontFamily}`; - - // Temporary canvas for measurement - const measureCanvas = createCanvas(10, 10); - const measureCtx = measureCanvas.getContext('2d'); - measureCtx.font = fontSpec; - - function wrapLine(ctx, text, maxW) { - const words = text.split(' '); - const wrapped = []; - let line = ''; - for (const word of words) { - const test = line ? `${line} ${word}` : word; - const w = ctx.measureText(test).width; - if (w > maxW && line) { - wrapped.push(line); - line = word; - } else { - line = test; - } - } - if (line) wrapped.push(line); - return wrapped; - } - - const baseColors = { - core: config.upgradeColors.core, - peace: config.upgradeColors.peace, - peaceDim: config.upgradeColors.peaceDim, - war: config.upgradeColors.war, - warDim: config.upgradeColors.warDim - }; - - - const state = (data.state || '').toLowerCase(); - let dimMode = 'desaturate'; - let inactiveGroup = null; - if (state === 'peace') { - dimMode = 'opacity'; - inactiveGroup = 'war'; - } else if (state === 'war') { - dimMode = 'opacity'; - inactiveGroup = 'peace'; - } - - function colorForGroup(group) { - if (!group) return '#ffffff'; - if (group === 'core') return baseColors.core; - - if (dimMode === 'opacity') { - if (group === inactiveGroup) return group === 'peace' ? baseColors.peaceDim : baseColors.warDim; - return group === 'peace' ? baseColors.peace : baseColors.war; - } else { - // fallback darker variants when state is unknown - return group === 'peace' ? baseColors.peaceDim : baseColors.warDim; - } - } - - // Wrap and measure lines while preserving group and level - const fontSizes = { 0: 26, 1: 22, 2: 18 }; - const lineHeightFactor = 1.3; - - let visualLines = []; // { text, group, level, fontSize, lineHeight } - let measuredMaxWidth = 0; - const textMaxWidth = maxWidth - padding * 2; - for (const ln of lines) { - if (!ln.text) { - visualLines.push({ text: '', group: null, level: 0, fontSize: fontSizes[0], lineHeight: Math.round(fontSizes[0] * lineHeightFactor) }); - continue; - } - - const leading = (ln.text.match(/^ */) || [''])[0].length; - let level = Math.min(2, Math.floor(leading / 2)); - // Use smallest font size for core upgrade list items (they are indented) - if (ln.group === 'core' && level === 1) level = 2; - const rawText = ln.text.trim(); - const fsz = fontSizes[level] || fontSize; - - measureCtx.font = `${fsz}px ${fontFamily}`; - const wrapped = wrapLine(measureCtx, rawText, textMaxWidth); - for (const wln of wrapped) { - const w = Math.ceil(measureCtx.measureText(wln).width); - visualLines.push({ text: wln, group: ln.group, level, fontSize: fsz, lineHeight: Math.round(fsz * lineHeightFactor) }); - measuredMaxWidth = Math.max(measuredMaxWidth, w); - } - } - - const canvasWidth = Math.min(maxWidth, measuredMaxWidth + padding * 2); - const canvasHeight = padding * 2 + visualLines.reduce((sum, l) => sum + l.lineHeight, 0); - - const canvas = createCanvas(canvasWidth, canvasHeight); - const ctx = canvas.getContext('2d'); - - // rounded corners, if you think i wouldnt have an ai do this for me youre silly - const cornerRadius = 24; - ctx.fillStyle = config.upgradeColors.background; - (function roundedRect(ctx, x, y, w, h, r) { - const radius = Math.max(0, Math.min(r, Math.min(w / 2, h / 2))); - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + w - radius, y); - ctx.quadraticCurveTo(x + w, y, x + w, y + radius); - ctx.lineTo(x + w, y + h - radius); - ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); - ctx.lineTo(x + radius, y + h); - ctx.quadraticCurveTo(x, y + h, x, y + h - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); - ctx.fill(); - })(ctx, 0, 0, canvasWidth, canvasHeight, cornerRadius); - - ctx.textBaseline = 'top'; - - let y = padding; - for (const vln of visualLines) { - ctx.font = `${vln.fontSize}px ${fontFamily}`; - ctx.fillStyle = colorForGroup(vln.group); - const textWidth = Math.ceil(ctx.measureText(vln.text).width); - const x = Math.round((canvasWidth - textWidth) / 2); - ctx.fillText(vln.text, x, y); - y += vln.lineHeight; - } - - const outDir = path.resolve(__dirname, '..', '..', 'public'); - fs.mkdirSync(outDir, { recursive: true }); - - const outFile = path.join(outDir, 'upgrades.png'); - const buffer = canvas.toBuffer('image/png'); - fs.writeFileSync(outFile, buffer); + await interaction.deferReply(); try { + const data = await torn.faction.upgrades(); + const buffer = renderer.render(data); + + const outDir = path.resolve(__dirname, '..', '..', 'public'); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + const outFile = path.join(outDir, 'upgrades.png'); + fs.writeFileSync(outFile, buffer); + await interaction.editReply({ files: [outFile] }); } catch (err) { - await interaction.editReply('Generated upgrades image but failed to attach it.'); - console.debug('Failed to attach image to interaction reply:', err.message || err); + console.error('Error generating upgrades image:', err); + await interaction.editReply('Failed to generate upgrades image.'); } }, -}; \ No newline at end of file +}; diff --git a/tasks/autoUpdateUpgrades.js b/tasks/autoUpdateUpgrades.js new file mode 100644 index 0000000..cb31b61 --- /dev/null +++ b/tasks/autoUpdateUpgrades.js @@ -0,0 +1,24 @@ +module.exports = async (client, torn, config) => { + console.debug("Task: Executing autoUpdateUpgrades"); + const fs = require('fs'); + const path = require('path'); + const renderer = require('../utils/UpgradeRenderer.js'); + + try { + const data = await torn.faction.upgrades(); + const buffer = renderer.render(data); + + const outDir = path.resolve(__dirname, '..', 'public'); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + const outFile = path.join(outDir, 'upgrades.png'); + fs.writeFileSync(outFile, buffer); + console.debug("autoUpdateUpgrades: Successfully updated upgrades.png"); + } catch (err) { + console.error("autoUpdateUpgrades: Failed to update upgrades.png", err); + } +}; + +module.exports.schedule = '0 * * * *'; diff --git a/utils/UpgradeRenderer.js b/utils/UpgradeRenderer.js new file mode 100644 index 0000000..cfcc80e --- /dev/null +++ b/utils/UpgradeRenderer.js @@ -0,0 +1,214 @@ +const { createCanvas } = require('canvas'); +const config = require('../config.json'); + +class UpgradeRenderer { + constructor() { + this.padding = 24; + this.maxWidth = 1100; + this.fontFamily = 'Sans'; + this.baseColors = { + core: config.upgradeColors.core, + peace: config.upgradeColors.peace, + peaceDim: config.upgradeColors.peaceDim, + war: config.upgradeColors.war, + warDim: config.upgradeColors.warDim, + background: config.upgradeColors.background + }; + this.fontSizes = { 0: 26, 1: 22, 2: 18 }; + this.lineHeightFactor = 1.3; + } + + /** + * Renders the upgrades data to a PNG buffer. + * @param {Object} data The data returned from torn.faction.upgrades() + * @returns {Buffer} The PNG image buffer + */ + render(data) { + const lines = this.buildLines(data); + const state = (data.state || '').toLowerCase(); + const dimMode = (state === 'peace' || state === 'war') ? 'opacity' : 'desaturate'; + const inactiveGroup = state === 'peace' ? 'war' : (state === 'war' ? 'peace' : null); + + // Measurement Canvas + const measureCanvas = createCanvas(10, 10); + const measureCtx = measureCanvas.getContext('2d'); + + const visualLines = this.measureAndWrapLines(measureCtx, lines); + const { width, height } = this.calculateCanvasDimensions(visualLines); + + // Final Canvas + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + this.drawBackground(ctx, width, height); + this.drawText(ctx, visualLines, width, dimMode, inactiveGroup); + + return canvas.toBuffer('image/png'); + } + + buildLines(data) { + const lines = []; // { text: string, group: 'core'|'peace'|'war'|null } + + // Core + lines.push({ text: 'Core Upgrades', group: 'core' }); + const armoryNames = []; + if (data.upgrades.core.upgrades) { + for (const upgrade of data.upgrades.core.upgrades) { + if (upgrade.name && String(upgrade.name).toLowerCase().includes('armory')) { + armoryNames.push(upgrade.name.replace(/\s+armory$/i, '')); + } else { + lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'core' }); + } + } + } + if (armoryNames.length) { + lines.push({ text: ` Armory: ${armoryNames.join(', ')}`, group: 'core' }); + } + lines.push({ text: '', group: null }); + + // Peace + lines.push({ text: 'Peace Upgrades', group: 'peace' }); + if (data.upgrades.peace) { + for (const branch of data.upgrades.peace) { + lines.push({ text: ` ${branch.name}`, group: 'peace' }); + for (const upgrade of branch.upgrades) { + lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'peace' }); + } + } + } + lines.push({ text: '', group: null }); + + // War + lines.push({ text: 'War Upgrades', group: 'war' }); + if (data.upgrades.war) { + for (const branch of data.upgrades.war) { + lines.push({ text: ` ${branch.name}`, group: 'war' }); + for (const upgrade of branch.upgrades) { + lines.push({ text: ` ${upgrade.name} - ${upgrade.ability}`, group: 'war' }); + } + } + } + + return lines; + } + + measureAndWrapLines(ctx, lines) { + const visualLines = []; + const textMaxWidth = this.maxWidth - this.padding * 2; + + for (const ln of lines) { + if (!ln.text) { + visualLines.push({ + text: '', + group: null, + level: 0, + width: 0, + fontSize: this.fontSizes[0], + lineHeight: Math.round(this.fontSizes[0] * this.lineHeightFactor) + }); + continue; + } + + const leading = (ln.text.match(/^ */) || [''])[0].length; + let level = Math.min(2, Math.floor(leading / 2)); + if (ln.group === 'core' && level === 1) level = 2; + + const fsz = this.fontSizes[level]; + ctx.font = `${fsz}px ${this.fontFamily}`; + + const rawText = ln.text.trim(); + const wrapped = this.wrapLine(ctx, rawText, textMaxWidth); + + for (const wln of wrapped) { + const w = Math.ceil(ctx.measureText(wln).width); + visualLines.push({ + text: wln, + group: ln.group, + level, + width: w, + fontSize: fsz, + lineHeight: Math.round(fsz * this.lineHeightFactor) + }); + } + } + return visualLines; + } + + wrapLine(ctx, text, maxW) { + const words = text.split(' '); + const wrapped = []; + let line = ''; + for (const word of words) { + const test = line ? `${line} ${word}` : word; + const w = ctx.measureText(test).width; + if (w > maxW && line) { + wrapped.push(line); + line = word; + } else { + line = test; + } + } + if (line) wrapped.push(line); + return wrapped; + } + + calculateCanvasDimensions(visualLines) { + let maxW = 0; + let totalH = 0; + for (const line of visualLines) { + maxW = Math.max(maxW, line.width); + totalH += line.lineHeight; + } + return { + width: Math.min(this.maxWidth, maxW + this.padding * 2), + height: this.padding * 2 + totalH + }; + } + + drawBackground(ctx, width, height) { + const cornerRadius = 24; + ctx.fillStyle = this.baseColors.background; + + const r = Math.max(0, Math.min(cornerRadius, Math.min(width / 2, height / 2))); + ctx.beginPath(); + ctx.moveTo(r, 0); + ctx.lineTo(width - r, 0); + ctx.quadraticCurveTo(width, 0, width, r); + ctx.lineTo(width, height - r); + ctx.quadraticCurveTo(width, height, width - r, height); + ctx.lineTo(r, height); + ctx.quadraticCurveTo(0, height, 0, height - r); + ctx.lineTo(0, r); + ctx.quadraticCurveTo(0, 0, r, 0); + ctx.closePath(); + ctx.fill(); + } + + drawText(ctx, visualLines, width, dimMode, inactiveGroup) { + ctx.textBaseline = 'top'; + let y = this.padding; + + for (const vln of visualLines) { + ctx.font = `${vln.fontSize}px ${this.fontFamily}`; + ctx.fillStyle = this.getColorForGroup(vln.group, dimMode, inactiveGroup); + + const x = Math.round((width - vln.width) / 2); + ctx.fillText(vln.text, x, y); + y += vln.lineHeight; + } + } + + getColorForGroup(group, dimMode, inactiveGroup) { + if (!group) return '#ffffff'; + if (group === 'core') return this.baseColors.core; + + if (dimMode === 'opacity') { + if (group === inactiveGroup) return group === 'peace' ? this.baseColors.peaceDim : this.baseColors.warDim; + return group === 'peace' ? this.baseColors.peace : this.baseColors.war; + } else { + return group === 'peace' ? this.baseColors.peaceDim : this.baseColors.warDim; + } + } +} + +module.exports = new UpgradeRenderer();