Compare commits

...

10 Commits

23 changed files with 1229 additions and 418 deletions

3
.gitignore vendored
View File

@@ -140,6 +140,7 @@ dist
config.json config.json
state.json state.json
cache.json
docker-compose.yml docker-compose.yml
public/ public/
data/
activity_visualization.md

View File

@@ -11,7 +11,7 @@ module.exports = {
async execute(interaction) { async execute(interaction) {
let id let id
if (!interaction.options.getInteger('id')) { if (!interaction.options.getInteger('id')) {
id = await torn.self.id() id = torn.self.player_id
console.log(`Profile: Looking up "${id}"`) console.log(`Profile: Looking up "${id}"`)
} else { } else {
id = interaction.options.getInteger('id'); id = interaction.options.getInteger('id');

View File

@@ -0,0 +1,59 @@
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
const config = require('../../config.json');
module.exports = {
data: new SlashCommandBuilder()
.setName('crimenotify')
.setDescription('Options for crime level empty alerts')
.addSubcommand(subcommand =>
subcommand.setName('set')
.setDescription('Decide if a specific crime level will trigger an alert when empty')
.addIntegerOption(option =>
option.setName('level')
.setDescription('The crime level to set the alert for.')
.setRequired(true)
.setMinValue(1)
.setMaxValue(10))
.addBooleanOption(option =>
option.setName('notify')
.setDescription('Whether to notify when this crime level is empty.')
.setRequired(true))
)
.addSubcommand(subcommand =>
subcommand.setName('list')
.setDescription('List all crime levels and whether they trigger an alert when empty.')
),
async execute(interaction) {
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'list') {
let message = 'Crime levels and whether they trigger an alert when empty:\n';
if (config.crimeNotify) {
for (const level in config.crimeNotify) {
message += `Crime level ${level}/10 will ${config.crimeNotify[level] ? 'notify' : 'not notify'} when empty.\n`;
}
} else {
message += 'No alert overrides set.\n';
}
await interaction.reply({ content: message, flags: MessageFlags.Ephemeral });
} else if (subcommand === 'set') {
const level = interaction.options.getInteger('level');
const notify = interaction.options.getBoolean('notify');
if (!config.crimeNotify) {
config.crimeNotify = {};
}
config.crimeNotify[level] = notify;
const fs = require('fs');
const path = require('path');
try {
fs.writeFileSync(path.join(__dirname, '../../config.json'), JSON.stringify(config, null, 2));
} catch (error) {
console.error('Failed to save config.json:', error);
}
await interaction.reply({ content: `Crime level ${level}/10 will ${notify ? 'notify' : 'not notify'} when empty.`, flags: MessageFlags.Ephemeral });
}
},
};

View File

@@ -1,4 +1,4 @@
const { SlashCommandBuilder } = require('discord.js'); const { SlashCommandBuilder, MessageFlags } = require('discord.js');
const torn = require('../../torn.js'); const torn = require('../../torn.js');
const config = require('../../config.json'); const config = require('../../config.json');
const state = require('../../state.json'); const state = require('../../state.json');
@@ -16,17 +16,17 @@ module.exports = {
const task = interaction.client.tasks[taskName]; const task = interaction.client.tasks[taskName];
if (!task) { if (!task) {
await interaction.reply({ content: `Task "${taskName}" not found.`, ephemeral: true }); await interaction.reply({ content: `Task "${taskName}" not found.`, flags: MessageFlags.Ephemeral });
return; return;
} }
try { try {
await interaction.reply({ content: `Executing task "${taskName}"...`, ephemeral: true }); await interaction.reply({ content: `Executing task "${taskName}"...`, flags: MessageFlags.Ephemeral });
await task(interaction.client, torn, config, state); await task(interaction.client, torn, config, state);
await interaction.followUp({ content: `Task "${taskName}" executed successfully.`, ephemeral: true }); await interaction.followUp({ content: `Task "${taskName}" executed successfully.`, flags: MessageFlags.Ephemeral });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.followUp({ content: `There was an error while executing task "${taskName}"!`, ephemeral: true }); await interaction.followUp({ content: `There was an error while executing task "${taskName}"!`, flags: MessageFlags.Ephemeral });
} }
}, },
}; };

View File

@@ -1,4 +1,4 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -8,7 +8,7 @@ module.exports = {
const taskNames = Object.keys(interaction.client.tasks); const taskNames = Object.keys(interaction.client.tasks);
if (taskNames.length === 0) { if (taskNames.length === 0) {
await interaction.reply({ content: 'No tasks found.', ephemeral: true }); await interaction.reply({ content: 'No tasks found.', flags: MessageFlags.Ephemeral });
return; return;
} }
@@ -17,6 +17,6 @@ module.exports = {
.setTitle('Available Tasks') .setTitle('Available Tasks')
.setDescription(taskNames.map(name => `- ${name}`).join('\n')); .setDescription(taskNames.map(name => `- ${name}`).join('\n'));
await interaction.reply({ embeds: [embed], ephemeral: true }); await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}, },
}; };

View File

@@ -7,14 +7,17 @@ module.exports = {
.setDescription('How is KZNKing doing'), .setDescription('How is KZNKing doing'),
async execute(interaction) { async execute(interaction) {
const kznID = 3392710 const kznID = 3392710
const KZNKing = await torn.user.profile(kznID); const data = await torn.user.get(kznID, ['profile', 'job', 'faction']);
let message = `${KZNKing.name} has ${KZNKing.friends} friends and ${KZNKing.enemies} enemies. `;
(KZNKing.married.duration) ? message += `He has been married to [${KZNKing.married.spouse_name}](https://www.torn.com/profiles.php?XID=${KZNKing.married.spouse_id}) for ${KZNKing.married.duration} days. ` : message += `He is not married. `;
(KZNKing.property === "Private Island") ? message += `He has a Private Island. ` : message += `He does not have a Private Island. `;
(KZNKing.job.position === "Director") ? message += `He is director of ${KZNKing.job.company_name}. ` : message += `He is not director of his company. `;
(KZNKing.faction.position === "Leader") ? message += `He is leader of ${KZNKing.faction.faction_name}. ` : message += `He is not leader of his faction. `;
const company = (await torn.company(KZNKing.job.company_id)); const KZNKing = { ...data.profile, job: data.job, faction: data.faction };
let message = `${KZNKing.name} has ${KZNKing.friends} friends and ${KZNKing.enemies} enemies. `;
(KZNKing.spouse && KZNKing.spouse.days_married) ? message += `He has been married to [${KZNKing.spouse.name}](https://www.torn.com/profiles.php?XID=${KZNKing.spouse.id}) for ${KZNKing.spouse.days_married} days. ` : message += `He is not married. `;
(KZNKing.property === "Private Island") ? message += `He has a Private Island. ` : message += `He does not have a Private Island. `;
(KZNKing.job.position === "Director") ? message += `He is director of ${KZNKing.job.name}. ` : message += `He is not director of his company. `;
(KZNKing.faction.position === "Leader") ? message += `He is leader of ${KZNKing.faction.name}. ` : message += `He is not leader of his faction. `;
const company = (await torn.company(KZNKing.job.id));
const embeds = []; const embeds = [];
if (KZNKing.job.position === "Director") { if (KZNKing.job.position === "Director") {
const jobEmbed = new EmbedBuilder() const jobEmbed = new EmbedBuilder()
@@ -60,35 +63,35 @@ module.exports = {
embeds.push(jobEmbed); embeds.push(jobEmbed);
} }
const faction = await torn.faction.basic(KZNKing.faction.faction_id) const factionBasic = await torn.faction.basic(KZNKing.faction.id)
if (KZNKing.faction.position === "Leader") { if (KZNKing.faction.position === "Leader") {
const facEmbed = new EmbedBuilder() const facEmbed = new EmbedBuilder()
.setTitle(faction.name) .setTitle(factionBasic.name)
.setURL(`https://www.torn.com/factions.php?step=profile&ID=${faction.id}`) .setURL(`https://www.torn.com/factions.php?step=profile&ID=${factionBasic.id}`)
.addFields( .addFields(
{ {
name: "Members", name: "Members",
value: `${faction.members}/${faction.capacity}`, value: `${factionBasic.members}/${factionBasic.capacity}`,
inline: true inline: true
}, },
{ {
name: "Rank", name: "Rank",
value: `${faction.rank.name} ${faction.rank.division}`, value: `${factionBasic.rank.name} ${factionBasic.rank.division}`,
inline: true inline: true
}, },
{ {
name: "Respect", name: "Respect",
value: `${faction.respect.toLocaleString()}`, value: `${factionBasic.respect.toLocaleString()}`,
inline: true inline: true
}, },
{ {
name: "Age", name: "Age",
value: `${faction.days_old}`, value: `${factionBasic.days_old}`,
inline: true inline: true
}, },
{ {
name: "Wars Won", name: "Wars Won",
value: `${faction.rank.wins}`, value: `${factionBasic.rank.wins}`,
inline: true inline: true
}, },
); );
@@ -98,7 +101,7 @@ module.exports = {
let companyFemales = 0; let companyFemales = 0;
let companyTotal = 0; let companyTotal = 0;
const companyFemalePromises = Object.entries(company.employees).map(([user]) => { const companyFemalePromises = Object.entries(company.employees).map(([user]) => {
return torn.cache.user(user).then(data => { return torn.user.basic(user).then(data => {
companyTotal++; companyTotal++;
if (data.gender === "Female") { if (data.gender === "Female") {
companyFemales++; companyFemales++;
@@ -108,9 +111,9 @@ module.exports = {
let factionFemales = 0; let factionFemales = 0;
let factionTotal = 0; let factionTotal = 0;
const factionMembers = await torn.faction.members(KZNKing.faction.faction_id); const factionMembers = await torn.faction.members(KZNKing.faction.id);
const factionFemalePromises = factionMembers.map((user) => { const factionFemalePromises = factionMembers.map((user) => {
return torn.cache.user(user.id).then(data => { return torn.user.basic(user.id).then(data => {
factionTotal++; factionTotal++;
if (data.gender === "Female") { if (data.gender === "Female") {
factionFemales++; factionFemales++;

View File

@@ -7,8 +7,8 @@ module.exports = {
.setDescription('Calculate war payout based on participation') .setDescription('Calculate war payout based on participation')
.addIntegerOption(option => .addIntegerOption(option =>
option.setName('total') option.setName('total')
.setDescription('Full war earnings total before cuts') .setDescription('Full war earnings total before cuts (Optional)')
.setRequired(true)) .setRequired(false))
.addIntegerOption(option => .addIntegerOption(option =>
option.setName('percentage') option.setName('percentage')
.setDescription('Percentage of leader cut (default 10)')) .setDescription('Percentage of leader cut (default 10)'))
@@ -21,14 +21,10 @@ module.exports = {
{ name: 'Attack Based', value: 'attacks' }, { name: 'Attack Based', value: 'attacks' },
)), )),
async execute(interaction) { async execute(interaction) {
const total = interaction.options.getInteger('total'); let total = interaction.options.getInteger('total');
const percentage = interaction.options.getInteger('percentage') ?? 10; const percentage = interaction.options.getInteger('percentage') ?? 10;
const method = interaction.options.getString('method') ?? 'flat'; const method = interaction.options.getString('method') ?? 'flat';
// Calculate cuts
const leaderCut = Math.ceil(total * (percentage / 100));
const pool = total - leaderCut;
await interaction.deferReply(); await interaction.deferReply();
try { try {
@@ -44,6 +40,39 @@ module.exports = {
return interaction.editReply('Could not find our faction in the last war report.'); return interaction.editReply('Could not find our faction in the last war report.');
} }
// Auto-calculate total if not provided
if (!total) {
let calculatedTotal = 0;
const rewards = ourFaction.rewards;
if (rewards && rewards.items) {
const itemIds = Array.isArray(rewards.items)
? rewards.items.map(i => i.id || i.ID)
: Object.keys(rewards.items);
for (const itemId of itemIds) {
const qt = Array.isArray(rewards.items)
? rewards.items.find(i => (i.id == itemId || i.ID == itemId)).quantity
: rewards.items[itemId];
const itemData = await torn.item(itemId, true);
if (itemData && itemData.value && itemData.value.market_price) {
calculatedTotal += itemData.value.market_price * qt;
}
}
}
if (calculatedTotal > 0) {
total = calculatedTotal;
} else {
return interaction.editReply('No total provided and could not calculate rewards from the war report.');
}
}
// Calculate cuts
const leaderCut = Math.ceil(total * (percentage / 100));
const pool = total - leaderCut;
const members = ourFaction.members; const members = ourFaction.members;
const participants = []; const participants = [];
const nonParticipants = []; const nonParticipants = [];
@@ -77,7 +106,7 @@ module.exports = {
let message = `# War Payout: ${ourFaction.name} vs ${enemyFaction.name}\n`; let message = `# War Payout: ${ourFaction.name} vs ${enemyFaction.name}\n`;
message += `**Total Earnings:** $${total.toLocaleString()}\n`; message += `**Total Earnings:** $${total.toLocaleString()}${!interaction.options.getInteger('total') ? ' (Auto-Calculated)' : ''}\n`;
message += `**Leader Cut (${percentage}%):** $${leaderCut.toLocaleString()} (Yameii)\n`; message += `**Leader Cut (${percentage}%):** $${leaderCut.toLocaleString()} (Yameii)\n`;
message += `**Distributable Pool:** $${pool.toLocaleString()}\n`; message += `**Distributable Pool:** $${pool.toLocaleString()}\n`;

View File

@@ -0,0 +1,154 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const fs = require('fs');
const path = require('path');
const torn = require('../../torn.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('inactive')
.setDescription('Shows users who haven\'t participated in an OC recently.')
.addIntegerOption(option =>
option.setName('days')
.setDescription('Number of days of inactivity (default 3)')
.setMinValue(1)
),
async execute(interaction) {
await interaction.deferReply();
const days = interaction.options.getInteger('days') || 3;
const cutoffTime = Date.now() - (days * 24 * 60 * 60 * 1000);
const statsPath = path.join(__dirname, '../../data/ocStats.json');
// Load tracked stats
let stats = {};
if (fs.existsSync(statsPath)) {
try {
stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
} catch (e) {
console.error("inactive: Failed to load ocStats.json", e);
}
}
// Fetch own faction members
try {
members = await torn.faction.members();
} catch (e) {
console.error("inactive: Failed to fetch members", e);
return interaction.editReply('Failed to fetch faction members from API.');
}
// Fetch currently active/planned/recruiting crimes to check for current participation
const activeUserIds = new Set();
try {
const categories = ['recruiting', 'planned', 'active'];
const promises = categories.map(cat => torn.faction.crimes({ category: cat, limit: 100 })); // limit 100 to catch most
const results = await Promise.all(promises);
results.forEach(crimes => {
if (crimes && Array.isArray(crimes)) {
crimes.forEach(crime => {
// Only consider truly active/pending statuses
const completedStatuses = ['Successful', 'Failure', 'Canceled', 'Expired', 'Timeout'];
if (completedStatuses.includes(crime.status)) {
return;
}
if (crime.slots && Array.isArray(crime.slots)) {
crime.slots.forEach(slot => {
if (slot.user && slot.user.id) {
activeUserIds.add(slot.user.id);
}
});
}
});
}
});
console.log(`inactive: Found ${activeUserIds.size} users currently in crimes.`);
} catch (e) {
console.error("inactive: Failed to fetch current crimes", e);
}
const inactiveUsers = [];
// Check each member
for (const member of members) {
const userId = member.id;
// Skip if user is currently in a crime
if (activeUserIds.has(userId)) continue;
const userName = member.name;
const userStat = stats[userId];
if (!userStat || !userStat.lastSeen) {
// Never seen in tracking or no lastSeen data
inactiveUsers.push({
id: userId,
name: userName,
lastSeen: null,
daysInactive: -1
});
} else {
if (userStat.lastSeen < cutoffTime) {
inactiveUsers.push({
id: userId,
name: userName,
lastSeen: new Date(userStat.lastSeen),
lastCrimeId: userStat.lastCrimeId,
daysInactive: Math.floor((Date.now() - userStat.lastSeen) / (24 * 60 * 60 * 1000))
});
}
}
}
// Sort: Never seen first, then by longest inactivity
inactiveUsers.sort((a, b) => {
if (a.lastSeen === null && b.lastSeen === null) return 0;
if (a.lastSeen === null) return -1; // a comes first
if (b.lastSeen === null) return 1; // b comes first
return a.lastSeen - b.lastSeen; // Older timestamp (smaller) comes first
});
const embed = new EmbedBuilder()
.setTitle(`Inactive OC Members (> ${days} days)`)
.setColor(0xFF0000)
.setTimestamp();
if (inactiveUsers.length === 0) {
embed.setDescription(`Everyone has participated in an OC in the last ${days} days!`);
embed.setColor(0x00FF00);
} else {
const limit = 25;
const shownUsers = inactiveUsers.slice(0, limit);
shownUsers.forEach(user => {
let value = "";
if (user.lastSeen === null) {
value = "Never seen in OCs (since tracking started)";
} else {
const ts = Math.floor(user.lastSeen.getTime() / 1000);
let dateStr = `<t:${ts}:d>`;
if (user.lastCrimeId) {
const url = `https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${user.lastCrimeId}`;
dateStr = `[Last crime](${url}): <t:${ts}:d>`;
} else {
dateStr = `Last crime: <t:${ts}:d>`;
}
value = `${dateStr}\n(<t:${ts}:R>)`;
}
embed.addFields({
name: `${user.name} [${user.id}]`,
value: value,
inline: true
});
});
if (inactiveUsers.length > limit) {
embed.setFooter({ text: `...and ${inactiveUsers.length - limit} more.` });
}
}
await interaction.editReply({ embeds: [embed] });
},
};

View File

@@ -0,0 +1,38 @@
const { SlashCommandBuilder } = require('discord.js');
const fs = require('fs');
const path = require('path');
const { fetchAndProcessHistory } = require('../../utils/ocLogic');
const torn = require('../../torn.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('scanoc')
.setDescription('Scans historical OCs to populate participation stats.')
.addIntegerOption(option =>
option.setName('days')
.setDescription('How many days back to scan (default 30)')
.setMinValue(1)
.setMaxValue(365)
),
async execute(interaction) {
await interaction.deferReply();
const days = interaction.options.getInteger('days') || 30;
const statsPath = path.join(__dirname, '../../data/ocStats.json');
await interaction.editReply(`Scanning OCs from the last ${days} days...`);
try {
const updates = await fetchAndProcessHistory(torn, statsPath, days);
if (updates > 0) {
await interaction.editReply(`Scan complete. Updated stats for ${updates} users.`);
} else {
await interaction.editReply(`Scan complete. No new updates needed.`);
}
} catch (e) {
console.error("scanOC: Failed to scan history", e);
await interaction.editReply(`Scan failed: ${e.message}`);
}
},
};

View File

@@ -1,10 +1,8 @@
const { SlashCommandBuilder } = require('discord.js'); const { SlashCommandBuilder } = require('discord.js');
const torn = require('../../torn.js'); const torn = require('../../torn.js');
const config = require('../../config.json');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// eslint-disable-next-line no-unused-vars const renderer = require('../../utils/UpgradeRenderer.js');
const { createCanvas, registerFont } = require('canvas');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -12,184 +10,24 @@ module.exports = {
.setDescription('Generate the faction upgrades PNG'), .setDescription('Generate the faction upgrades PNG'),
async execute(interaction) { async execute(interaction) {
await interaction.deferReply(); // give more time for image generation await interaction.deferReply();
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);
try { 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] }); await interaction.editReply({ files: [outFile] });
} catch (err) { } catch (err) {
await interaction.editReply('Generated upgrades image but failed to attach it.'); console.error('Error generating upgrades image:', err);
console.debug('Failed to attach image to interaction reply:', err.message || err); await interaction.editReply('Failed to generate upgrades image.');
} }
}, },
}; };

View File

@@ -4,6 +4,7 @@
"alerts": "YOUR DISCORD USER ID", "alerts": "YOUR DISCORD USER ID",
"torn": "TORN API KEY", "torn": "TORN API KEY",
"httpPort": 3000, "httpPort": 3000,
"taskWaitMinutes": 5,
"channels": { "channels": {
"ocAlert": "000000000000000000" "ocAlert": "000000000000000000"
}, },

View File

@@ -1,7 +1,8 @@
const cron = require('node-cron');
const fs = require('fs'); const fs = require('fs');
const path = require('node:path'); const path = require('node:path');
const torn = require('./torn.js'); const torn = require('./torn.js');
const { fetchAndProcessHistory } = require('./utils/ocLogic');
const express = require('express'); const express = require('express');
let config, state; let config, state;
@@ -44,7 +45,7 @@ const client = new Client({
}); });
client.once(Events.ClientReady, readyClient => { client.once(Events.ClientReady, readyClient => {
console.log(`Discord: Connected as ${readyClient.user.tag}`); console.log(`Discord: Connected as ${readyClient.user.tag}`);
torn.readyCheck(config.torn); torn.readyCheck();
}); });
client.login(config.token); client.login(config.token);
client.commands = new Collection(); client.commands = new Collection();
@@ -52,23 +53,50 @@ client.tasks = {};
fs.readdir('./tasks/', (err, files) => { fs.readdir('./tasks/', (err, files) => {
if (err) return console.log(err); if (err) return console.log(err);
const taskNames = [];
files.forEach(file => { files.forEach(file => {
const taskFile = require(`./tasks/${file}`); const taskFile = require(`./tasks/${file}`);
const taskName = file.split('.')[0]; const taskName = file.split('.')[0];
client.tasks[taskName] = taskFile; client.tasks[taskName] = taskFile;
if (taskFile.schedule) { taskNames.push(taskName);
console.debug(`Tasks: Scheduling "${taskName}" for ${taskFile.schedule}`);
cron.schedule(taskFile.schedule, () => { taskFile(client, torn, config, state); });
} else {
console.debug(`Tasks: Registered "${taskName}"`); console.debug(`Tasks: Registered "${taskName}"`);
}
}); });
// Round-robin scheduler
let currentTaskIndex = 0;
const runNextTask = () => {
if (taskNames.length === 0) return;
const taskName = taskNames[currentTaskIndex];
const taskFile = client.tasks[taskName];
const now = new Date();
const dateString = now.toLocaleTimeString('en-US', { hour12: false }) + ' ' + now.toLocaleDateString('en-US');
try {
console.debug(`Tasks: Executing "${taskName}" at ${dateString}`);
taskFile(client, torn, config, state);
} catch (error) {
console.error(`Tasks: Error executing "${taskName}" at ${dateString}:`, error);
}
currentTaskIndex = (currentTaskIndex + 1) % taskNames.length;
const waitMinutes = config.taskWaitMinutes || 5;
setTimeout(runNextTask, waitMinutes * 60 * 1000);
};
// Start the loop with an initial delay
if (taskNames.length > 0) {
const waitMinutes = config.taskWaitMinutes || 5;
console.log(`Tasks: Scheduler started. First task will run in ${waitMinutes} minutes.`);
setTimeout(runNextTask, waitMinutes * 60 * 1000);
}
}); });
// discord command stuff also yoinked // discord command stuff also yoinked
const foldersPath = path.join(__dirname, 'commands'); const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath); const commandFolders = fs.readdirSync(foldersPath);
for (const folder of commandFolders) { for (const folder of commandFolders) {
const commandsPath = path.join(foldersPath, folder); const commandsPath = path.join(foldersPath, folder);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) { for (const file of commandFiles) {
@@ -85,6 +113,26 @@ const commandFolders = fs.readdirSync(foldersPath);
// On client ready, generate upgrades image if missing or on first run // On client ready, generate upgrades image if missing or on first run
client.on(Events.ClientReady, async () => { client.on(Events.ClientReady, async () => {
// 1. Check and populate OC Stats if missing
try {
const statsPath = path.resolve(__dirname, 'data/ocStats.json');
const dataDir = path.dirname(statsPath);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(statsPath)) {
console.log('Startup: ocStats.json missing. Initiating auto-population (scanning last 90 days)...');
// Scan 90 days by default for safety
fetchAndProcessHistory(torn, statsPath, 90).then(count => {
console.log(`Startup: Auto-population complete. Updated/Created stats for ${count} users.`);
}).catch(e => {
console.error('Startup: Auto-population failed', e);
});
}
} catch (err) {
console.error('Startup: Error checking ocStats', err);
}
// 2. Upgrades Image check
try { try {
const imgDir = path.resolve(__dirname, 'public'); const imgDir = path.resolve(__dirname, 'public');
if (!fs.existsSync(imgDir)) fs.mkdirSync(imgDir, { recursive: true }); if (!fs.existsSync(imgDir)) fs.mkdirSync(imgDir, { recursive: true });
@@ -94,8 +142,8 @@ client.on(Events.ClientReady, async () => {
if (cmd && typeof cmd.execute === 'function') { if (cmd && typeof cmd.execute === 'function') {
console.debug('Startup: Generating upgrades image (missing or first run)'); console.debug('Startup: Generating upgrades image (missing or first run)');
const mockInteraction = { const mockInteraction = {
deferReply: async () => {}, deferReply: async () => { },
editReply: async () => {} editReply: async () => { }
}; };
try { try {
await cmd.execute(mockInteraction); await cmd.execute(mockInteraction);

24
package-lock.json generated
View File

@@ -12,8 +12,7 @@
"canvas": "^3.2.0", "canvas": "^3.2.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"express": "^5.2.1", "express": "^5.2.1",
"fs": "^0.0.1-security", "fs": "^0.0.1-security"
"node-cron": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.24.0", "@eslint/js": "^9.24.0",
@@ -1835,18 +1834,6 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"license": "ISC",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2547,15 +2534,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -20,8 +20,7 @@
"canvas": "^3.2.0", "canvas": "^3.2.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"express": "^5.2.1", "express": "^5.2.1",
"fs": "^0.0.1-security", "fs": "^0.0.1-security"
"node-cron": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.24.0", "@eslint/js": "^9.24.0",

147
scripts/listOC.js Normal file
View File

@@ -0,0 +1,147 @@
// makes a list of members last OC time, outputs to activity_visualization.md
const torn = require('../torn.js');
const fs = require('fs');
const path = require('path');
(async () => {
try {
// Load stats
const statsPath = './data/ocStats.json';
let stats = {};
if (fs.existsSync(statsPath)) {
stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
} else {
console.log("No ocStats.json found.");
}
// Fetch members
console.log("Fetching members...");
const members = await torn.faction.members();
// Fetch current crimes
console.log("Fetching current crimes...");
const activeCrimes = new Map(); // userId -> { crimeId, category, time }
const categories = ['recruiting', 'planned', 'active'];
const promises = categories.map(cat => torn.faction.crimes({ category: cat, limit: 100 }));
const results = await Promise.all(promises);
results.forEach((crimes, index) => {
const cat = categories[index];
if (crimes && Array.isArray(crimes)) {
crimes.forEach(c => {
const completed = ['Successful', 'Failure', 'Canceled', 'Expired', 'Timeout'];
// We catch everything but flag status for visualization
// But if we want to mimic inactive.js strict active check:
// Only treat as 'current' if NOT completed.
// Actually, for visualization, let's keep the record but mark it differently?
// The user wants to see "Stage".
// If it is completed, it should be treated as "Historic" essentially, logic-wise for "Active" label.
if (c.slots && Array.isArray(c.slots)) {
c.slots.forEach(s => {
if (s.user && s.user.id) {
const newStatus = c.status;
const newIsCompleted = completed.includes(newStatus);
const existing = activeCrimes.get(s.user.id);
if (existing && !existing.isCompleted && newIsCompleted) {
// Existing is active, new is completed. Do NOT overwrite.
return;
}
activeCrimes.set(s.user.id, {
crimeId: c.id,
category: cat,
status: newStatus,
started: c.time_started || c.initiated_at || c.created_at,
isCompleted: newIsCompleted
});
}
});
}
});
}
});
let output = "# Activity Visualization\n\n";
output += "| Name | Stage | Last Time | Details |\n";
output += "|---|---|---|---|\n";
// Calculate latestTime for everyone first to allow sorting
const memberData = members.map(m => {
const stat = stats[m.id];
const current = activeCrimes.get(m.id);
const currentStart = current ? current.started * 1000 : 0;
const lastSeen = stat ? stat.lastSeen : 0;
const latestTime = Math.max(currentStart, lastSeen);
return { m, stat, current, latestTime };
});
// Sort: Longest time ago (smallest timestamp) first
memberData.sort((a, b) => {
if (a.latestTime === 0 && b.latestTime === 0) return 0;
if (a.latestTime === 0) return -1; // Keep members with no activity at the top (or bottom, depending on desired order)
if (b.latestTime === 0) return 1;
return a.latestTime - b.latestTime;
});
memberData.forEach(({ m, stat, current, latestTime }) => {
let stage = "Unknown";
let timeStr = "Never";
let details;
const isActuallyActive = current && !current.isCompleted;
// Helper to linkify ID
const linkify = (id) => `[${id}](https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${id})`;
// Determine Stage and Details string
if (isActuallyActive) {
stage = `**${current.status}**`;
details = `In: ${linkify(current.crimeId)}`;
} else if (current && current.isCompleted) {
// It was found in API but is completed
stage = `${current.status}`;
details = `Done: ${linkify(current.crimeId)}`;
} else if (stat) {
// Historic
stage = "Historic";
const diff = Date.now() - stat.lastSeen;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 3) stage = "Recent";
else if (days > 7) stage = "Inactive";
details = `Last: ${stat.lastCrimeId ? linkify(stat.lastCrimeId) : '?'}`;
} else {
stage = "No Data";
}
if (latestTime > 0) {
const date = new Date(latestTime);
timeStr = date.toLocaleString();
// Add relative time
const diff = Date.now() - latestTime;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) timeStr += " (Today)";
else if (days === 1) timeStr += " (Yesterday)";
else timeStr += ` (${days} days ago)`;
}
output += `| ${m.name} | ${stage} | ${timeStr} | ${details} |\n`;
});
fs.writeFileSync('activity_visualization.md', output, 'utf8');
console.log("Written output to activity_visualization.md");
} catch (e) {
console.error("Error:", e);
}
})();

View File

@@ -0,0 +1,24 @@
module.exports = async (client, torn, config) => {
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 * * * *';

View File

@@ -1,10 +1,15 @@
module.exports = async (client, torn, config) => { module.exports = async (client, torn, config) => {
console.debug("Task: Executing noItemOC");
const fs = require('fs'); const fs = require('fs');
const channel = client.channels.resolve(config.channels.ocAlert); const channel = client.channels.resolve(config.channels.ocAlert);
const now = new Date(); const now = new Date();
const state = require('../state.json'); const state = require('../state.json');
const data = { crimes: await torn.faction.crimes({ category: 'planning', sort: 'DESC' }) }; const crimesList = await torn.faction.crimes({ category: 'planning', sort: 'DESC' });
if (!crimesList) {
console.error("noItemOC: API returned no crimes (check permissions/key?)");
return;
}
const data = { crimes: crimesList };
let itemsneeded = 0; let itemsneeded = 0;
let message = "OCs with unavailable items:\n"; let message = "OCs with unavailable items:\n";
for (const crime of data.crimes) { for (const crime of data.crimes) {
@@ -12,7 +17,7 @@ module.exports = async (client, torn, config) => {
if (slot.item_requirement) { if (slot.item_requirement) {
if (slot.item_requirement.is_available === false) { if (slot.item_requirement.is_available === false) {
const username = (await torn.user.profile(slot.user.id)).name; const username = (await torn.user.profile(slot.user.id)).name;
const itemname = (await torn.cache.item(slot.item_requirement.id)).name; const itemname = (await torn.item(slot.item_requirement.id)).name;
console.debug(`noItemOC: Found crime with unavailable item: ${crime.name}: ${slot.user.id}`); console.debug(`noItemOC: Found crime with unavailable item: ${crime.name}: ${slot.user.id}`);
message += `[${username}](https://www.torn.com/profiles.php?XID=${slot.user.id}) needs [${itemname}](https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${slot.item_requirement.id}) for [${crime.name}](https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${crime.id})\n`; message += `[${username}](https://www.torn.com/profiles.php?XID=${slot.user.id}) needs [${itemname}](https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${slot.item_requirement.id}) for [${crime.name}](https://www.torn.com/factions.php?step=your&type=1#/tab=crimes&crimeId=${crime.id})\n`;
itemsneeded++; itemsneeded++;

61
tasks/trackOC.js Normal file
View File

@@ -0,0 +1,61 @@
const fs = require('fs');
const path = require('path');
const { getMemberMap, processCrimes } = require('../utils/ocLogic');
module.exports = async (client, torn, config) => {
const statsPath = path.join(__dirname, '../data/ocStats.json');
// Load existing stats
let stats = {};
try {
if (fs.existsSync(statsPath)) {
stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
}
} catch (e) {
console.error("trackOC: Failed to load ocStats.json", e);
}
// Fetch faction members
const memberMap = await getMemberMap(torn);
// Fetch recent crimes (last 48 hours to be safe and catch up)
const now = Date.now();
const twoDaysAgo = Math.floor((now - (48 * 60 * 60 * 1000)) / 1000);
let crimesList = [];
const categories = ['recruiting', 'planned', 'active', 'successful', 'failed'];
for (const cat of categories) {
try {
const crimes = await torn.faction.crimes({ from: twoDaysAgo, sort: 'ASC', category: cat });
if (crimes && Array.isArray(crimes)) {
crimesList = crimesList.concat(crimes);
}
} catch (e) {
console.error(`trackOC: Failed to fetch crimes for category '${cat}'`, e);
}
}
if (!crimesList || crimesList.length === 0) {
console.debug("trackOC: No crimes found in the last 48 hours.");
return;
}
// Process crimes using utility
const updates = await processCrimes(crimesList, stats, memberMap, torn);
if (updates > 0) {
try {
const dir = path.dirname(statsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statsPath, JSON.stringify(stats, null, 4));
console.log(`trackOC: Updated participation for ${updates} users.`);
} catch (e) {
console.error("trackOC: Failed to save stats", e);
}
} else {
console.debug("trackOC: No new updates.");
}
};

View File

@@ -1,5 +1,5 @@
module.exports = async (client, torn, config) => { module.exports = async (client, torn, config) => {
console.debug("Task: Executing unavailableOC");
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
const fs = require('fs'); const fs = require('fs');
const channel = client.channels.resolve(config.channels.ocAlert); const channel = client.channels.resolve(config.channels.ocAlert);
@@ -41,18 +41,33 @@ module.exports = async (client, torn, config) => {
let embed = new EmbedBuilder() let embed = new EmbedBuilder()
.setTitle('Crime Availability Check') .setTitle('Crime Availability Check')
await torn.faction.crimes({ category: 'recruiting', offset: 0, sort: 'DESC' }).then(crimeList => { await torn.faction.crimes({ category: 'recruiting', offset: 0, sort: 'DESC' }).then(crimeList => {
if (!crimeList) {
console.error("unavailableOC: API returned no crimes.");
return;
}
const data = { crimes: crimeList }; const data = { crimes: crimeList };
data.crimes.forEach(crime => { data.crimes.forEach(crime => {
if (crime.difficulty >= 1 && crime.difficulty <= factionMaxCrime) {
crimes.difficulty[crime.difficulty - 1].count++ crimes.difficulty[crime.difficulty - 1].count++
}
}); });
let isSomethingZero = false; let isSomethingZero = false;
crimes.difficulty.forEach(difficulty => { crimes.difficulty.forEach(difficulty => {
console.debug(`unavailableOC: ${difficulty.name}: ${difficulty.count}`); console.debug(`unavailableOC: ${difficulty.name}: ${difficulty.count}`);
if (difficulty.count === 0) { if (difficulty.count === 0) {
const level = parseInt(difficulty.name);
let shouldNotify = true;
if (config.crimeNotify && config.crimeNotify[level] === false) {
shouldNotify = false;
}
if (shouldNotify) {
isSomethingZero = true; isSomethingZero = true;
}
embed.addFields({ embed.addFields({
name: `Difficulty ${difficulty.name}`, name: `Difficulty ${difficulty.name}`,
value: `Nobody can sign up for ${difficulty.name} crimes!` value: `Nobody can sign up for ${difficulty.name} crimes!${shouldNotify ? '' : ' (muted)'}`
}) })
} else { } else {
embed.addFields({ embed.addFields({

View File

@@ -1,23 +1,28 @@
module.exports = async (client, torn, config) => { module.exports = async (client, torn, config) => {
console.debug("Task: Executing unpaidOC");
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
const fs = require('fs'); const fs = require('fs');
const channel = client.channels.resolve(config.channels.ocAlert); const channel = client.channels.resolve(config.channels.ocAlert);
const now = new Date(); const now = new Date();
const state = require('../state.json'); const state = require('../state.json');
let embeds = []; let embeds = [];
const data = { crimes: await torn.faction.crimes({ category: 'successful', from: now.getTime() / 1000 - 7 * 24 * 60 * 60, sort: 'DESC' }) }; const crimesList = await torn.faction.crimes({ category: 'successful', from: now.getTime() / 1000 - 7 * 24 * 60 * 60, sort: 'DESC' });
if (!crimesList) {
console.error("unpaidOC: API returned no crimes.");
return;
}
const data = { crimes: crimesList };
for (const crime of data.crimes) { for (const crime of data.crimes) {
if (!crime.rewards.payout) { if (!crime.rewards.payout) {
console.debug(`unpaidOC: Found unpaid crime: ${crime.name}:${crime.id}`); console.debug(`unpaidOC: Found unpaid crime: ${crime.name}:${crime.id}`);
const execDate = new Date(crime.executed_at * 1000); const execDate = new Date(crime.executed_at * 1000);
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(crime.name) .setTitle(crime.name)
.setDescription(`Completed <t:${execDate.getTime() / 1000}:R>\nCash earned: $${crime.rewards.money}`) .setDescription(`Completed <t:${execDate.getTime() / 1000}:R>\nCash earned: $${crime.rewards.money.toLocaleString()}\nSplit per person: $${Math.floor((crime.rewards.money * 0.9) / crime.slots.length).toLocaleString()}`)
.setURL(`https://www.torn.com/factions.php?step=your&type=7#/tab=crimes&crimeId=${crime.id}`); .setURL(`https://www.torn.com/factions.php?step=your&type=7#/tab=crimes&crimeId=${crime.id}`);
if (crime.rewards.money === 0) { if (crime.rewards.money === 0) {
const itemPromises = crime.rewards.items.map(item => const itemPromises = crime.rewards.items.map(item =>
torn.cache.item(item.id).then(itemData => ({ torn.item(item.id, true).then(itemData => ({
quantity: item.quantity, quantity: item.quantity,
name: itemData.name, name: itemData.name,
value: itemData.value.market_price value: itemData.value.market_price

273
torn.js
View File

@@ -10,7 +10,7 @@ try {
} }
try { try {
cache = require('./cache.json'); cache = require('./data/cache.json');
} catch (e) { } catch (e) {
cache = { cache = {
users: {}, users: {},
@@ -19,29 +19,46 @@ try {
items: {} items: {}
}; };
try { try {
fs.writeFileSync('./cache.json', JSON.stringify(cache)); const dir = './data';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync('./data/cache.json', JSON.stringify(cache));
} catch (writeErr) { } catch (writeErr) {
console.error("Failed to write initial cache.json", writeErr); console.error("Failed to write initial cache.json", writeErr);
} }
} }
// Constants // Constants
const TIME_12H = 12 * 60 * 60 * 1000; const HOURS = 60 * 60 * 1000;
const TIME_7D = 7 * 24 * 60 * 60 * 1000; const TTL = {
const TIME_30D = 30 * 24 * 60 * 60 * 1000; USER: 12 * HOURS,
FACTION: 12 * HOURS,
COMPANY: 12 * HOURS,
ITEM: 7 * 24 * HOURS,
ITEM_LOOKUP: 30 * 24 * HOURS
};
// Helper to save cache // Helper to save cache
function saveCache() { function saveCache() {
try { try {
fs.writeFileSync('./cache.json', JSON.stringify(cache)); const dir = './data';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync('./data/cache.json', JSON.stringify(cache));
} catch (e) { } catch (e) {
console.error("Failed to save cache:", e); console.error("Failed to save cache:", e);
} }
} }
// Generic Caching Helper // Generic Caching Helper
async function getCached(collectionName, id, fetchFn, ttl) { async function getCached(collectionName, id, fetchFn, ttl, force = false) {
const now = new Date().getTime(); const now = new Date().getTime();
// Ensure nested object exists
if (!cache[collectionName]) cache[collectionName] = {};
const item = cache[collectionName][id]; const item = cache[collectionName][id];
let lastUpdated = 0; let lastUpdated = 0;
@@ -53,37 +70,23 @@ async function getCached(collectionName, id, fetchFn, ttl) {
} }
} }
if (item && (now - lastUpdated < ttl)) { if (!force && item && (now - lastUpdated < ttl)) {
console.debug(`Cache: Hit for ${collectionName} ${item.name || id}`); console.debug(`Cache: Hit for ${collectionName} ${item.name || id}`);
return item; return item;
} else { } else {
console.debug(`Cache: Miss for ${collectionName} ${id || 'unknown'}`); if (force) console.debug(`Cache: Force refresh for ${collectionName} ${id || 'unknown'}`);
else console.debug(`Cache: Miss for ${collectionName} ${id || 'unknown'}`);
try { try {
// The fetchFn is expected to update the cache and return the data, or we can structure it differently.
// Based on the refactor code below, the fetchFn calls saveCache() and returns the data.
// But wait, the original logic for checking cache was inside the 'cache' object functions,
// calling the specific fetcher which updated the cache.
// In the refactored 'api.cache.user' below, I call 'api.user.basic(user)'.
// 'api.user.basic' updates the cache and returns data.
// So this helper just needs to return that result.
// BUT, I need to make sure I return the logical object.
const result = await fetchFn(); const result = await fetchFn();
console.debug(`Cache: Resolved ${collectionName} ${id}`); console.debug(`Cache: Resolved ${collectionName} ${result.name || result.title || id}`);
// If the fetchFn updated the cache, we can return the cached item to be consistent // Update cache with full result
// or just the result. The original returned the cached item in the cache wrapper. cache[collectionName][id] = {
// Let's return the result from fetchFn which is usually the data. ...result,
// However, the original cache wrappers returned `cache.users[user]`. updated: new Date().toISOString()
// Let's see if there is a difference. };
// `api.user.basic` returns `data`. `cache.users[user]` is a subset of `data`? saveCache();
// Original:
// `cache.users[user] = { name, player_id, level, ... }`
// `return(data)` (full api response)
// But `module.exports.cache.user` returned `cache.users[user]`.
// So the CACHE wrapper returned the CACHED OBJECT (subset), while the FETCH function returned the FULL API response.
// This is a subtle difference.
// If I want to maintain compatibility, `getCached` should return the cached item from `cache` after fetching.
return cache[collectionName][id]; return cache[collectionName][id];
} catch (e) { } catch (e) {
@@ -97,15 +100,27 @@ async function getCached(collectionName, id, fetchFn, ttl) {
async function fetchApi(path) { async function fetchApi(path) {
const glue = path.includes('?') ? '&' : '?'; const glue = path.includes('?') ? '&' : '?';
const response = await fetch(`${path}${glue}key=${config.torn}`); const response = await fetch(`${path}${glue}key=${config.torn}`);
return response.json(); const data = await response.json();
if (data.error) {
console.error(`Torn API Error on ${path}:`, JSON.stringify(data.error));
throw new Error(data.error.error || "Torn API Error");
}
return data;
} }
const api = { const api = {
self: {}, // Will be populated by readyCheck
readyCheck: async (key) => { readyCheck: async (key) => {
const url = `https://api.torn.com/user/?selections=basic&key=${key}`; try {
const response = await fetch(url); // Fetch own 'basic' data using V2 (which returns profile object)
const data = await response.json(); // By passing null/undefined as user, api.user.basic defaults to 'self' cache key
const data = await api.user.basic(null, true);
api.self = data;
console.log(`Torn: Connected as ${data.name} [${data.player_id}]`); console.log(`Torn: Connected as ${data.name} [${data.player_id}]`);
} catch (e) {
console.error("Torn: Critical error during startup check", e);
}
}, },
test: async () => { test: async () => {
@@ -120,47 +135,22 @@ const api = {
return response.json(); return response.json();
}, },
cache: {
async user(user) {
return getCached('users', user, async () => await api.user.basic(user), TIME_12H);
},
async faction(faction) {
return getCached('factions', faction, async () => await api.faction.basic(faction), TIME_12H);
},
async company(company) {
return getCached('companies', company, async () => await api.company(company), TIME_12H);
},
async item(item) {
return getCached('items', item, async () => await api.item(item), TIME_7D);
}
},
user: { user: {
async basic(user) { async basic(user, force = false) {
const data = await fetchApi(`https://api.torn.com/user/${user}?selections=basic`); const endpoint = user ? `https://api.torn.com/v2/user/${user}/basic` : `https://api.torn.com/v2/user/basic`;
const now = new Date(); return getCached('users', user || 'self', async () => {
cache.users[user] = { const data = await fetchApi(endpoint);
name: data.name, if (data.profile) data.profile.player_id = data.profile.id; // Shim for V1 compatibility
player_id: data.player_id, return data.profile; // V2 wraps in 'profile'
level: data.level, }, TTL.USER, force);
gender: data.gender,
updated: now.toISOString()
};
saveCache();
return data;
}, },
async profile(user) { async profile(user, force = false) {
const data = await fetchApi(`https://api.torn.com/user/${user}?selections=profile`); const endpoint = user ? `https://api.torn.com/v2/user/${user}/profile` : `https://api.torn.com/v2/user/profile`;
const now = new Date(); return getCached('users', user || 'self', async () => {
cache.users[user] = { const data = await fetchApi(endpoint);
name: data.name, if (data.profile) data.profile.player_id = data.profile.id; // Shim for V1 compatibility
player_id: data.player_id, return data.profile; // V2 wraps in 'profile'
level: data.level, }, TTL.USER, force);
gender: data.gender,
updated: now.toISOString()
};
saveCache();
return data;
}, },
async stats(user, category, statName) { async stats(user, category, statName) {
let url = `https://api.torn.com/v2/user`; let url = `https://api.torn.com/v2/user`;
@@ -169,28 +159,62 @@ const api = {
if (statName) { url += `&stat=${statName}`; } if (statName) { url += `&stat=${statName}`; }
return fetchApi(url); return fetchApi(url);
}, },
// Added lookup to maintain feature parity if it was ever needed, though not in original user object async job(user, force = false) {
const endpoint = user ? `https://api.torn.com/v2/user/${user}/job` : `https://api.torn.com/v2/user/job`;
return getCached('users_job', user || 'self', async () => {
const data = await fetchApi(endpoint);
return data.job;
}, TTL.USER, force);
},
async faction(user, force = false) {
const endpoint = user ? `https://api.torn.com/v2/user/${user}/faction` : `https://api.torn.com/v2/user/faction`;
return getCached('users_faction', user || 'self', async () => {
const data = await fetchApi(endpoint);
return data.faction;
}, TTL.USER, force);
},
async get(user, selections = [], force = false) {
const selStr = selections.join(',');
const endpoint = user ? `https://api.torn.com/v2/user/${user}?selections=${selStr}` : `https://api.torn.com/v2/user?selections=${selStr}`;
// Cache usage for composite calls is tricky. For now, let's skip complex caching or cache by selection string key.
// A simple key like "users_profile_job_faction_ID" works.
const cacheKey = selections.sort().join('_');
return getCached(`users_${cacheKey}`, user || 'self', async () => {
const data = await fetchApi(endpoint);
return data;
}, TTL.USER, force);
},
}, },
faction: { faction: {
async basic(faction) { async basic(faction, force = false) {
const endpoint = faction ? `https://api.torn.com/v2/faction/${faction}/basic` : `https://api.torn.com/v2/faction/basic`; // If faction is null, we can't key by ID easily until we fetch.
const response = await fetchApi(endpoint); // For now, let's assume if faction is provided we use it as key.
// v2 return structure: { basic: { ... } } // If not provided, we might be fetching our own faction.
const data = response.basic; // We can key it by "own" or similar if needed, but let's see.
// If faction is missing, we fetch own faction, resulting data has ID.
const now = new Date(); // Special handling: if faction is undefined, we can't check cache by ID easily without knowing ID.
// Store by ID. If faction is null (own faction), we rely on data.id // However, we can use a special key like 'own' or skip cache check pre-fetch?
cache.factions[data.id] = { // Better: If no ID provided, we just fetch to be safe, OR we assume config.factionID if we had it.
name: data.name, // Let's implement transparent fetching without ID -> fetch -> cache by ID.
leader_id: data.leader_id,
capacity: data.capacity, if (!faction) {
rank: data.rank, const endpoint = `https://api.torn.com/v2/faction/basic`;
best_chain: data.best_chain, const response = await fetchApi(endpoint);
updated: now.toISOString() const data = response.basic;
}; // We can update cache here manually
cache.factions[data.id] = { ...data, updated: new Date().toISOString() };
saveCache(); saveCache();
return data; return data;
}
return getCached('factions', faction, async () => {
const endpoint = `https://api.torn.com/v2/faction/${faction}/basic`;
const response = await fetchApi(endpoint);
return response.basic;
}, TTL.FACTION, force);
}, },
async members(faction) { async members(faction) {
const endpoint = faction ? `https://api.torn.com/v2/faction/${faction}/members?striptags=true` : `https://api.torn.com/v2/faction/members?striptags=true`; const endpoint = faction ? `https://api.torn.com/v2/faction/${faction}/members?striptags=true` : `https://api.torn.com/v2/faction/members?striptags=true`;
@@ -199,12 +223,12 @@ const api = {
}, },
async crimes(options = {}) { async crimes(options = {}) {
let params = new URLSearchParams(); let params = new URLSearchParams();
let category = '';
if (typeof options === 'string') { if (typeof options === 'string') {
category = options; params.append('cat', options);
} else { } else {
if (options.category) category = options.category; if (options.category) params.append('cat', options.category);
if (options.from) params.append('from', options.from); if (options.from) params.append('from', options.from);
if (options.to) params.append('to', options.to); if (options.to) params.append('to', options.to);
if (options.limit) params.append('limit', options.limit); if (options.limit) params.append('limit', options.limit);
@@ -213,7 +237,7 @@ const api = {
if (options.initiator) params.append('initiator', options.initiator); if (options.initiator) params.append('initiator', options.initiator);
} }
const endpoint = category ? `https://api.torn.com/v2/faction/crimes/${category}` : `https://api.torn.com/v2/faction/crimes`; const endpoint = `https://api.torn.com/v2/faction/crimes`;
const queryString = params.toString() ? `?${params.toString()}` : ''; const queryString = params.toString() ? `?${params.toString()}` : '';
const data = await fetchApi(`${endpoint}${queryString}`); const data = await fetchApi(`${endpoint}${queryString}`);
@@ -245,37 +269,35 @@ const api = {
} }
}, },
// company was a top-level function in export, but also used as property company: async (company, force = false) => {
// Original: module.exports.company = async ... if (!company) {
// So api.company should be a function const endpoint = `https://api.torn.com/company/?selections=profile`;
company: async (company) => {
const endpoint = company ? `https://api.torn.com/company/${company}?selections=profile` : `https://api.torn.com/company/?selections=profile`;
const data = await fetchApi(endpoint); const data = await fetchApi(endpoint);
const now = new Date(); // ID is data.company.ID
// company ID is data.company.ID // Torn API v1/v2 difference? URL says /company/? so likely v1 standard structure
cache.companies[data.company.ID] = { // Let's assume data.company exists.
name: data.company.name, if (data.company) {
id: data.company.ID, cache.companies[data.company.ID] = { ...data.company, updated: new Date().toISOString() };
company_type: data.company.company_type,
director_id: data.company.director,
rating: data.company.rating,
updated: now.toISOString()
};
saveCache(); saveCache();
return data.company; return data.company;
}
return data;
}
return getCached('companies', company, async () => {
const endpoint = `https://api.torn.com/company/${company}?selections=profile`;
const data = await fetchApi(endpoint);
return data.company;
}, TTL.COMPANY, force);
}, },
// item was a function with a .lookup property // item was a function with a .lookup property
item: Object.assign( item: Object.assign(
async (item) => { async (item, force = false) => {
return getCached('items', item, async () => {
const data = await fetchApi(`https://api.torn.com/v2/torn/${item}/items?sort=ASC`); const data = await fetchApi(`https://api.torn.com/v2/torn/${item}/items?sort=ASC`);
const now = new Date();
cache.items[item] = data.items[0]; // Assuming item is ID
if (cache.items[item]) {
cache.items[item].updated = now.toISOString();
}
saveCache();
return data.items[0]; return data.items[0];
}, TTL.ITEM, force);
}, },
{ {
lookup: async (itemName) => { lookup: async (itemName) => {
@@ -288,7 +310,7 @@ const api = {
let last = 0; let last = 0;
try { last = new Date(cache.items[itemId].updated).getTime(); } catch (e) { } try { last = new Date(cache.items[itemId].updated).getTime(); } catch (e) { }
if (now - last < TIME_30D) { if (now - last < TTL.ITEM_LOOKUP) {
console.debug(`Cache: Hit for item ${cache.items[itemId].name}`); console.debug(`Cache: Hit for item ${cache.items[itemId].name}`);
return cache.items[itemId]; return cache.items[itemId];
} }
@@ -317,20 +339,7 @@ const api = {
} }
), ),
self: {
async id() {
if (!config.tornid) {
const url = `https://api.torn.com/user/?selections=basic&key=${config.torn}`;
const response = await fetch(url);
const data = await response.json();
config.tornid = data.player_id;
console.log(`Torn: Retrieved default ID as "${data.player_id}"`);
return data.player_id;
} else {
return config.tornid;
}
}
}
}; };
module.exports = api; module.exports = api;

214
utils/UpgradeRenderer.js Normal file
View File

@@ -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();

183
utils/ocLogic.js Normal file
View File

@@ -0,0 +1,183 @@
const fs = require('fs');
const path = require('path');
/**
* Fetches faction members and returns a map of ID -> Name.
* @param {Object} torn The torn wrapper instance.
* @returns {Promise<Object>} Map of member ID to Name.
*/
async function getMemberMap(torn) {
let memberMap = {};
try {
const members = await torn.faction.members();
members.forEach(m => memberMap[m.id] = m.name);
} catch (e) {
console.error("ocLogic: Failed to fetch faction members for name resolution", e);
}
return memberMap;
}
/**
* Calculates the best timestamp for a crime participation.
* @param {Object} slot The user slot object.
* @param {Object} crime The crime object.
* @returns {number|null} Timestamp in milliseconds, or null if invalid.
*/
function calculateCrimeTimestamp(slot, crime) {
// Priority: Max of (User join time, Crime related times)
const timestamps = [
slot.user.joined_at,
crime.initiated_at,
crime.time_started,
crime.time_completed,
crime.executed_at,
crime.time_ready,
crime.created_at
].filter(t => t && t > 0);
if (timestamps.length === 0) {
return null;
}
return Math.max(...timestamps) * 1000;
}
/**
* Processes a list of crimes and updates the stats object.
* @param {Array} crimesList List of crime objects.
* @param {Object} stats The current stats object.
* @param {Object} memberMap Map of user ID to name.
* @returns {number} Number of updates made.
*/
async function processCrimes(crimesList, stats, memberMap, torn) {
let updatedUsers = new Set();
// We iterate sequentially to allow async fetching if needed without blasting API
for (const crime of crimesList) {
if (crime.slots && Array.isArray(crime.slots)) {
for (const slot of crime.slots) {
if (!slot.user) continue; // Skip if user data is missing
const userId = slot.user.id;
// Try to resolve name
let userName = slot.user.name;
if (!userName) userName = memberMap[userId];
if ((!userName || userName === 'Unknown') && userId && torn) {
try {
const profile = await torn.user.profile(userId);
if (profile && profile.name) {
userName = profile.name;
} else {
console.debug(`ocLogic: Failed to resolve name for ${userId} (No name in response)`);
}
} catch (e) {
console.debug(`ocLogic: Error resolving name for ${userId}`, e);
}
}
if (userId) {
const existing = stats[userId] || {};
const crimeTime = calculateCrimeTimestamp(slot, crime);
if (!crimeTime || crimeTime === 0) continue;
// Update if this crime is newer than what we have stored
// OR if we have a better name now (and same or newer time)
const isNewer = !existing.lastSeen || crimeTime > existing.lastSeen;
const isBetterName = userName && userName !== 'Unknown' && existing.name === "Unknown";
if (isNewer || isBetterName) {
// Only update timestamp if it's actually newer
const newTime = isNewer ? crimeTime : existing.lastSeen;
const newCrimeId = isNewer ? crime.id : existing.lastCrimeId;
stats[userId] = {
name: userName || existing.name || "Unknown",
lastSeen: newTime,
lastCrimeId: newCrimeId
};
updatedUsers.add(userId);
}
}
}
}
}
return updatedUsers.size;
}
/**
* Fetches historical OCs and updates the stats file.
* @param {Object} torn The torn wrapper instance.
* @param {string} statsPath Path to the stats JSON file.
* @param {number} days Number of days back to scan.
* @returns {Promise<number>} Number of users updated.
*/
async function fetchAndProcessHistory(torn, statsPath, days) {
const now = Date.now();
const fromTimestamp = Math.floor((now - (days * 24 * 60 * 60 * 1000)) / 1000);
// Load existing stats
let stats = {};
if (fs.existsSync(statsPath)) {
try {
stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
} catch (e) {
console.error("ocLogic: Failed to load existing stats during fetch", e);
}
}
// Fetch faction members
const memberMap = await getMemberMap(torn);
let crimesList = [];
const categories = ['recruiting', 'planned', 'active', 'successful', 'failed'];
console.debug(`ocLogic: Scanning history for last ${days} days...`);
for (const cat of categories) {
try {
const crimes = await torn.faction.crimes({
from: fromTimestamp,
sort: 'ASC',
category: cat,
limit: 300 // Match scanoc batch size
});
if (crimes && Array.isArray(crimes)) {
crimesList = crimesList.concat(crimes);
}
} catch (e) {
console.error(`ocLogic: Failed to fetch crimes for category '${cat}'`, e);
}
}
if (crimesList.length === 0) {
return 0;
}
// Process crimes
const updates = await processCrimes(crimesList, stats, memberMap, torn);
// Save
if (updates > 0) {
try {
const dir = path.dirname(statsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(statsPath, JSON.stringify(stats, null, 4));
console.log(`ocLogic: Updated history for ${updates} users.`);
} catch (e) {
console.error("ocLogic: Failed to save stats", e);
}
}
return updates;
}
module.exports = {
getMemberMap,
calculateCrimeTimestamp,
processCrimes,
fetchAndProcessHistory
};