Files
gods-eye-view/mobile/ui/chatSim.js
T
Claude a6f98ce3c1 feat(mobile): Live and Record lanes, Signal, Need eyes and Crews
Every room has a Twitch-style Live lane (simulated crowd chat, emotes,
reactions that also rise from the globe, slow mode, witness glow) and a
Reddit/X-style Record (Signal-weighted corroborate/dispute, threads,
quotes, notes, synthesis). Chat lines with evidence or corroboration
promote to the Record, and a rules-based clerk summarizes chat with
citations. Signal is a receipts ledger; Need eyes requests reward a
sealed answer with a First eyes byline. Crews add persistent groups.
Simulated and example content is labelled throughout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
2026-10-23 07:29:14 +00:00

1090 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Simulated Live chat (DESIGN.md §12.6, §12.8 "Honest labels").
*
* A believable crowd for the demo: per-genre pools written like real people
* on the ground and in the hobby (pilots, locals, weather nerds, skeptics,
* jokers, moderators), realistic pacing with bursts, and replies to what the
* person types. Every handle is fictional and every message is flagged
* `sim: true`; the lane that shows them is labelled "Simulated chat · demo".
* Numbers quoted in messages come only from the situation's live metrics.
*/
import { fmtNum } from './dom.js';
import { headlineOf, fmtKm, cardinal } from './bands.js';
import { simTier, hashStr } from './signal.js';
/* ---------------- Personas (fictional) ---------------- */
export const PERSONAS = {
mod: [
{ handle: '@deskmod_rae', mod: true, floor: 900 },
{ handle: '@nightdesk_mod', mod: true, floor: 760 },
],
pilot: [
{ handle: '@flaps30', domain: 'Pilot', floor: 420 },
{ handle: '@heavy_tess', domain: 'Pilot', floor: 640 },
{ handle: '@atc_listener', floor: 220 },
],
wx: [
{ handle: '@wxdan', domain: 'Met', floor: 520 },
{ handle: '@cape_and_shear', floor: 300 },
{ handle: '@radar_rina', domain: 'Met', floor: 820 },
],
local: [
{ handle: '@onthe5th', local: true, floor: 140 },
{ handle: '@harborkid_22', local: true, floor: 90 },
{ handle: '@ridge_rd_amy', local: true, floor: 260 },
{ handle: '@corner_deli_mo', local: true, floor: 40 },
],
skeptic: [
{ handle: '@actually_tho', floor: 380 },
{ handle: '@sourcepls', floor: 560 },
],
joker: [
{ handle: '@lagspike', floor: 0 },
{ handle: '@pigeonwatch', floor: 60 },
{ handle: '@buffering_bev', floor: 10 },
],
orbit: [
{ handle: '@tle_tom', floor: 700 },
{ handle: '@pass_predictor', floor: 330 },
],
sea: [{ handle: '@bridgewing_ko', domain: 'Mariner', floor: 610 }],
medic: [
{ handle: '@medic_off_shift', domain: 'First responder', floor: 480 },
],
scanner: [
{ handle: '@scanner_sal', floor: 350 },
{ handle: '@freq_hopper', floor: 180 },
],
photo: [{ handle: '@longlens_ben', floor: 290 }],
newbie: [
{ handle: '@first_time_here', floor: 0 },
{ handle: '@lurker9', floor: 0 },
{ handle: '@k8_mobile', floor: 20 },
{ handle: '@just_scrolling', floor: 0 },
],
};
const ALL = Object.entries(PERSONAS).flatMap(([role, list]) =>
list.map((p) => ({ ...p, role })),
);
export const personaOf = (handle) => ALL.find((p) => p.handle === handle);
/** Where watchers are (for "hi from …" lines and crowd reactions). */
export const WORLD = [
['Lisbon', 38.72, -9.14, 3],
['Manila', 14.6, 120.98, 6],
['Lagos', 6.52, 3.38, 5],
['São Paulo', -23.55, -46.63, 6],
['Mumbai', 19.08, 72.88, 7],
['Jakarta', -6.2, 106.85, 6],
['Tokyo', 35.68, 139.69, 7],
['Seoul', 37.57, 126.98, 5],
['London', 51.51, -0.13, 6],
['Berlin', 52.52, 13.4, 4],
['Toronto', 43.65, -79.38, 4],
['Chicago', 41.88, -87.63, 4],
['Mexico City', 19.43, -99.13, 6],
['Sydney', -33.87, 151.21, 4],
['Nairobi', -1.29, 36.82, 3],
['Cairo', 30.04, 31.24, 5],
['Istanbul', 41.01, 28.98, 5],
['Denver', 39.74, -104.99, 3],
['Seattle', 47.61, -122.33, 3],
['Austin', 30.27, -97.74, 3],
['Houston', 29.76, -95.37, 4],
['Miami', 25.76, -80.19, 3],
['New York', 40.71, -74.01, 7],
['Los Angeles', 34.05, -118.24, 6],
['Paris', 48.86, 2.35, 5],
['Madrid', 40.42, -3.7, 4],
['Warsaw', 52.23, 21.01, 3],
['Helsinki', 60.17, 24.94, 2],
['Bangkok', 13.76, 100.5, 5],
['Singapore', 1.35, 103.82, 4],
['Taipei', 25.03, 121.57, 4],
['Auckland', -36.85, 174.76, 2],
['Bogotá', 4.71, -74.07, 4],
['Johannesburg', -26.2, 28.05, 3],
['Dubai', 25.2, 55.27, 3],
['Reykjavík', 64.15, -21.94, 1],
];
const WORLD_W = WORLD.reduce((a, w) => a + w[3], 0);
export function worldPoint(rand = Math.random) {
let x = rand() * WORLD_W;
for (const w of WORLD) {
x -= w[3];
if (x <= 0)
return {
city: w[0],
lat: w[1] + (rand() - 0.5) * 1.6,
lon: w[2] + (rand() - 0.5) * 1.6,
};
}
return { city: WORLD[0][0], lat: WORLD[0][1], lon: WORLD[0][2] };
}
/* ---------------- Genre ---------------- */
export function chatGenre(s) {
const k =
`${s?.kind || ''} ${s?.archetype || ''} ${s?.title || ''}`.toLowerCase();
if (s?.momentId || /circling|moment|climber/.test(k)) return 'circling';
if (/cyclone|typhoon|hurricane/.test(k)) return 'cyclone';
if (/quake/.test(k)) return 'quake';
if (/fire|volcano/.test(k)) return 'fire';
if (/lightning|rain|storm|radar/.test(k)) return 'storm';
if (/squawk/.test(k)) return 'squawk';
if (/gnss|jam/.test(k)) return 'gnss';
if (/launch/.test(k)) return 'launch';
if (/iss|pass|aurora|orbit|starlink/.test(k)) return 'orbit';
if (/camera|cctv|eye/.test(k)) return 'eye';
if (/queue|strait|port|ship|vessel/.test(k)) return 'sea';
if (/heli|rotor/.test(k)) return 'heli';
if (/airport|military|jet|flight|tanker/.test(k)) return 'air';
return 'default';
}
/* ---------------- Metric helpers ---------------- */
function fmtMetric(m) {
if (!m) return '';
const v =
typeof m.value === 'number'
? fmtNum(m.value, m.value % 1 ? 1 : 0)
: String(m.value ?? '');
return `${v}${m.unit ? ` ${m.unit}` : ''}`.trim();
}
function vars(s) {
const ms = s.metrics || [];
const hl = headlineOf(s);
const find = (k) =>
ms.find((m) =>
String(m.label).toLowerCase().startsWith(String(k).toLowerCase()),
);
return {
place: s.place || s.title || 'there',
title: s.title || '',
m0: fmtMetric(ms[0]),
m1: fmtMetric(ms[1]),
m2: fmtMetric(ms[2]),
l0: String(ms[0]?.label || '').toLowerCase(),
l1: String(ms[1]?.label || '').toLowerCase(),
hl: hl ? `${hl.value}${hl.unit ? ` ${hl.unit}` : ''}` : '',
hll: hl?.label || '',
dir: cardinal(s.media?.headingDeg) || '',
find: (k) => fmtMetric(find(k)),
};
}
/** "{m0}" style templates; returns '' if any placeholder is empty. */
export function fill(t, V, extra = {}) {
let bad = false;
const out = t.replace(/\{([\w:]+)\}/g, (_, key) => {
let v = '';
if (key.startsWith('m:')) v = V.find(key.slice(2));
else v = extra[key] ?? V[key] ?? '';
if (!v) bad = true;
return v;
});
return bad ? '' : out;
}
/* ---------------- Pools ---------------- */
// [role, text]. Text may use {place} {title} {m0} {m1} {hl} {hll} {m:Label}.
const COMMON = [
['newbie', 'first time in a live room, this is wild'],
['newbie', 'how is everyone seeing this so fast'],
['newbie', 'what does the gold ring next to some names mean?'],
[
'mod',
'Gold ring = that person has a sealed capture near this. Their chat glows, it doesn’t mean they’re right SealIt',
],
['joker', 'my boss thinks I’m in a spreadsheet rn'],
['joker', 'chat moving faster than my wifi'],
[
'skeptic',
'reminder that chat is not a source. Record lane or it didn’t happen',
],
['newbie', 'is the map live or a replay?'],
[
'mod',
'Map is live. Anything with a seal is a real capture; anything that says Example or Simulated is demo content',
],
['local', 'refreshing this instead of sleeping, as one does'],
[
'photo',
'if anyone nearby has a long lens, the Need eyes pins are where it’s useful',
],
['newbie', 'o7 from the night shift'],
['joker', 'Receipts or it’s fan fiction'],
];
const GREET = [
'hi from {city}',
'watching from {city} o7',
'{city} checking in',
'evening from {city}',
'morning from {city}, coffee + this',
];
const POOLS = {
circling: [
[
'scanner',
'NYPD aviation on the Special Ops freq, they’re holding over 34th',
],
[
'local',
'I can see two of them from my office, both orbiting tight around the top of the building',
],
[
'pilot',
'that orbit radius looks like ~500 m, classic news-helo orbit for a stable shot',
],
[
'local',
'there are people on the observation deck pointing UP. something is on the mast',
],
[
'photo',
'from 5th Ave with a 400mm you can see two figures above the 86th floor deck EyesUp',
],
['skeptic', 'two figures according to who? show me a sealed capture'],
[
'local',
'sealed a clip from the 34th St side, posting to Record SealIt',
{ evidence: 0.25 },
],
['joker', 'King Kong reboot looking real low budget'],
[
'pilot',
'police heli is lower, around 1,000 ft, news birds stacked above at 1,500',
],
['scanner', 'ESU requested to the 86th floor, that’s rope rescue'],
[
'mod',
'Please don’t share names of anyone on the building. Slow mode is on.',
],
['newbie', 'why are there 3 helicopters for 2 people'],
[
'pilot',
'one police, two news. the news ones are there for the shot, not the rescue',
],
['local', 'crowd forming on 34th, cops pushing everyone back to Broadway'],
[
'skeptic',
'the climbers thing is unconfirmed until someone up close seals it. could be maintenance crew',
],
['local', 'maintenance doesn’t wave a banner HeliSpin'],
[
'photo',
'banner says something, can’t read it, the wind keeps flipping it',
],
['scanner', 'they’re calling it a “protest climb” on the radio now'],
['joker', 'imagine commuting to work and it’s this'],
[
'local',
'i’m at 1 Penn, 30th floor, perfect angle. sealing now',
{ evidence: 0.6 },
],
],
cyclone: [
[
'wx',
'eyewall replacement explains why the peak dipped but the wind field got wider',
],
[
'wx',
'sustained {m:Wind} is still a serious storm, don’t let the dip fool you',
],
['local', 'power flickering here twice in the last ten minutes'],
[
'local',
'outer bands hit the waterfront, chairs on the pier are sliding StormMode',
],
['skeptic', 'whoever is posting Cat 5 clips: that’s from 2013'],
[
'wx',
'pressure {m:Pressure} — watch whether it falls again after the EWRC',
],
['newbie', 'is it going to hit Manila?'],
['wx', 'the cone is wide this far out. look at the cone, not the line'],
[
'mod',
'If you’re in the warning area: follow PAGASA, not chat. We’re a window, not an alert service.',
],
['joker', 'StormMode activated, snacks acquired'],
[
'local',
'my uncle in Aparri says the sea is already over the seawall',
{ evidence: null },
],
['sea', 'ships are leaving the anchorage, that’s a tell'],
['photo', 'IR loop is gorgeous, eye is clearing again EyesUp'],
[
'skeptic',
'“my uncle says” is not evidence, but a sealed photo from him would be',
],
[
'local',
'sealed a clip of the harbour from the 3rd floor, posting',
{ evidence: 3.8 },
],
],
storm: [
['wx', '{m0} in the last hour, that’s a very electric line'],
[
'local',
'constant flashes to the south, thunder every couple seconds StormMode',
],
['local', 'gutters already overflowing on my street'],
['wx', 'the core is moving with the line, maybe 40 min to the coast'],
['newbie', 'is it safe to drive home now?'],
[
'medic',
'if you can wait 30–40 min, wait. most crashes in these are in the first wave of rain',
],
[
'skeptic',
'that “tornado” photo going around is from Oklahoma last spring',
],
[
'photo',
'got a lightning shot from the parking garage, 8 s exposure',
{ evidence: 2.1 },
],
['joker', 'my dog has filed a formal complaint'],
['wx', 'peak density {m1}, wow'],
['local', 'power just went out on our block'],
[
'mod',
'Flood photos: please post them to the Record with a seal so the room can use them',
],
[
'local',
'underpass on 7th is a lake, cars turning around',
{ evidence: 0.8 },
],
],
quake: [
['local', 'long rolling one, maybe 20 seconds, 6th floor'],
['local', 'glasses rattled, nothing fell here'],
['wx', 'depth {m:Depth} — shallow ones feel sharper'],
['newbie', 'tsunami??'],
[
'mod',
'Tsunami advisories come from the national warning centre. Not from chat. Link is in the Record.',
],
[
'skeptic',
'first automatic solutions often fix depth at 10 km, wait for the reviewed one',
],
['local', 'aftershock just now, small, felt it in the chair'],
['joker', 'my cat knew 10 seconds before. cats are the real sensors'],
['medic', 'check gas lines if you’re in an older building'],
['local', 'sealed a clip of the hallway lights swinging', { evidence: 12 }],
[
'skeptic',
'magnitude keeps getting revised, quote the latest from the brief',
],
],
fire: [
['local', 'ash on the cars this morning, sky orange to the east'],
['local', 'smoke is laying right down the valley'],
['wx', 'wind is pushing the plume east, look at the wind lens'],
['newbie', 'how many fires is that?'],
[
'wx',
'each hotspot is a ~375 m pixel, {m0} hotspots isn’t that many separate fires',
],
[
'skeptic',
'the “fire jumped the river” post is not on the perimeter layer',
],
['medic', 'close windows, N95 if you have one, AQI will spike tonight'],
[
'photo',
'sealed a photo from the ridge road, glow over the second ridge',
{ evidence: 6.5 },
],
['joker', 'my car is now beige, it was black'],
[
'mod',
'Evacuation info: county alerts only. Don’t relay road closures from chat as fact.',
],
],
squawk: [
['pilot', '7700 is a general emergency. medical diversions use it too'],
[
'pilot',
'{m:Altitude} and {m:Speed}, looks like a controlled descent to me',
],
['atc', 'they asked for vectors to the nearest suitable, sounded calm'],
['newbie', 'hijack??'],
['skeptic', 'not a hijack. that would be 7500. please stop'],
['scanner', 'tower has fire trucks positioned, that’s standard'],
[
'local',
'heard it low over the harbour, gear down, engines sounded normal',
],
['joker', 'pilots when the coffee machine breaks: 7700'],
[
'pilot',
'track turned north a few min ago, lining up for the long runway',
],
['mod', 'Please don’t speculate about passengers. Facts in the Record.'],
],
gnss: [
[
'pilot',
'flew through there last week, GPS dropped, we were on inertial for ~20 min',
],
['newbie', 'does this affect phones?'],
[
'wx',
'the layer is aircraft self-reporting nav accuracy, {m0} aircraft, not phones',
],
[
'skeptic',
'calling everything jamming overstates it, some are equipment faults',
],
['sea', 'ships near the coast report the same pattern at night'],
['joker', 'my phone thinks I’m in the sea again'],
['mod', 'Stick to what the data shows. No attribution games in Live.'],
],
orbit: [
[
'orbit',
'pass is ~6 min horizon to horizon, look west-southwest at the start',
],
['orbit', 'it’s brighter than anything but the Moon tonight EyesUp'],
['newbie', 'how do I know which dot it is?'],
['orbit', 'it doesn’t blink and it moves steadily, planes blink'],
['local', 'clouds here, praying for a gap'],
['photo', 'tripod out, going for a 6 s exposure streak'],
['joker', 'waving at the astronauts o7'],
['skeptic', 'the viral fireball clip is a reentry from last year'],
['local', 'SAW IT. so bright. sealed photo', { evidence: 0.4 }],
],
launch: [
['orbit', 'weather is the only thing that can scrub this now'],
['local', 'causeway is packed, cars parked on the shoulder for a mile'],
['newbie', 'can you see it from Houston?'],
['orbit', 'not the launch, maybe the plume if it’s clear'],
['joker', 'LiftOff LiftOff LiftOff'],
['skeptic', 'T-0 is a window, not a promise'],
[
'photo',
'set up at the beach with 600mm, sealed test shot of the pad',
{ evidence: 9 },
],
['mod', 'Reminder: respect the exclusion zone. No drone footage please.'],
],
eye: [
['local', 'walking past now, way busier than the frame shows'],
['local', 'line out the door at the corner'],
[
'skeptic',
'the camera faces {dir}, the “crowd heading to the square” is walking away from it',
],
['newbie', 'is this actually live?'],
[
'mod',
'Frame refreshes every ~15 s from the city’s public camera. Faces are not identifiable at this res, keep it that way.',
],
['joker', 'i’m the pixel in the red jacket. hi mom'],
['local', 'lane closure still on, cones all the way to the light'],
[
'photo',
'sealed a photo from across the street, matches the frame',
{ evidence: 0.08 },
],
],
sea: [
[
'sea',
'anchored in the waiting area since morning, told to hold 12 more hours',
],
['sea', 'mostly scheduling, convoys run at set times'],
['newbie', 'why so many ships?'],
['skeptic', 'some of those are bunkering, not queuing'],
['joker', 'this is the DMV of oceans'],
['sea', 'AIS will show them moving in a column when the convoy starts'],
],
heli: [
['pilot', 'that’s a training pattern, same circuit every 6–7 min'],
['local', 'they do this all day, you stop hearing it'],
['newbie', 'is something wrong?'],
['pilot', 'nope, autorotation practice. looks scary, is routine'],
['joker', 'HeliSpin HeliSpin HeliSpin'],
['local', 'one just did a hover check right over the field'],
['mod', 'Alumni crew is in here tonight o7'],
],
air: [
[
'pilot',
'those racetracks are holding patterns, controllers stack arrivals',
],
['local', 'watching from the fence, one every couple minutes'],
['skeptic', 'not a record day, {m0} is high but normal peak'],
['atc', 'they just switched runways, that’s why the holds'],
['joker', 'plane spotters are the original live chat'],
[
'photo',
'sealed a clip with the approach lights in frame',
{ evidence: 1.4 },
],
],
default: [
['local', 'here now, what I see matches the map'],
['newbie', 'is this normal for this time of day?'],
['skeptic', '{m0} is within the usual range, don’t overread it'],
['joker', 'chat is the real content'],
],
};
/** Burst lines: short, loud, many people at once. */
const BURST = {
circling: [
'THEY’RE MOVING',
'one of them just climbed higher',
'ESU on the roof now',
'EyesUp EyesUp',
'banner is out!!',
'HeliSpin',
'oh no oh no',
'that’s so high',
'did you see that',
'they waved',
],
cyclone: [
'eye clearing!!',
'StormMode',
'new IR frame is insane',
'that gust',
'whoa',
'pressure dropping again?',
],
storm: [
'THAT FLASH',
'StormMode',
'lightning right over us',
'whoa',
'power just blinked',
'that one shook the windows',
],
quake: [
'AFTERSHOCK',
'felt that',
'here too',
'whoa',
'that was bigger',
'everyone ok?',
],
fire: [
'new hotspot!',
'wind shifted',
'whoa',
'that plume',
'eyes on the ridge EyesUp',
],
squawk: [
'ON FINAL',
'gear down',
'touchdown?',
'they’re down!!',
'o7',
'o7 crew',
],
gnss: ['new cell lit up', 'whoa', 'spreading north'],
orbit: [
'THERE IT IS',
'EyesUp EyesUp',
'so bright',
'o7 ISS',
'saw it!!',
'wow',
],
launch: [
'LiftOff',
'LIFTOFF',
'go go go',
'LiftOff LiftOff',
'max q',
'beautiful',
],
eye: [
'crowd surge',
'whoa',
'here comes the parade',
'EyesUp',
'look at the corner',
],
sea: ['convoy moving', 'here they go', 'whoa'],
heli: ['autorotation!', 'HeliSpin', 'nice flare', 'o7'],
air: ['go around!', 'whoa', 'heavy on final', 'nice landing'],
default: ['whoa', 'did you see that', 'EyesUp', 'wow'],
};
/** Reaction mix per genre (weights for heart, fire, eyes, wow). */
const MIX = {
circling: [3, 1, 5, 4],
cyclone: [3, 2, 3, 3],
storm: [2, 4, 2, 4],
quake: [4, 0, 3, 2],
fire: [3, 4, 2, 1],
squawk: [6, 0, 4, 1],
gnss: [1, 1, 4, 2],
orbit: [5, 1, 3, 5],
launch: [3, 6, 2, 5],
eye: [4, 2, 4, 2],
sea: [2, 1, 3, 2],
heli: [3, 2, 3, 3],
air: [3, 1, 3, 2],
default: [3, 1, 3, 2],
};
export function reactionKind(genre, rand = Math.random) {
const w = MIX[genre] || MIX.default;
const kinds = ['heart', 'fire', 'eyes', 'wow'];
let x = rand() * w.reduce((a, b) => a + b, 0);
for (let i = 0; i < 4; i += 1) {
x -= w[i];
if (x <= 0) return kinds[i];
}
return 'heart';
}
/* ---------------- Replies to the person ---------------- */
const REPLIES = [
{
re: /\b(hi|hey|hello|yo|sup|first)\b/i,
lines: [
['local', 'welcome {user} o7'],
[
'mod',
'Welcome {user}. Live is fast, the Record is where evidence goes.',
],
['newbie', 'hey {user}'],
],
},
{
re: /\b(fake|real|hoax|cgi|staged|lie|lying)\b/i,
lines: [
[
'skeptic',
'{user} fair question. sealed captures only, check the Record lane',
],
[
'mod',
'{user} If you think something is wrong, post a Correction in the Record. Corroborated corrections raise your Signal.',
],
['local', '{user} i’m literally here lol'],
],
},
{
re: /\b(safe|danger|ok|okay|scared|worried|evacuat)/i,
lines: [
[
'medic',
'{user} follow local officials for safety calls, chat can’t see your street',
],
[
'mod',
'{user} Nothing in this room is an alert. If you’re close, local channels first.',
],
],
},
{
re: /\b(seal|sealed|capture|photo|pic|video|clip)\b/i,
lines: [
['photo', '{user} SealIt pls, the room needs eyes from the ground'],
[
'local',
'{user} there’s a Need eyes pin near the scene if you’re close',
],
],
},
{
re: /\b(heli|helicopter|chopper|rotor)/i,
lines: [
[
'pilot',
'{user} rotorcraft orbiting low and slow, the track lens shows the circles clearly',
],
['scanner', '{user} one of them is police, the others are news'],
],
},
{
re: /\b(iss|satellite|starlink|orbit)\b/i,
lines: [
[
'orbit',
'{user} if it doesn’t blink and moves steady, that’s a satellite',
],
],
},
{
re: /\b(rain|flood|storm|lightning|wind|thunder)\b/i,
lines: [
[
'wx',
'{user} radar lens shows the core, the lightning dots are the last 15 min',
],
['local', '{user} it’s really coming down here'],
],
},
{
re: /\b(where|location|place|address)\b/i,
lines: [
['local', '{user} {place}, tap the room title for the map'],
[
'mod',
'{user} Exact spots of people stay private. The situation pin is on the globe.',
],
],
},
{
re: /\b(lol|lmao|haha|rofl|💀)\b/i,
lines: [
['joker', '{user} LMAO'],
['joker', '{user} chat is undefeated'],
],
},
{
re: /\b(how many|how much|number|count|stats?)\b/i,
lines: [
['wx', '{user} brief says {m0}{sep1}{m1}'],
[
'skeptic',
'{user} numbers are in the Brief with their source and age, chat rounds things',
],
],
},
{
re: /\?$/,
lines: [
['local', '{user} good q, nobody’s confirmed yet'],
[
'skeptic',
'{user} post it as a Question in the Record, they float until answered',
],
[
'mod',
'{user} Questions in the Record stay pinned until someone answers with evidence.',
],
],
},
];
const GENERIC_REPLY = [
['local', '{user} yeah'],
['newbie', '{user} same'],
['joker', '{user} real'],
['local', '{user} 100%'],
['skeptic', '{user} source?'],
];
/* ---------------- The simulator ---------------- */
let seq = 0;
export function makeMsg(persona, text, extra = {}) {
const tier = simTier(persona.handle, persona.floor || 0);
return {
id: `sim-${Date.now().toString(36)}-${(seq += 1)}`,
handle: persona.handle,
text,
at: Date.now(),
sim: true,
mod: !!persona.mod,
domain: persona.domain || null,
local: !!persona.local,
tier,
...extra,
};
}
function pick(list, rand = Math.random) {
return list[Math.floor(rand() * list.length)];
}
function personaFor(role, rand) {
if (role === 'atc') return { ...PERSONAS.pilot[2], role };
const list = PERSONAS[role] || PERSONAS.newbie;
return { ...pick(list, rand), role };
}
/**
* @param {object} s situation
* @param {{ onMessage(msg), onReactions?(kind, n, burst), viewers?: number,
* me?: () => string, rand?: () => number }} o
*/
export function createChatSim(s, o) {
const rand = o.rand || Math.random;
const genre = chatGenre(s);
const V = vars(s);
const pool = [...(POOLS[genre] || POOLS.default), ...COMMON];
const used = new Set();
let timer = 0;
let burstUntil = 0;
let nextBurst = Date.now() + 9000 + rand() * 12000;
let running = false;
let viewers = o.viewers || viewerCount(s);
const witnessHandles = new Set(); // sim chatters with sealed captures nearby
function line() {
// Prefer unused lines; recycle once the pool is spent.
let cand = pool.filter((_, i) => !used.has(i));
if (cand.length < 3) {
used.clear();
cand = pool;
}
for (let tries = 0; tries < 6; tries += 1) {
const idx = Math.floor(rand() * pool.length);
if (used.has(idx) && tries < 5) continue;
const [role, t, meta] = pool[idx];
const text = fill(t, V);
if (!text) {
used.add(idx);
continue;
}
used.add(idx);
const p = personaFor(role, rand);
const extra = {};
if (meta && meta.evidence != null) {
extra.evidence = { sealed: true, km: meta.evidence, example: true };
witnessHandles.add(p.handle);
}
if (witnessHandles.has(p.handle)) extra.witness = true;
return makeMsg(p, text, extra);
}
return null;
}
function greet() {
const w = worldPoint(rand);
const p = personaFor(pick(['newbie', 'local', 'joker', 'newbie']), rand);
// Greeters are from elsewhere: never mark them local.
return makeMsg(
{ ...p, local: false },
fill(pick(GREET, rand), V, { city: w.city }),
{
from: w,
},
);
}
function burstLine() {
const list = BURST[genre] || BURST.default;
const p = personaFor(
pick(['newbie', 'local', 'joker', 'photo', 'newbie', 'local'], rand),
rand,
);
let text = pick(list, rand);
if (rand() < 0.25) text = text.toUpperCase();
return makeMsg(
p,
text,
witnessHandles.has(p.handle) ? { witness: true } : {},
);
}
function tick() {
if (!running) return;
const now = Date.now();
if (now > nextBurst) {
burstUntil = now + 3200 + rand() * 2500;
nextBurst = now + 22000 + rand() * 26000;
o.onBurst?.(genre);
}
const bursting = now < burstUntil;
// Messages per second scales gently with viewers; bursts ×4.
const base = Math.min(2.4, Math.max(0.35, viewers / 1400));
const rate = bursting ? base * 4 + 1.5 : base;
const r = rand();
const msg = bursting
? r < 0.75
? burstLine()
: line()
: r < 0.14
? greet()
: line();
if (msg) o.onMessage(msg);
// Reactions from the crowd, in proportion to viewers.
if (o.onReactions) {
const n = Math.max(
0,
Math.round((viewers / 900) * (bursting ? 6 : 1) * (0.4 + rand())),
);
if (n) o.onReactions(reactionKind(genre, rand), n, bursting);
}
// Viewer count random walk, ±2 %.
viewers = Math.max(12, Math.round(viewers * (1 + (rand() - 0.48) * 0.02)));
o.onViewers?.(viewers);
const gap = (1000 / rate) * (0.35 + rand() * 1.3);
timer = setTimeout(tick, gap);
}
/** Someone (the person) said something: a few sim people answer. */
function hear(text, handle) {
const t = String(text || '');
const rule = REPLIES.find((r) => r.re.test(t));
const lines = rule ? rule.lines : GENERIC_REPLY;
const n = rule ? 1 + (rand() < 0.45 ? 1 : 0) : rand() < 0.55 ? 1 : 0;
const chosen = [...lines].sort(() => rand() - 0.5).slice(0, n);
chosen.forEach(([role, tpl], i) => {
const txt = fill(tpl, V, {
user: handle,
sep1: V.m1 ? ' and ' : '',
});
if (!txt) return;
setTimeout(
() =>
running &&
o.onMessage(
makeMsg(personaFor(role, rand), txt, { replyTo: handle }),
),
1400 + i * 1700 + rand() * 1600,
);
});
// A mention of evidence draws corroborating eyes.
return chosen.length;
}
return {
genre,
start() {
if (running) return;
running = true;
// Backfill a little history so the lane is never empty.
const back = [];
for (let i = 0; i < 14; i += 1) {
const m = i % 5 === 2 ? greet() : line();
if (m) {
m.at = Date.now() - (14 - i) * (2500 + rand() * 4000);
back.push(m);
}
}
o.onBackfill?.(back);
timer = setTimeout(tick, 600);
},
stop() {
running = false;
clearTimeout(timer);
},
hear,
burst() {
nextBurst = 0;
},
get viewers() {
return viewers;
},
set viewers(v) {
viewers = v;
},
get running() {
return running;
},
};
}
/** A stable, plausible viewer count for a situation. */
export function viewerCount(s) {
if (!s) return 0;
const h = hashStr(s.id);
const g = chatGenre(s);
const base =
{
circling: 4800,
cyclone: 2600,
launch: 3900,
squawk: 1800,
storm: 1300,
quake: 1500,
orbit: 1100,
eye: 640,
fire: 900,
}[g] || 380;
return Math.round(base * (0.75 + (h % 500) / 1000) + (s.severity || 0) * 120);
}
/**
* A Moment's chat on its own clock. Uses moment.chat when the engine
* provides it; otherwise writes one from the genre pool across the window.
* @returns {{ t:number, handle, text, badge?, sim:true }[]}
*/
export function momentChat(m) {
if (Array.isArray(m?.chat) && m.chat.length)
return m.chat
.map((c) => ({ ...c, id: c.id || `${c.t}|${c.handle}`, sim: true }))
.sort((a, b) => a.t - b.t);
const start = +m?.time?.start || Date.now() - 20 * 60e3;
const end = +m?.time?.end || Date.now();
const s = {
id: m?.id || 'moment',
kind: 'circling',
title: m?.title,
place: m?.place,
momentId: m?.id,
};
let r = hashStr(s.id);
const rand = () => {
r =
Math.imul(r ^ (r >>> 15), 2246822507) ^
Math.imul(r ^ (r >>> 13), 3266489909);
return ((r ^= r >>> 16) >>> 0) / 4294967296;
};
const out = [];
const pool = POOLS.circling;
const V = vars(s);
const n = Math.min(80, Math.max(24, Math.round((end - start) / 15000)));
for (let i = 0; i < n; i += 1) {
const t = start + ((i + rand() * 0.8) / n) * (end - start);
const [role, tpl, meta] =
i % 7 === 3 ? ['newbie', pick(BURST.circling, rand)] : pick(pool, rand);
const p = personaFor(role, rand);
const text = fill(tpl, V) || tpl;
out.push({
id: `mc-${s.id}-${i}`,
t,
handle: p.handle,
text,
badge: p.mod ? 'mod' : meta?.evidence != null ? 'witness' : undefined,
sim: true,
});
}
return out.sort((a, b) => a.t - b.t);
}
export { fmtKm };
/** A few representative lines for a room preview (Rooms tab ticker). */
export function sampleLines(s, n = 6) {
const V = vars(s);
const pool = POOLS[chatGenre(s)] || POOLS.default;
let r = hashStr(s.id);
const rand = () => {
r = (Math.imul(r, 1664525) + 1013904223) >>> 0;
return r / 4294967296;
};
const out = [];
const idx = pool.map((_, i) => i).sort(() => rand() - 0.5);
for (const i of idx) {
const [role, t, meta] = pool[i];
const text = fill(t, V);
if (!text) continue;
const p = personaFor(role, rand);
out.push(
makeMsg(
p,
text,
meta?.evidence != null
? { evidence: { sealed: true, km: meta.evidence } }
: {},
),
);
if (out.length >= n) break;
}
return out;
}