<!-- SOLID CITY · part 9 -->
hits } -> { ok, rank, total, row }
   Limits: 6 results per IP per 10 min; time 20 s..1 h; kills 0..17; name 2-16 letters/digits/space/._- (else PILOT).
   Stored in data/city-space-scores.json (best 400 rows kept; re-read when the file changes on disk). */
const fs = require('fs'), path = require('path'), express = require('express');
const router = express.Router();
const FILE = process.env.CITY_SPACE_FILE || path.join(__dirname, '..', 'data', 'city-space-scores.json');
const TOTAL = 17, MAX_ROWS = 400;
let cache = { t: -1, rows: [] };
function load() {
  try { const st = fs.statSync(FILE); if (st.mtimeMs !== cache.t) { const j = JSON.parse(fs.readFileSync(FILE, 'utf8')); cache = { t: st.mtimeMs, rows: Array.isArray(j.rows) ? j.rows : [] }; } }
  catch (e) { if (e.code === 'ENOENT') cache = { t: -1, rows: cache.rows || [] }; }
  return cache.rows;
}
function save(rows) { const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify({ rows }), { mode: 0o640 }); fs.renameSync(tmp, FILE); cache = { t: fs.statSync(FILE).mtimeMs, rows }; }
const cmp = (a, b) => (b.kills - a.kills) || (a.timeMs - b.timeMs) || (a.at - b.at);
const pub = (r) => ({ name: r.name, timeMs: r.timeMs, kills: r.kills, at: r.at });
const hits = new Map();
function limited(ip) {
  const now = Date.now(), a = (hits.get(ip) || []).filter((t) => now - t < 600e3);
  if (a.length >= 6) { hits.set(ip, a); return true; }
  a.push(now); hits.set(ip, a); if (hits.size > 5000) hits.clear(); return false;
}
function cleanName(s) { s = String(s || '').normalize('NFKC').replace(/[^\p{L}\p{N} ._-]/gu, '').replace(/\s+/g, ' ').trim().slice(0, 16); return s.length >= 2 ? s : 'PILOT'; }

router.get('/api/city/space/board', (req, res) => {
  const rows = load().slice().sort(cmp);
  res.set('Cache-Control', 'no-store').json({ ok: true, total: rows.length, aliens: TOTAL, top: rows.slice(0, 10).map(pub) });
});
router.post('/api/city/space/score', express.json({ limit: '2kb' }), (req, res) => {
  const b = req.body || {};
  const timeMs = Math.round(Number(b.timeMs)), kills = Math.round(Number(b.kills)), hitsTaken = Math.max(0, Math.min(999, Math.round(Number(b.hits) || 0)));
  if (!Number.isFinite(timeMs) || timeMs < 20000 || timeMs > 3600000 || !Number.isFinite(kills) || kills < 0 || kills > TOTAL) return res.status(400).json({ ok: false, error: 'bad score' });
  if (limited(String(req.ip || ''))) return res.status(429).json({ ok: false, error: 'slow down: max 6 results per 10 minutes' });
  const row = { name: cleanName(b.name), timeMs, kills, hits: hitsTaken, at: Date.now() };
  const rows = load().slice(); rows.push(row); rows.sort(cmp);
  const rank = rows.indexOf(row) + 1;
  try { save(rows.slice(0, MAX_ROWS)); } catch (e) { return res.status(500).json({ ok: false, error: 'could not save' }); }
  res.set('Cache-Control', 'no-store').json({ ok: true, rank, total: rows.length, row: pub(row) });
});
module.exports = router;


===== FILE: lib/city.js =====
'use strict';
const fs = require('fs');
const path = require('path');
const royal = require('./royal'); // ROYAL RIGHTS GATE (2026-10-12)
// SOLID CITY (/city) — server side is deliberately tiny: the whole world
// (terrain, city, ship, sky) is generated and rendered client-side from
// public/city/. The server only answers "who am I" and relays SABLE talk.
//
//   GET  /api/city/me     -> { loggedIn, name, csrf }   (no-store)
//   POST /api/city/sable  -> login required, 40/hour per user, then handed to
//                            the EXISTING /api/sable/chat route stack
//                            (its abuseGuard + 20/min rateLimit + brain), so
//                            SABLE keeps one implementation.
//   GET  /api/city/songs  -> the King's SolidBigIsland catalogue for the city
//                            player (no lyrics; lazy-loaded by the client).
//                            COVERS (kind:'cover', other artists' songs) are
//                            admin-only here by default — see COVERS_PUBLIC.
//   GET  /api/city/songs/:n/lyrics -> lyrics for one song (same cover rule).
//   POST /api/city/soulprint -> login required, 30/hour per user, then handed to the EXISTING
//                            /api/soulprint/chat relay stack (its login gate, abuseGuard, 20/min
//                            rateLimit and brain) with a Soul Print Tower context. Stateless: only
//                            this visit's last few lines (sent by the client) — the King's private
//                            tower log (soulprint.js / data/soulprint) is never read here.
//   /api/city/builds…     -> 🔨 THE FORGE world builds (lib/city-builds.js).
//   POST /api/city/citizen -> 👥 AI CITIZENS of Solid City (city-only characters modelled on the King's
//                            chat bots). Login + CSRF (global middleware), 60/hour per user plus 6/min
//                            burst, site-wide caps (CITIZEN_AI_HOURLY / CITIZEN_AI_DAILY), 4 in flight,
//              