Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 118 additions & 56 deletions scripts/telegram-ads-simulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
* telegram-ads-simulator.js
* RuneWager Telegram Ads Simulator v1.0
*
* Validates 30 Telegram ad creatives for compliance, simulates user behavior
* Validates Telegram ad creatives for compliance, simulates user behavior
* across time buckets, days of week, channel types, and user personas, runs
* an auto-optimization loop across 3 iterations, and outputs structured JSON
* followed by a plain-text human-readable summary.
* an auto-optimization loop, and outputs structured JSON followed by a
* plain-text human-readable summary.
* Key tuning constants (TOP_COMBOS, OPT_ITERATIONS, etc.) are centralized
* in the CONSTANTS block below.
*
* Usage:
* node scripts/telegram-ads-simulator.js
Expand All @@ -27,10 +29,30 @@ const path = require('path');
// CONSTANTS
// ============================================================

const MAX_LENGTH = 160;
const BOT_LINK = 'https://t.me/RuneWager_bot';
const MAX_LENGTH = 160;
const BOT_LINK = 'https://t.me/RuneWager_bot';
const TELEGRAM_LINK_RE = /^https:\/\/(t\.me|telegram\.me)\/[A-Za-z0-9_]+(?:\/\d+)?$/;
const FORBIDDEN_WORDS = ['cashout', 'withdraw', 'payout'];
const FORBIDDEN_WORDS = ['cashout', 'withdraw', 'payout'];

// Simulation tuning — change here to adjust the whole simulation
const IMPRESSIONS_BASE = 10000; // Baseline impressions per ad run at bid=1.0
const OPT_ITERATIONS = 3; // Number of copy-optimization iterations to run
const TOP_COMBOS = 20; // Top combinations stored per ad in by_time_day_channel_persona
const TOP_AD_COUNT = 3; // Number of top ads to select and report

const USAGE = [
'Usage: node telegram-ads-simulator.js [options]',
'',
'Options:',
' --output <path> Write full JSON + summary to <path>; print summary to stdout.',
' --summary-only Print only the human-readable plain-text summary.',
Comment on lines +39 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Guard against OPT_ITERATIONS being set to 0 or a negative value.

This logic relies on OPT_ITERATIONS being ≥ 1: you create optimized_versions with Array.from({ length: OPT_ITERATIONS }) and later access result.optimized_versions[OPT_ITERATIONS - 1]. If OPT_ITERATIONS is ever set to 0 or negative, optimized_versions will be empty and selectTopAds will hit undefined.metrics at runtime. Consider either validating OPT_ITERATIONS (e.g., fail fast if < 1) or handling an empty optimized_versions array when selecting top ads.

' --json-only Print only the machine-readable JSON.',
' (no flags) Print JSON then summary to stdout.',
'',
'Constraints:',
' --summary-only and --json-only are mutually exclusive.',
' --output cannot be combined with --summary-only or --json-only.',
].join('\n');

// ============================================================
// RAW AD CANDIDATES (30 variants)
Expand Down Expand Up @@ -464,7 +486,7 @@ function bidSensitivity(baselineCTR, baselineCPC, baselineROI) {

return {
bid,
estimated_impressions: Math.round(10000 * profile.impressionFactor),
estimated_impressions: Math.round(IMPRESSIONS_BASE * profile.impressionFactor),
CTR: adjCTR,
CPC,
ROI_index: ROI,
Expand Down Expand Up @@ -622,7 +644,7 @@ function runAdSimulation(validatedAd) {
}
}

// Aggregate baseline across all 1,512 combinations
// Aggregate baseline across all combinations
const n = allCombos.length;
const mean = key => r4(allCombos.reduce((s, c) => s + c[key], 0) / n);

Expand All @@ -636,11 +658,11 @@ function runAdSimulation(validatedAd) {
ROI_index: mean('ROI_index'),
};

// Top 20 combinations by ROI_index (sorted copy, no mutation of original)
// Top combinations by ROI_index (sorted copy, no mutation of original)
const top20 = allCombos
.slice()
.sort((a, b) => b.ROI_index - a.ROI_index)
.slice(0, 20);
.slice(0, TOP_COMBOS);

// Bid sensitivity at baseline aggregate metrics
const bid_sensitivity = bidSensitivity(
Expand All @@ -649,8 +671,8 @@ function runAdSimulation(validatedAd) {
baseline_metrics.ROI_index,
);

// 3-iteration optimization
const optimized_versions = [1, 2, 3].map(iter => ({
// Optimization iterations
const optimized_versions = Array.from({ length: OPT_ITERATIONS }, (_, i) => i + 1).map(iter => ({
iteration: iter,
text: optimizeText(text, category, iter),
metrics: iterMetrics(baseline_metrics, iter),
Expand Down Expand Up @@ -681,7 +703,7 @@ function runAdSimulation(validatedAd) {
*/
function selectTopAds(adResults, validatedAds) {
const scored = adResults.map(r => {
const m = r.optimized_versions[2].metrics; // iter-3 metrics
const m = r.optimized_versions[OPT_ITERATIONS - 1].metrics;
const score = m.ROI_index * 0.50
+ m.overall_prize_redemption_rate * 100 * 0.30
+ m.overall_leaderboard_participation_rate * 100 * 0.20;
Expand All @@ -690,35 +712,51 @@ function selectTopAds(adResults, validatedAds) {

scored.sort((a, b) => b.score - a.score);

return scored.slice(0, 3).map(({ ad_id, result }) => {
const iter3 = result.optimized_versions[2];
const m = iter3.metrics;
return scored.slice(0, TOP_AD_COUNT).map(({ ad_id, result }) => {
const finalIter = result.optimized_versions[OPT_ITERATIONS - 1];
const m = finalIter.metrics;

// Best bid: highest ROI_index from bid_sensitivity
const bestBidEntry = result.bid_sensitivity
.slice()
.sort((a, b) => b.ROI_index - a.ROI_index)[0];

// Aggregate best time windows from top-5 combinations
const top5 = result.by_time_day_channel_persona.slice(0, 5);
// Aggregate best time windows across ALL top combinations (not just top-5).
// For each channel, accumulate ROI-weighted counts for every time bucket and
// day of week that appears in the top-combo set, then select the most
// ROI-weighted time bucket and top-3 days per channel.
const windowMap = {};
for (const combo of top5) {
for (const combo of result.by_time_day_channel_persona) {
const key = combo.channel_type;
if (!windowMap[key]) windowMap[key] = { times: new Set(), days: new Set() };
windowMap[key].times.add(combo.time_bucket);
windowMap[key].days.add(combo.day_of_week);
if (!windowMap[key]) {
windowMap[key] = { timeWeight: {}, dayWeight: {}, combos: 0 };
}
const w = windowMap[key];
w.timeWeight[combo.time_bucket] = (w.timeWeight[combo.time_bucket] || 0) + combo.ROI_index;
w.dayWeight[combo.day_of_week] = (w.dayWeight[combo.day_of_week] || 0) + combo.ROI_index;
w.combos++;
}

const best_time_windows = Object.entries(windowMap).map(([ch, v]) => ({
time_bucket: [...v.times][0] || '16:00-20:00',
days_of_week: [...v.days],
channel_types: [ch],
reason: `Top ROI_index combination for ${ch} in this ad`,
}));
const best_time_windows = Object.entries(windowMap).map(([ch, v]) => {
// Pick the single time bucket with the highest accumulated ROI weight
const topTime = Object.entries(v.timeWeight)
.sort((a, b) => b[1] - a[1])[0][0];
// Pick the top-3 days by accumulated ROI weight
const topDays = Object.entries(v.dayWeight)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([day]) => day);
return {
time_bucket: topTime,
days_of_week: topDays,
channel_types: [ch],
reason: `Highest ROI-weighted time/day for ${ch} across ${v.combos} top combinations`,
};
});

return {
ad_id,
final_text: iter3.text,
final_text: finalIter.text,
best_bid: bestBidEntry.bid,
best_time_windows,
summary_metrics: {
Expand Down Expand Up @@ -862,7 +900,7 @@ function generateSummary(output) {
lines.push(`Total ads validated: ${output.validated_ads.length}`);
lines.push(`Fully compliant (no changes needed): ${compliant}`);
lines.push(`Auto-fixed (minor edits applied): ${violations}`);
lines.push('All 30 ads are Telegram Ads compliant after validation.');
lines.push(`All ${output.validated_ads.length} ads are Telegram Ads compliant after validation.`);
lines.push('');
lines.push(`Simulation ran: ${output.validated_ads.length} ads x ${output.simulation_config.time_buckets.length} time buckets x ${output.simulation_config.days_of_week.length} days x ${output.simulation_config.channel_types.length} channels x ${output.simulation_config.personas.length} personas = ${output.validated_ads.length * output.simulation_config.time_buckets.length * output.simulation_config.days_of_week.length * output.simulation_config.channel_types.length * output.simulation_config.personas.length} combinations`);
lines.push('');
Expand All @@ -881,42 +919,68 @@ function pct(v) { return (v * 100).toFixed(2) + '%'; }

/**
* Orchestrates the full simulation pipeline:
* 1. Validate all 30 ads
* 2. Run simulation across all dimension combinations
* 3. Run 3-iteration optimization loop
* 4. Select top 3 ads
* 5. Output PART A (JSON) then PART B (plain-text summary)
* 1. Parse and validate CLI arguments — fail fast on bad input
* 2. Validate all ads for Telegram Ads compliance
* 3. Run simulation across all dimension combinations
* 4. Run OPT_ITERATIONS-iteration optimization loop
* 5. Select top TOP_AD_COUNT ads
* 6. Output PART A (JSON) then PART B (plain-text summary)
*/
function main() {
const args = process.argv.slice(2);
const outputIdx = args.indexOf('--output');
const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : null;

// ---- Argument parsing ----
const summaryOnly = args.includes('--summary-only');
const jsonOnly = args.includes('--json-only');
const jsonOnly = args.includes('--json-only');
const outputIdx = args.indexOf('--output');
const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : null;

// Fail fast with a clear message on invalid argument combinations
const die = msg => {
process.stderr.write(`Error: ${msg}\n\n${USAGE}\n`);
process.exit(1);
};

if (summaryOnly && jsonOnly) {
die('--summary-only and --json-only are mutually exclusive.');
}
if (outputIdx >= 0 && (!outputFile || outputFile.startsWith('--'))) {
die('--output requires a file path argument (e.g. --output results/ads.json).');
}
if (outputFile && (summaryOnly || jsonOnly)) {
die('--output cannot be combined with --summary-only or --json-only.');
}
const KNOWN_FLAGS = new Set(['--output', '--summary-only', '--json-only']);
for (const arg of args) {
if (arg.startsWith('--') && !KNOWN_FLAGS.has(arg)) {
die(`Unknown flag "${arg}".`);
}
}
Comment on lines +950 to +958
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider allowing a help flag instead of treating it as an unknown option.

Because of the strict KNOWN_FLAGS check, --help / -h will currently be rejected with "Unknown flag" even though you already define USAGE. It would be better to special-case these flags to print USAGE and exit 0 instead of treating them as invalid.

Suggested change
if (outputFile && (summaryOnly || jsonOnly)) {
die('--output cannot be combined with --summary-only or --json-only.');
}
const KNOWN_FLAGS = new Set(['--output', '--summary-only', '--json-only']);
for (const arg of args) {
if (arg.startsWith('--') && !KNOWN_FLAGS.has(arg)) {
die(`Unknown flag "${arg}".`);
}
}
if (outputFile && (summaryOnly || jsonOnly)) {
die('--output cannot be combined with --summary-only or --json-only.');
}
const KNOWN_FLAGS = new Set(['--output', '--summary-only', '--json-only', '--help']);
for (const arg of args) {
if (arg === '--help' || arg === '-h') {
console.log(USAGE);
process.exit(0);
}
if (arg.startsWith('--') && !KNOWN_FLAGS.has(arg)) {
die(`Unknown flag "${arg}".`);
}
}


// ---- Simulation ----
const log = msg => process.stderr.write(msg + '\n');
const adCount = RAW_ADS.length;

log('RuneWager Telegram Ads Simulator v1.0');
log('--------------------------------------');
log('Step 1: Validating 30 ad creatives for Telegram Ads compliance...');
log(`Step 1: Validating ${adCount} ad creatives for Telegram Ads compliance...`);

const validated_ads = RAW_ADS.map((text, i) => validateAd(text, i));
const fixedCount = validated_ads.filter(a => !a.compliant).length;
log(` Done. ${validated_ads.length - fixedCount} fully compliant, ${fixedCount} auto-fixed.`);
log(` Done. ${adCount - fixedCount} fully compliant, ${fixedCount} auto-fixed.`);

const totalCombos = validated_ads.length
* TIME_BUCKETS.length * DAYS_OF_WEEK.length
* CHANNEL_TYPES.length * PERSONAS.length;
const combosPerAd = TIME_BUCKETS.length * DAYS_OF_WEEK.length * CHANNEL_TYPES.length * PERSONAS.length;
const totalCombos = adCount * combosPerAd;

log(`Step 2: Running simulation (${totalCombos.toLocaleString()} total combinations)...`);

const ad_results = validated_ads.map((ad, i) => {
if ((i + 1) % 5 === 0 || i === 0) log(` Ad ${i + 1}/30 simulated...`);
if ((i + 1) % 5 === 0 || i === 0) log(` Ad ${i + 1}/${adCount} simulated...`);
return runAdSimulation(ad);
});
log(' Done. Baseline metrics, bid sensitivity, and 3-iteration optimization computed.');
log(` Done. Baseline metrics, bid sensitivity, and ${OPT_ITERATIONS}-iteration optimization computed.`);

log('Step 3: Selecting top 3 ads by composite score...');
log(`Step 3: Selecting top ${TOP_AD_COUNT} ads by composite score...`);
const top_ads = selectTopAds(ad_results, validated_ads);
log(` Top ads: #${top_ads.map(a => a.ad_id).join(', #')}`);

Expand All @@ -929,8 +993,11 @@ function main() {
violations: a.violations,
})),
simulation_config: {
impressions_per_simulation: 10000,
impressions_per_simulation: IMPRESSIONS_BASE,
base_bid: 1.0,
top_combos_per_ad: TOP_COMBOS,
optimization_iterations: OPT_ITERATIONS,
top_ads_selected: TOP_AD_COUNT,
time_buckets: TIME_BUCKETS,
days_of_week: DAYS_OF_WEEK,
channel_types: CHANNEL_TYPES,
Expand All @@ -951,34 +1018,29 @@ function main() {
HighRollerCasinoChat: 'High-value casino players. Best prize redemption and bonus completion rates.',
CasualGamingChannel: 'Casual gaming community. Lower conversion for casino-style content.',
},
note: 'by_time_day_channel_persona contains the top 20 combinations by ROI_index per ad (out of 1,512 total combinations each).',
note: `by_time_day_channel_persona contains the top ${TOP_COMBOS} combinations by ROI_index per ad (out of ${combosPerAd} total combinations each).`,
},
ad_results,
top_ads,
};

log('Step 4: Generating output...');

const jsonStr = JSON.stringify(output, null, 2);
const jsonStr = JSON.stringify(output, null, 2);
const summaryStr = generateSummary(output);

if (outputFile) {
// Write full output to file
const fullContent = jsonStr + '\n\n' + summaryStr + '\n';
fs.mkdirSync(path.dirname(path.resolve(outputFile)), { recursive: true });
fs.writeFileSync(outputFile, fullContent, 'utf8');
fs.writeFileSync(outputFile, jsonStr + '\n\n' + summaryStr + '\n', 'utf8');
log(` Full output written to: ${outputFile}`);
// Always print summary to stdout when using --output
process.stdout.write(summaryStr + '\n');
} else if (summaryOnly) {
process.stdout.write(summaryStr + '\n');
} else if (jsonOnly) {
process.stdout.write(jsonStr + '\n');
} else {
// Default: PART A (JSON) then PART B (summary) to stdout
process.stdout.write(jsonStr + '\n');
process.stdout.write('\n');
process.stdout.write(summaryStr + '\n');
process.stdout.write(jsonStr + '\n\n' + summaryStr + '\n');
}

log('Simulation complete.');
Expand Down