← back to Costa Rica

routes/logo-agent.js

262 lines

'use strict';
// Logo Agent — hot-or-not tournament logo/brand builder for the Costa Rica
// marketplace. Zero external deps: express Router + Node crypto/fs. Sessions are
// per-file JSON under data/logo-agent-sessions/; the locked brand + SVG land in
// data/logo-agent-final.json + public/img/cr-logo.svg on finalize.
//
// Mechanic per component: show 3 variants -> user ranks 1/2/3 -> top-1 x top-2
// breed a NEW 3rd variant (categorical 50/50, numeric midpoint+mutation) ->
// re-rank -> converge when the same variant wins 3 rounds, or user locks.

const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

const router = express.Router();
const SESS_DIR = path.join(__dirname, '..', 'data', 'logo-agent-sessions');
const FINAL = path.join(__dirname, '..', 'data', 'logo-agent-final.json');
const SVG_OUT = path.join(__dirname, '..', 'public', 'img', 'cr-logo.svg');
fs.mkdirSync(SESS_DIR, { recursive: true });

// ---- token spaces (Costa Rica Directory brand) -----------------------------
const MOTIFS = ['volcano', 'wave', 'monstera', 'toucan', 'sun', 'coffee'];
const CONTAINERS = ['none', 'circle', 'shield'];
const PALETTES = {
  rainforest: { bg: '#0f2e1d', fg: '#eaf5ee', accent: '#2fb673', card: '#16301f' },
  pacific:    { bg: '#062a3a', fg: '#eaf6fb', accent: '#12a5c7', card: '#0c3546' },
  sunset:     { bg: '#2a1410', fg: '#fdeee6', accent: '#ff6b4a', card: '#3a1c16' },
  volcanic:   { bg: '#17161a', fg: '#f2f0f3', accent: '#e0b34a', card: '#221f27' },
  sand:       { bg: '#f5efe3', fg: '#231d14', accent: '#c8892f', card: '#efe6d4' },
  pura:       { bg: '#0b1f16', fg: '#f0faf4', accent: '#4bd07f', card: '#123024' },
};
const TYPE = [
  { display: 'Fraunces, Georgia, serif',        ui: 'Inter, system-ui, sans-serif' },
  { display: '"Playfair Display", serif',        ui: '"Work Sans", sans-serif' },
  { display: '"DM Serif Display", serif',        ui: '"DM Sans", sans-serif' },
  { display: 'Poppins, sans-serif',              ui: 'Inter, sans-serif' },
  { display: '"Cormorant Garamond", serif',      ui: '"Nunito Sans", sans-serif' },
];
const TAGLINES = [
  'Discover Costa Rica',
  'Pura Vida, planned',
  'Stay. Explore. Belong.',
  'Your Costa Rica, curated',
  'Tourism · Rentals · Local',
  'The whole coast, one place',
  'Book the real Costa Rica',
  'Where the wild is welcome',
];
const LAYOUTS = ['icon-left', 'icon-top', 'icon-only', 'wordmark-only'];

const COMPONENTS = ['glyph', 'palette', 'typography', 'tagline', 'layout', 'openspace'];
const rnd = (n) => crypto.randomInt(n);
const pick = (arr) => arr[rnd(arr.length)];
const jitter = (v, amt, lo, hi) => Math.max(lo, Math.min(hi, v + (rnd(2 * amt + 1) - amt)));
const id = () => crypto.randomBytes(8).toString('hex');

// A "genome" is the full brand token set; each round mutates ONE component's genes.
function randomGenesFor(component) {
  switch (component) {
    case 'glyph':      return { motif: pick(MOTIFS), container: pick(CONTAINERS), weight: 4 + rnd(9), dominance: 40 + rnd(50) };
    case 'palette':    return { palette: pick(Object.keys(PALETTES)) };
    case 'typography': return { type: rnd(TYPE.length) };
    case 'tagline':    return { tagline: rnd(TAGLINES.length) };
    case 'layout':     return { layout: pick(LAYOUTS) };
    case 'openspace':  return { density: 20 + rnd(80) };
    default:           return {};
  }
}
function crossover(component, a, b) {
  switch (component) {
    case 'glyph': return {
      motif: rnd(2) ? a.motif : b.motif,
      container: rnd(2) ? a.container : b.container,
      weight: jitter(Math.round((a.weight + b.weight) / 2), 1, 3, 14),
      dominance: jitter(Math.round((a.dominance + b.dominance) / 2), 6, 30, 95),
    };
    case 'palette':    return { palette: rnd(2) ? a.palette : b.palette };
    case 'typography': return { type: rnd(2) ? a.type : b.type };
    case 'tagline':    return { tagline: rnd(2) ? a.tagline : b.tagline };
    case 'layout':     return { layout: rnd(2) ? a.layout : b.layout };
    case 'openspace':  return { density: jitter(Math.round((a.density + b.density) / 2), 8, 10, 100) };
    default:           return { ...a };
  }
}

// ---- deterministic SVG glyph -----------------------------------------------
function motifPath(motif, w) {
  const sw = w; // stroke width scales with the "weight" gene
  switch (motif) {
    case 'volcano': return `<path d="M14 46 L32 16 L50 46 Z" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linejoin="round"/><path d="M24 30 Q32 22 40 30" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round"/>`;
    case 'wave':    return `<path d="M10 34 Q20 22 30 34 T50 34" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round"/><path d="M10 42 Q20 30 30 42 T50 42" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" opacity="0.6"/>`;
    case 'monstera':return `<path d="M32 12 C14 18 12 44 30 50 C48 44 50 18 32 12 Z" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linejoin="round"/><path d="M32 16 L32 50 M22 26 L30 30 M42 26 L34 30 M24 38 L30 40 M40 38 L34 40" stroke="currentColor" stroke-width="${Math.max(2, sw - 2)}" stroke-linecap="round"/>`;
    case 'toucan':  return `<circle cx="26" cy="26" r="10" fill="none" stroke="currentColor" stroke-width="${sw}"/><path d="M34 22 Q52 20 50 30 Q40 30 34 28 Z" fill="currentColor"/>`;
    case 'sun':     return `<circle cx="32" cy="32" r="9" fill="none" stroke="currentColor" stroke-width="${sw}"/>` + Array.from({ length: 8 }, (_, i) => { const a = (i * Math.PI) / 4; const x1 = 32 + Math.cos(a) * 15, y1 = 32 + Math.sin(a) * 15, x2 = 32 + Math.cos(a) * 21, y2 = 32 + Math.sin(a) * 21; return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round"/>`; }).join('');
    case 'coffee':  return `<ellipse cx="32" cy="32" rx="12" ry="16" fill="none" stroke="currentColor" stroke-width="${sw}"/><path d="M32 16 Q26 32 32 48 Q38 32 32 16" fill="none" stroke="currentColor" stroke-width="${Math.max(2, sw - 1)}"/>`;
    default:        return '';
  }
}
function containerWrap(container, inner, w) {
  if (container === 'circle') return `<circle cx="32" cy="32" r="30" fill="none" stroke="currentColor" stroke-width="${Math.max(2, w - 1)}"/>${inner}`;
  if (container === 'shield') return `<path d="M32 4 L58 12 V34 Q58 52 32 60 Q6 52 6 34 V12 Z" fill="none" stroke="currentColor" stroke-width="${Math.max(2, w - 1)}" stroke-linejoin="round"/>${inner}`;
  return inner;
}
function buildSvg(genome, { standalone = false } = {}) {
  const pal = PALETTES[genome.palette] || PALETTES.rainforest;
  const g = genome.glyph || {};
  const glyph = containerWrap(g.container || 'none', motifPath(g.motif || 'volcano', g.weight || 6), g.weight || 6);
  const color = standalone ? pal.accent : 'currentColor';
  return `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Costa Rica Directory mark" style="color:${color}">${glyph}</svg>`;
}

// ---- lockbrand assembly (what the UI renders per variant) -------------------
function assemble(genome) {
  const pal = PALETTES[genome.palette] || PALETTES.rainforest;
  const type = TYPE[genome.type ?? 0];
  const tagline = TAGLINES[genome.tagline ?? 0];
  return {
    svg: buildSvg(genome),
    palette: pal,
    type,
    tagline,
    layout: genome.layout || 'icon-left',
    density: genome.density ?? 50,
    genome,
  };
}

function loadSession(sid) {
  // L1 — path-traversal guard: sid is a 16-hex token (crypto.randomBytes(8)).
  // Reject anything else before it reaches path.join (blocks ../ escapes).
  if (!/^[0-9a-f]{16}$/.test(sid)) return null;
  const f = path.join(SESS_DIR, `${sid}.json`);
  if (!fs.existsSync(f)) return null;
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}
function saveSession(s) {
  // Self-heal: re-create SESS_DIR if it was removed (dir is made once at require-time,
  // so a cleanup/rmdir between boot and now would otherwise ENOENT-500 the next write).
  fs.mkdirSync(SESS_DIR, { recursive: true });
  fs.writeFileSync(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2));
}

function newRound(component, base) {
  // 3 fresh variants; if we have a locked base genome, keep the other components fixed.
  return [0, 1, 2].map(() => ({ vid: id(), ...(base || {}), ...randomGenesFor(component) }));
}

// POST /session/start
router.post('/session/start', (_req, res) => {
  const s = {
    id: id(), createdAt: new Date().toISOString(),
    component: COMPONENTS[0], componentIdx: 0,
    locked: {}, base: {},
    round: 1, winStreak: 0, lastWinnerSig: null,
    xp: 0, badges: [],
    variants: newRound(COMPONENTS[0], {}),
  };
  saveSession(s);
  res.json({ session_id: s.id, ...roundView(s) });
});

function sigOf(component, genes) {
  const g = genes || {};
  return JSON.stringify(component === 'glyph' ? { m: g.motif, c: g.container } :
    component === 'palette' ? g.palette : component === 'typography' ? g.type :
    component === 'tagline' ? g.tagline : component === 'layout' ? g.layout :
    Math.round((g.density ?? 50) / 15));
}
function roundView(s) {
  return {
    component: s.component, componentIdx: s.componentIdx, totalComponents: COMPONENTS.length,
    round: s.round, winStreak: s.winStreak, xp: s.xp, badges: s.badges,
    converged: s.winStreak >= 3,
    variants: s.variants.map((v) => ({ vid: v.vid, ...assemble({ ...s.base, ...v }) })),
    locked: s.locked,
  };
}

// POST /rank/:sid  { ranks: [vid1, vid2, vid3] }  (vid1 = best)
router.post('/rank/:sid', (req, res) => {
  const s = loadSession(req.params.sid);
  if (!s) return res.status(404).json({ error: 'session not found' });
  const ranks = (req.body && req.body.ranks) || [];
  if (ranks.length < 2) return res.status(400).json({ error: 'need at least 2 ranked vids' });
  const byId = Object.fromEntries(s.variants.map((v) => [v.vid, v]));
  const first = byId[ranks[0]], second = byId[ranks[1]];
  if (!first || !second) return res.status(400).json({ error: 'unknown vid in ranks' });

  s.xp += 10;
  const sig = sigOf(s.component, first);
  if (sig === s.lastWinnerSig) { s.winStreak += 1; s.xp += 5; }
  else { s.winStreak = 1; s.lastWinnerSig = sig; }
  if (!s.badges.includes('first-critic')) s.badges.push('first-critic');
  if (s.winStreak >= 3 && !s.badges.includes('convergence')) s.badges.push('convergence');

  // breed the top-2 into a new 3rd; keep the reigning champ + runner-up.
  const child = { vid: id(), ...crossover(s.component, first, second) };
  s.variants = [{ ...first }, { ...second }, child];
  s.round += 1;
  saveSession(s);
  res.json({ session_id: s.id, ...roundView(s), championVid: first.vid });
});

// POST /lock/:sid  { winner_id }
router.post('/lock/:sid', (req, res) => {
  const s = loadSession(req.params.sid);
  if (!s) return res.status(404).json({ error: 'session not found' });
  const winner = (s.variants || []).find((v) => v.vid === (req.body && req.body.winner_id));
  if (!winner) return res.status(400).json({ error: 'winner_id not in current variants' });

  const { vid, ...genes } = winner;
  s.locked[s.component] = genes;
  s.base = { ...s.base, ...genes };
  s.xp += 25;
  if (!s.badges.includes('decisive')) s.badges.push('decisive');

  if (s.componentIdx + 1 < COMPONENTS.length) {
    s.componentIdx += 1;
    s.component = COMPONENTS[s.componentIdx];
    s.round = 1; s.winStreak = 0; s.lastWinnerSig = null;
    s.variants = newRound(s.component, s.base);
    saveSession(s);
    return res.json({ session_id: s.id, done: false, ...roundView(s) });
  }
  // all six locked
  if (!s.badges.includes('tastemaker')) s.badges.push('tastemaker');
  s.finalized = true;
  saveSession(s);
  res.json({ session_id: s.id, done: true, xp: s.xp, badges: s.badges, brand: assemble(s.base) });
});

// GET /state/:sid
router.get('/state/:sid', (req, res) => {
  const s = loadSession(req.params.sid);
  if (!s) return res.status(404).json({ error: 'session not found' });
  res.json({ session_id: s.id, ...roundView(s), finalized: !!s.finalized });
});

// POST /finalize/:sid  -> writes SVG + brand JSON to disk
router.post('/finalize/:sid', (req, res) => {
  const s = loadSession(req.params.sid);
  if (!s) return res.status(404).json({ error: 'session not found' });
  const brand = assemble(s.base);
  const pal = brand.palette;
  fs.mkdirSync(path.dirname(SVG_OUT), { recursive: true });
  fs.writeFileSync(SVG_OUT, buildSvg(s.base, { standalone: true }));
  const cssVars = `:root{\n  --cr-bg:${pal.bg};\n  --cr-fg:${pal.fg};\n  --cr-accent:${pal.accent};\n  --cr-card:${pal.card};\n  --cr-font-display:${brand.type.display};\n  --cr-font-ui:${brand.type.ui};\n}`;
  const out = { finalizedAt: new Date().toISOString(), brand, cssVars, svgPath: '/img/cr-logo.svg' };
  fs.writeFileSync(FINAL, JSON.stringify(out, null, 2));
  res.json({ ok: true, ...out });
});

// Expose pure internals for the test suite WITHOUT changing the mount contract
// (Express routers are functions; attaching props is safe, `app.use(router)` still works).
router._internals = {
  crossover, buildSvg, assemble, randomGenesFor, motifPath,
  MOTIFS, CONTAINERS, PALETTES, TYPE, TAGLINES, LAYOUTS, COMPONENTS,
};

module.exports = router;