// Circle Suite — Text Ad generator for the Traffic Desk.
//
// Text placements out-perform banners on this network (357 text ads have taken
// ~171 clicks each against ~150 for banners, on a fraction of the inventory),
// so members get a generator for them too.
//
// The format is not a guess. It was measured across 60 live text ads on the
// network: Subject is capped at 20 characters, Body is ALWAYS exactly three
// lines joined by
, each capped at 24 characters. Every ad in the sample
// obeyed that, without exception.
//
// Two deliberate design decisions:
//
// 1. The engine writes PLAIN text inside a deliberately tight budget, and we
// apply the emoji ourselves afterward. Language models cannot count
// characters reliably — especially with emoji, where one glyph can be
// several code points — so asking for "<=24 chars with emoji" produces
// overflow every time. Writing to 20 and adding a known pair of 1-code-point
// emoji is arithmetic we control, and it lets us rotate the emoji
// independently of the copy so two ads never look alike.
//
// 2. The palette carries NO money emoji. The network is full of piles-of-cash
// ads; ours deliberately do not imply earnings, because the compliance rule
// against implying income outranks matching the neighbours.
'use strict';
const suiteAi = require('./suite-ai');
const MAX_SUBJECT = 20; // measured hard cap on live inventory
const MAX_LINE = 24; // measured hard cap per body line
const BODY_LINES = 3; // every single live ad uses exactly three
// Budget the engine writes to, leaving room for the emoji we add.
const SUBJECT_BUDGET = MAX_SUBJECT - 2;
const LINE_BUDGET = MAX_LINE - 2;
// Single-code-point emoji only — no variation selectors, no ZWJ sequences, so
// the character arithmetic above stays exact.
const PALETTES = [
['⚡', '⚡'], // high voltage
['🔥', '🔥'], // fire
['🚀', '🚀'], // rocket
['🔗', '🔗'], // link
['👥', '👥'], // busts in silhouette
['🤝', '🤝'], // handshake
['🎯', '🎯'], // direct hit
['⭐', '⭐'], // star
['🧩', '🧩'], // puzzle piece
['🔓', '🔓'], // unlocked
['🟣', '🟣'], // purple circle (Polygon)
['✅', '✅'] // check mark
];
// Angles mirror the ones the landing pages already support, so a click on the
// ad continues the same hook it started with.
const ANGLES = {
general: { label: 'The straight pitch', brief: 'the plain honest pitch: a crypto team build on a public smart contract, you need two people, payments are person to person' },
two: { label: 'You only need two', brief: 'the whole job is getting two people and helping them get their two — depth, not width, and nothing else to memorise' },
pocket: { label: 'Pocket change', brief: 'the entry is small and one-time, this is not a monthly subscription that bleeds you' },
phone: { label: 'Phone only', brief: 'the entire thing runs from a phone — no laptop, no office, no stock, no shipping' },
graveyard: { label: 'Side hustle graveyard', brief: 'this one does not vanish like every other program, because the contract is public, unchangeable and holds nobody\'s money' },
verify: { label: 'Verify it yourself', brief: 'do not trust anyone, read the contract on-chain yourself before you put in a cent' }
};
function angles() {
return Object.keys(ANGLES).map(function (k) { return { key: k, label: ANGLES[k].label }; });
}
function cp(s) { return Array.from(String(s || '')); }
function cpLen(s) { return cp(s).length; }
// Trim to a code-point budget on a word boundary where possible, so an ad never
// ends mid-word. Falls back to a hard cut for a single long token.
function fit(s, budget) {
s = String(s || '').replace(/\s+/g, ' ').trim();
s = s.replace(/[<>]/g, '');
if (cpLen(s) <= budget) return s;
const words = s.split(' ');
let out = '';
for (let i = 0; i < words.length; i++) {
const next = out ? out + ' ' + words[i] : words[i];
if (cpLen(next) > budget) break;
out = next;
}
if (!out) out = cp(s).slice(0, budget).join('');
return out.replace(/[,;:\-–—]+$/, '').trim();
}
// Strip the decoration models add on their own so our budget maths holds.
function strip(s) {
return String(s || '')
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}]/gu, '')
.replace(/^["'“”\s*#>\-]+|["'“”\s*]+$/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function parse(text) {
const out = [];
// Split on an "AD n" marker, tolerating markdown and missing numbers.
const blocks = String(text || '').split(/(?:^|\n)[ \t]*[*#>\s]*AD\s*\d*[ \t]*[*#:]*[ \t]*(?=\n)/i);
blocks.forEach(function (b) {
const grab = function (label) {
const m = b.match(new RegExp('(?:^|\\n)[ \\t]*[*#>\\s]*' + label + '[ \\t]*[*#]*[ \\t]*:?[ \\t]*[*#]*[ \\t]*(.+)', 'i'));
return m ? strip(m[1]) : '';
};
const subject = grab('SUBJECT');
const l1 = grab('L1'), l2 = grab('L2'), l3 = grab('L3');
if (!subject || !l1) return;
out.push({ subject: subject, lines: [l1, l2, l3] });
});
return out;
}
// Apply emoji and enforce the measured limits. Line 3 is left bare on most ads
// because the live inventory does that too — a plain uppercase closing line
// reads as a call to action rather than more decoration.
function decorate(v, index) {
const pal = PALETTES[index % PALETTES.length];
const alt = PALETTES[(index + 5) % PALETTES.length];
const subject = fit(strip(v.subject), SUBJECT_BUDGET);
const lines = [];
const raw = v.lines.map(function (l) { return strip(l); });
lines.push(pal[0] + fit(raw[0], LINE_BUDGET) + pal[1]);
if (raw[1]) lines.push(alt[0] + fit(raw[1], LINE_BUDGET) + alt[1]);
else lines.push('');
// Third line: bare, and upper-cased when it is short enough to read as a CTA.
if (raw[2]) {
const third = fit(raw[2], MAX_LINE);
lines.push(cpLen(third) <= 18 ? third.toUpperCase() : third);
} else lines.push('');
return {
subject: pal[0] + subject + pal[1],
lines: lines,
preview: lines.filter(Boolean).join('\n')
};
}
function valid(v) {
if (cpLen(v.subject) > MAX_SUBJECT) return false;
if (v.lines.length !== BODY_LINES) return false;
if (v.lines.some(function (l) { return cpLen(l) > MAX_LINE; })) return false;
if (!v.lines[0]) return false;
return true;
}
// Generate `want` distinct ads. We ask for extras because some come back
// unusable, and dedupe on the subject so a member never sees two of the same.
async function generate(opts) {
const want = Math.max(1, Math.min(8, Number(opts.count) || 5));
const angle = ANGLES[opts.angle] ? opts.angle : 'general';
const ask = want + 3;
const instruction =
'You are writing tiny text ads for a rotating advertising network, promoting a crypto team build called The RM Circle.\n\n' +
'WHAT IT IS: The RM Circle is a team build on a public, verified smart contract on Polygon. The contract cannot be changed and holds no member funds — every payment goes person to person in the same transaction. The whole job is getting two personal referrals and helping them get their two.\n\n' +
'ANGLE FOR THESE ADS: ' + ANGLES[angle].brief + '\n\n' +
'ABSOLUTE RULES:\n' +
'- NEVER promise, guarantee, project or imply income, earnings, returns or profit. No "$", no numbers of money, no "earn", no "profit", no "income", no "passive".\n' +
'- No hype words: guaranteed, risk-free, get rich, financial freedom.\n' +
'- Do not invent figures or results.\n' +
'- Write PLAIN TEXT ONLY. Do NOT use emoji, asterisks, quotes or HTML — decoration is added later by the system.\n\n' +
'HARD LENGTH LIMITS (count every character, spaces included — going over makes the ad unusable):\n' +
'- SUBJECT: at most ' + SUBJECT_BUDGET + ' characters.\n' +
'- L1, L2, L3: at most ' + LINE_BUDGET + ' characters EACH.\n' +
'These are extremely short. Think billboard, not sentence. Fragments are good. L3 should be a short call to action.\n\n' +
'Write ' + ask + ' different ads. Vary the wording and the hook across them. Output EXACTLY this shape and nothing else:\n\n' +
'AD 1\nSUBJECT: \nL1: \nL2: \nL3: \n\nAD 2\nSUBJECT: \nL1: \nL2: \nL3: \n\n(and so on)';
const text = await suiteAi.generateRaw(instruction);
const parsed = parse(text);
const seen = {};
const out = [];
parsed.forEach(function (v, i) {
const d = decorate(v, i);
if (!valid(d)) return;
const key = d.subject.toLowerCase();
if (seen[key]) return;
seen[key] = 1;
out.push(d);
});
if (!out.length) throw new Error('The engine did not come back with usable ads — try again.');
return { angle: angle, variants: out.slice(0, want) };
}
module.exports = {
generate, angles, decorate, fit, parse, valid,
ANGLES, PALETTES, MAX_SUBJECT, MAX_LINE, BODY_LINES
};