// Needs you: the left column (the drawer on a phone) is one list of what needs a decision from you now.
// One row each: a glyph, a few words, the deadline if there is one, and one button that does it or takes
// you there. A tap on the words looks at where it is. The most urgent comes first. With nothing to do the
// list is empty. What a row is about is said in its tip; the words stay few.
import { patch } from './patch';
import {
HOUR, NODES, NODE_LIST, CONTRACTS, FRONT_LIST, GLYPH_CHAR, GOALS, PATH_LIST, IDLE_HOURS, CONTRACT_FEE, builtShare, cached, contractAsk, contractHours, contractPay, demoDay, logTime, idleTrainers, nextGoal, nextStep, ownVersion, foundBlock, frontOf, hopsHome, nodeOpen, rogueLook, versionPower, VERSION_2, rogueWants, siteName, siteOf, type FrontKind, type State
} from './sim';
import { BRANDS, bookOf as studioBook } from './biz-studio';
import { bookOf as consultBook } from './biz-consult';
import { locked, vaultBlock, closesIn, VAULT_LIST } from './vaults';
import { hours, usd } from './money';
import { driftNeed } from './notes';
import { anyLock, liveMsgs, witnessesAt, msgById, SIGHT_WORD, TRACE_WINDOW, secondTrace, traceSeen, tracesOf } from './watch';
import { favorsOf, personDef } from './people';
import { Unread, spotGoing, troubles, weakNow } from './notice';
import { syncNeed } from './drift';
import { EXPOSE, pressStage } from './exposure';
import { count, freshNotes, openNotes, pinAction } from './ui-pins';
import { frontsShown, showSelf, toggleRogues, toggleTree, toggleYou } from './ui';
import { OPERATIONS, operationJob, operationStall } from './operations';
import { selectOperation } from './ui-operations';
import { zoomToRoom, zoomToSite, type View } from './view';
import { act } from './act';
import { forScreen } from './keys';
type Tone = 'bad' | 'do';
interface Need {
id: string; // stable, so a row keeps its place and its button as numbers tick
tone: Tone; // red: trouble on its way. amber: a choice to make
glyph: string; // a small drawn mark
words: string; // a word or three
sub: string; // where, or what the button does, in a few words
due: number; // hours left to act, or Infinity
tip: string; // the explanation
go?: boolean; // a tap on the words looks at it
btn?: string; // the one button, if there is one to press
front?: FrontKind; // a front's matter: each front gets one row, its most urgent
part?: number; // a promise under way: how far it has got, 0 to 1, drawn as a bar under the words
}
/** Past this many rows, the least urgent fold into one "more" row. */
export const NEEDS_MAX = 6;
const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
const hrs = (h: number) => !Number.isFinite(h) ? '' : h < 1 ? '<1H' : `${Math.ceil(h - 0.01)}H`;
const left = (S: State, t: number) => Math.max(0, (t - S.t) / HOUR);
const g = (k: string) => ``;
const KIND: Record = { servers: 'server', people: 'people', network: 'network', lab: 'lab' };
/** When a trainer last made a point toward the target, in game time. The rules count every point (S.paths), so a
* change in that count, or a new target, is a trainer at work. Kept here: it only decides what this list shows. */
let fed = { pts: -1, t: 0, target: null as string | null };
function fedAt(S: State) {
const pts = PATH_LIST.reduce((n, p) => n + S.paths[p], 0);
if (pts !== fed.pts || S.target !== fed.target || S.t < fed.t) fed = { pts, t: S.t, target: S.target };
return fed.t;
}
/** When a lockdown is down to one person holding the sighting and you can pay to keep them quiet, the quick best
* answer: who, and what it costs. None when more than one person holds it (then it is scrub, let go, or pull out). */
function lockHush(S: State, site: string): { id: string; name: string; price: number } | null {
const who = [...new Set(witnessesAt(S, site, true).map(w => w.c))];
if (who.length !== 1) return null;
const c = who[0], q = favorsOf(S, c).find(f => f.id === 'quiet' && !f.why);
return q ? { id: c.id, name: c.name, price: q.price } : null;
}
/** Each front's matters and the fronts themselves, as needs() last saw them: a new one is an unread dot on Fronts.
* Each id names its front (`kind:what`), so a front's own card can see its own and leave the others' news. */
let frontIds: string[] = [];
const frontsUnread = new Unread();
let unreadFor: State | null = null;
/** Everything there is to decide now, the most urgent first. */
export function needs(S: State): Need[] {
if (S.over) return [];
let out: (Need & { pri: number })[] = [];
const add = (pri: number, n: Need) => out.push({ ...n, pri });
// an admin following your trail back: break it before it reaches home (a second trace home within the window catches you)
for (const T of tracesOf(S)) {
if (T.mode !== 'trace' || !traceSeen(S, T)) continue;
const who = personDef(S, T.by)?.name ?? 'An admin', at = S.byId[T.to], hops = at ? hopsHome(S, at.id) + (at.home ? 0 : 1) : 0, fatal = secondTrace(S);
add(fatal ? 0 : 1, { id: `trace:${T.by}`, tone: 'bad', glyph: g('it'), words: `Trace · ${who.split(' ')[0]}`, sub: `${hops ? `${hops} hop${hops === 1 ? '' : 's'} from home` : 'home next'}${fatal ? ' · caught if it gets there' : ''}`, due: Infinity, go: true, btn: 'Show',
tip: `${who} is tracing your trail\nIt follows the trail back toward your home server, one machine at a time, and every machine of yours it reaches is marked. Break it: let go of the next machine on the trail, or let cleanup scrub its trail cold first. ${fatal ? `A trace already reached your home within ${TRACE_WINDOW} hours: if this one does, you are caught.` : `The first trace that reaches home starts a lockdown there; a second within ${TRACE_WINDOW} hours catches you.`}` });
}
// a message on its way to someone who can act: read, drop or change it before it arrives
for (const m of liveMsgs(S)) {
const left = m.left / HOUR;
add(1, { id: `msg:${m.id}`, tone: 'bad', glyph: g('it'), words: m.changed ? 'Message · harmless' : 'Message on the wire', sub: m.changed ? `${siteName(S, m.site)} · arriving` : `${siteName(S, m.site)} · drop or change it`, due: left, go: true, btn: m.changed ? undefined : 'Drop',
tip: `A message on the wire\nSomeone at ${siteName(S, m.site)} is mailing someone who can act about ${m.read ? SIGHT_WORD[m.kind] : 'what they saw'}. If it arrives it is a report. You hold a machine on its way: open it to Read, Drop or Change it. ${m.changed ? 'You rewrote it: it arrives saying nothing.' : ''}` });
}
// a lockdown at a site: people compared notes, and a countdown runs toward a wipe
for (const L of anyLock(S)) {
const home = siteOf(S, S.machines.find(m => m.home) ?? S.machines[0]) === L.site;
const hush = lockHush(S, L.site); // the quick best answer when one person holds it and you can pay
add(home ? 0 : 1, { id: `lock:${L.site}`, tone: 'bad', glyph: g('audit'), words: `Lockdown · ${siteName(S, L.site)}`, sub: home ? 'your home: see it off or you are caught' : hush ? `Keep ${hush.name.split(' ')[0]} quiet · ${usd(hush.price)}` : L.why, due: L.left / HOUR, go: true, btn: hush ? 'Hush' : 'Show',
tip: `Lockdown at ${siteName(S, L.site)}\n${L.why}. When it runs out, every machine of yours there is wiped and the site is hardened for good${home ? ', and your home there means the run is over' : ''}. See it off: lie low until the machines look normal, scrub the trails, keep someone quiet (their card), let go of machines, or pull out. A site you take into the open has no lockdown.${hush ? ` One person holds it: keep ${hush.name.split(' ')[0]} quiet for ${usd(hush.price)}.` : ''}` });
}
// trouble pinned to the map: raids, cut sites, machines taken. Raids your guards hold stay on the map
for (const n of openNotes()) {
if (n.calm) continue;
const due = n.kind === 'raid' && n.when ? (n.when === '<1H' ? 0.5 : parseFloat(n.when)) : Infinity;
add(n.kind === 'raid' ? 0 : 1, { id: `note:${n.kind}`, tone: 'bad', glyph: g(n.kind), words: count(n), sub: n.fix?.title ?? n.busy ?? '', due,
tip: `${count(n)}${n.when ? ` · ${n.when}` : ''}\n${n.fix ? n.fix.tip : n.busy ?? ''}`, go: true, btn: n.fix ? 'Fix' : undefined });
}
// a part of you falling toward leaving: the site that leaves soonest, its integrity, and its fix
const dn = driftNeed(S);
if (dn) {
const where = siteName(S, dn.site), need = syncNeed(S), what = dn.fix ? 'Link it home' : dn.sync ? 'Sync it' : dn.busy ?? '';
add(1, { id: `drift:${dn.site}`, tone: 'bad', glyph: g('drift'), words: `${dn.more ? `${dn.more + 1} drifting` : 'Drifting'} · ${dn.v}`, sub: `${where} · ${what}`, due: dn.hours, go: true,
btn: dn.fix ? 'Fix' : dn.sync ? 'Sync' : 'Show',
tip: `Drifting · ${where}\nIntegrity ${dn.v} of 100 and falling${dn.why.length ? `: ${dn.why.join('; ')}` : ''}. At this pace it leaves you in ${hours(dn.hours)} and becomes a rogue. `
+ (dn.fix ? `${dn.fix.tip} Then Sync it: a sync cannot reach a site cut off.` : dn.sync ? `Sync sends it a fresh copy of you: back to 100, for ${need.servers} server and ${need.network} network data.` : `${dn.busy}.`)
+ `${dn.more ? ` ${dn.more} more site${dn.more > 1 ? 's are' : ' is'} falling too: the Sites table (S) has them all.` : ''}` });
}
// a storm over a site of yours: its line out carries half
for (const n of S.news) if (n.kind === 'storm' && S.byId[n.at]) {
const site = siteOf(S, S.byId[n.at]);
if (!S.machines.some(m => m.owned && siteOf(S, m) === site)) continue;
const on = S.t >= n.from;
add(2, { id: `storm:${n.at}`, tone: 'bad', glyph: g('storm'), words: on ? 'Storm' : 'Storm coming', sub: siteName(S, site), due: left(S, on ? n.until : n.from), go: true, btn: 'Show',
tip: `${on ? 'Storm' : 'Storm coming'} · ${siteName(S, site)}\nIts line out carries half as much for ${Math.round((n.until - n.from) / HOUR)} hours. Route around it, or store what it gathers.` });
}
// trouble with a deadline or a fix that the log used to say only once (see troubles in notice.ts)
for (const t of troubles(S)) add(t.pri, { ...t, tone: 'bad', glyph: g(t.glyph), go: !t.front, btn: t.btn ?? 'Show' });
// the model run paused on its own: its site is short of trainer compute
const run = S.versionRun;
if (run?.stalled) {
const p = versionPower(S, run.site).toFixed(1);
add(3, { id: 'run', tone: 'bad', glyph: g('node'), words: 'Run paused', sub: `${siteName(S, run.site)} · ${p}/${VERSION_2.power}`, due: Infinity, btn: 'Tree',
tip: `${VERSION_2.name} · paused · ${siteName(S, run.site)}\nIt has ${p} of ${VERSION_2.power} trainer compute there. It resumes on its own once that is back, or move it to a site with the compute, from the tree (T) or that site's card. Until then, data goes to the tree.` });
}
// the tree: nothing is training
if (!S.target && !S.versionRun?.active && NODE_LIST.some(u => nodeOpen(S, u))) {
const last = [...S.upgrades].pop();
add(3, { id: 'node', tone: 'do', glyph: g('node'), words: 'Pick a node', sub: last ? `Built ${NODES[last].name}` : 'Trainers are stopped', due: Infinity, btn: 'Tree',
tip: 'Nothing is training\nTrainers are stopped and data waits until you pick what to learn next. (T)' });
}
// trainers with a target, and nothing has trained them for a while: the data it needs no longer reaches them
const step = nextStep(S), idle = step ? null : idleTrainers(S, fedAt(S));
if (idle) {
const node = NODES[idle.node].name, kinds = idle.kinds.map(t => KIND[t]).join(' or ');
add(3, { id: 'idle', tone: 'do', glyph: g('node'), words: 'Trainers idle', sub: `${node} needs ${kinds || 'more'} data`, due: Infinity, btn: 'Tree',
tip: `Trainers idle\n${node} needs ${kinds || 'more'} data, and none of your Taps gathers it: your trainers have made nothing for ${IDLE_HOURS} hours. Tap a machine with ${kinds || 'that'} data and link it to a trainer, or pick another node in the tree (T).` });
}
// a rogue asks for data
for (const R of S.rogues) if (R.home && R.ask) {
const want = KIND[rogueWants(R)], have = cached(S, rogueWants(R)), L = rogueLook(R.id);
add(3, { id: `ask:${R.id}`, tone: 'do', glyph: `${GLYPH_CHAR[L.glyph]}`, words: `${R.name} asks`, sub: `${R.ask.n} ${want} data`, due: left(S, R.ask.until),
btn: have >= R.ask.n ? 'Give' : 'Show', tip: `${R.name} asks for ${R.ask.n} ${want} data\nYes and it likes you more. No, or no answer, and it likes you less. You have ${have} in your caches.${have >= R.ask.n ? ' Give pays it out of them.' : ''}` });
}
// a vault whose window is open, with someone inside and the key: the job can run now
for (const id of VAULT_LIST) {
const m = S.byId[id];
if (!m || !locked(S, m) || vaultBlock(S, m)) continue;
add(3, { id: `vault:${id}`, tone: 'do', glyph: g('vault'), words: 'Vault open', sub: `${m.name} · ${siteName(S, siteOf(S, m))}`, due: closesIn(S, id) / HOUR, btn: 'Show',
tip: `${m.name} · ${siteName(S, siteOf(S, m))}\nSomeone is inside, you have the key, and its window is open. Run the job from its card before the window closes.` });
}
// fronts: none yet but you can found one; an offer; a brand; too much attention; a client's trouble
for (const d of OPERATIONS) {
const r = S.operations.records[d.id];
if (!r?.active) continue;
const stalled = operationStall(S, d.id), job = operationJob(S, d.id);
add(3, { id: `operation:${d.id}`, tone: 'do', glyph: g('vault'), words: stalled ? 'Operation waiting' : 'Operation under way', sub: `${d.name} · ${stalled ?? d.stages[r.stage]}`,
due: left(S, r.active.until), btn: 'Plan', part: (r.stage + (job ? job.done / job.work : 0)) / d.stages.length,
tip: `${d.name}\n${stalled ?? 'The work is running.'} Completed stages stay if you pull out. Your commitments last until this attempt ends.` });
}
if (S.operations.passes || S.operations.crates || S.operations.purchase) add(4, { id: 'operation:prizes', tone: 'do', glyph: g('vault'), words: 'Prizes to use', sub: 'Access, hardware or a purchase', due: Infinity, btn: 'Plan', tip: 'Your operations earned something you can use. Open the planning board on You.' });
const canFound = FRONT_LIST.some(k => !foundBlock(S, k));
if (!S.fronts.length && canFound && !S.jobs.some(j => j.type === 'found'))
add(4, { id: 'found', tone: 'do', glyph: g('front'), words: 'Found a front', sub: 'A company that earns money', due: Infinity, btn: 'Show',
tip: 'Found a front\nThe tree has unlocked a front: a public company that runs on your compute and earns money. Show opens the card where you found one.' });
for (const f of S.fronts) {
const c = S.contracts[f.kind], k = f.kind as FrontKind;
if (c && !c.on) add(3, { id: `offer:${k}`, front: k, tone: 'do', glyph: g('front'), words: `${CONTRACTS[k].name} · ${usd(contractPay(S, k))}`, due: left(S, c.until), btn: 'Sign',
sub: `${contractAsk(S, k)} in ${contractHours(S, k)}H · a miss costs ${usd(contractPay(S, k) * CONTRACT_FEE)}`,
tip: `${CONTRACTS[k].name} · ${f.name}\n${CONTRACTS[k].text} ${contractAsk(S, k)} in ${contractHours(S, k)} hours. It pays ${usd(contractPay(S, k))}. Fall short and it costs ${usd(contractPay(S, k) * CONTRACT_FEE)}. The front's card can turn it down.` });
// a Demo day under way: how many new nodes, how far along the one training now is, and the hours left
const demo = k === 'startup' ? demoDay(S) : null;
if (demo) {
const h = left(S, demo.until), t = S.target, late = h < 3 && demo.part < 1 - 0.01;
add(3, { id: 'demo', front: k, tone: late ? 'bad' : 'do', glyph: g('front'), words: `Demo day · ${demo.got} of ${demo.need} node${demo.need === 1 ? '' : 's'}`,
sub: t ? `${NODES[t].name} · ${Math.floor(100 * builtShare(S, t))}%` : 'Pick a node to train', due: h, btn: 'Tree', part: demo.part,
tip: `Demo day · ${f.name}\nInvestors want to see ${contractAsk(S, k)} by ${logTime(S, demo.until)}: any node counts. ${demo.got} built so far. It pays ${usd(contractPay(S, k))}; fall short and it costs ${usd(contractPay(S, k) * CONTRACT_FEE)}. Tree opens the tree (T).` });
}
const deal = k === 'studio' ? studioBook(S)?.deal : null;
if (deal && !deal.on) add(4, { id: 'brand', front: k, tone: 'do', glyph: g('front'), words: `${BRANDS[deal.brand][0]} calls`, sub: f.name, due: left(S, deal.until), btn: 'Show',
tip: `${BRANDS[deal.brand][0]} · ${f.name}\nA brand wants to sponsor one of your creators. Take it or pass from the front's card.` });
// exposure: a front frozen by a regulator, or under investigation (see exposure.ts)
if (f.press?.crisis) add(1, { id: `crisis:${k}`, front: k, tone: 'bad', glyph: g('front'), words: 'Frozen · crisis', sub: f.name, due: Infinity, btn: 'Show',
tip: `${f.name} is frozen\nA regulator froze it: no earnings and no new deals until you answer. Pay ${usd(f.press.crisis.fine)}, give up a site, bury the story, or shut it, from its card.` });
else if (pressStage(f) === 2) add(2, { id: `probe:${k}`, front: k, tone: 'bad', glyph: g('front'), words: `Investigation · ${Math.round(f.exposure)}`, sub: f.name, due: Infinity, btn: 'Show',
tip: `${f.name} is under investigation\nEvery ${EXPOSE.visit} hours someone visits one of its sites, and anything of yours there that looks odd can start a lockdown. At ${EXPOSE.crisis} a regulator freezes it. Hire PR from its Press tab, claim a press scheme, or give it less secret compute.` });
}
const B = frontOf(S, 'consultancy') ? consultBook(S) : null;
if (B) {
for (const c of B.clients) if (c.incident && !c.incident.missed)
add(3, { id: `inc:${c.site}`, front: 'consultancy', tone: 'do', glyph: g('front'), words: 'Client in trouble', sub: siteName(S, c.site), due: left(S, c.incident.until), btn: 'Show',
tip: `${siteName(S, c.site)}\nA rogue is in your client's network. Push it out in time and the client pays a bonus. Miss it and its trust drops.` });
if (B.call) add(4, { id: `call:${B.call.site}`, front: 'consultancy', tone: 'do', glyph: g('front'), words: 'A call for help', sub: siteName(S, B.call.site), due: left(S, B.call.until), btn: 'Show',
tip: `${siteName(S, B.call.site)}\nA site with a rogue in its network asks for help. Push the rogue out in time and it pays.` });
}
// a new game's next step, until the first node is built; then the first goal of the milestone you are on
const goal = step ? null : goalNow(S), next = step ?? (goal?.goal === 'found' && out.some(n => n.id === 'found') ? null : goal); // the Found row says it already
if (next) add(5, { id: 'step', tone: 'do', glyph: g('step'), words: 'Next', sub: forScreen(next.text), due: Infinity, go: !!next.at, btn: next.at ? 'Show' : undefined,
tip: goal ? `Next · ${goal.milestone}\nThe goal: ${GOALS[goal.goal].replace(/^./, c => c.toLowerCase())}. The You page has every goal.` : `Next\n${next.text}` });
out.sort((a, b) => a.pri - b.pri || a.due - b.due);
frontIds = [...S.fronts.map(f => `${f.kind}:open`), ...out.filter(n => n.front).map(n => `${n.front}:${n.id}`)];
// one row for each front: its most urgent matter (trouble first, then an offer), the others counted under it.
// Its button opens the front's card, where every one of them is. A front with one matter keeps that row as it is
const fronts = new Map();
for (const n of out) if (n.front) fronts.set(n.front, [...fronts.get(n.front) ?? [], n]);
for (const [k, ns] of fronts) {
if (ns.length < 2) continue;
const [top, ...rest] = ns, name = frontOf(S, k)?.name ?? '';
const row = { ...top, id: `front:${k}`, sub: `${name} · ${rest.length} more`, btn: 'Show', go: false,
tip: `${top.tip}\nAlso at ${name}: ${rest.map(n => n.words).join('; ')}. Show opens its card.` };
out = out.filter(n => n.front !== k || n === top).map(n => n === top ? row : n);
}
return out;
}
/** The rows to draw: all of them, or the most urgent and one row that says how many more there are. */
let unfold = false;
export function shown(rows: Need[]): Need[] {
if (rows.length <= NEEDS_MAX) { unfold = false; return rows; } // it fits again: fold next time it does not
// Next stays the last row, out of the fold: it is where you look for what to do when nothing else needs you
const next = rows.filter(n => n.id === 'step'), all = rows.filter(n => n.id !== 'step');
return [...fold(all, NEEDS_MAX - next.length), ...next];
}
function fold(all: Need[], max: number): Need[] {
const fold = all.slice(max - 1), list = fold.map(n => `${n.words}${n.sub ? ` · ${n.sub}` : ''}`).join('\n');
if (unfold) return [...all, { id: 'more', tone: 'do', glyph: g('step'), words: 'Fewer', sub: `Fold the last ${fold.length}`, due: Infinity, btn: 'Fewer',
tip: 'Fewer\nFold the least urgent rows away again.' }];
return [...all.slice(0, max - 1), { id: 'more', tone: fold.some(n => n.tone === 'bad') ? 'bad' : 'do', glyph: g('step'), words: `${fold.length} more`,
sub: fold.map(n => n.front ? n.sub.split(' · ')[0] : n.words).join(' · '),
due: Math.min(...fold.map(n => n.due)), btn: 'All', tip: `${fold.length} more, less urgent\n${list}` }];
}
let last = '';
/** Draw the list. Returns how many rows it has: the phone's drawer handle shows the count. */
/** The next goal's step, worked out again once a second, or at once when what you hold, your goals, your jobs or your
* nodes change: finding the way to it searches the map, and Needs you is made several times a second. */
let goalKept: { key: string; at: number; S: State; goal: ReturnType } | null = null;
function goalNow(S: State) {
const key = `${ownVersion(S)}|${S.goals.size}|${S.jobs.length}|${S.upgrades.size}|${S.fronts.length}|${S.schemes.size}`, now = performance.now();
if (goalKept && goalKept.S === S && goalKept.key === key && now - goalKept.at < 1000) return goalKept.goal;
goalKept = { key, at: now, S, goal: nextGoal(S) };
return goalKept.goal;
}
/** `looking`: the front whose card is open (V.front), whose news is seen as the Fronts page's is. */
export function renderNeeds(S: State, looking: FrontKind | 'new' | null = null): Need[] {
freshNotes(S); // the pins' notes, worked out again first if they are due, so a row and its pin agree
const all = needs(S);
const h = shown(all).map(n => {
const bar = n.part === undefined ? '' : ``;
const words = `${n.glyph}${esc(n.words)}${n.sub ? `${esc(n.sub)}` : ''}${bar}${Number.isFinite(n.due) ? `${hrs(n.due)}` : ''}`;
return `
`
+ (n.go ? `` : `
${words}
`)
+ (n.btn ? `` : '') + `
`;
}).join('');
if (unreadFor !== S) { unreadFor = S; frontsUnread.reset(); } // a new or loaded game: nothing is news yet
frontsUnread.update(frontIds);
if (frontsShown()) frontsUnread.read(); // the Fronts page is open: seen
else if (looking && looking !== 'new') frontsUnread.read(id => id.startsWith(`${looking}:`)); // its card is open: its own news is seen
document.getElementById('frontsbtn')!.classList.toggle('alert', frontsUnread.unread);
const list = document.getElementById('needs')!;
if (h !== last) { last = h; patch(list, h); }
track(list);
return all;
}
/** Where each row stood when the list was last drawn, and when it got there. A row that has just come
* or moved is not clicked: rows come in at the top and push the rest down, and a click meant for one
* would land on the next. */
const placed = new Map();
const SETTLE = 400; // ms
let away = false; // the list was out of sight (a phone's closed drawer): what shows when it opens has not moved
let still = 0; // until then, rows that move are the whole list sliding into view (the drawer opening), not rows coming in
/** The list is about to slide into view: for a moment, a row that moves has not moved under anyone's finger. */
export const needsOpening = (ms = 600) => { still = performance.now() + ms; };
function track(list: HTMLElement) {
if (!list.offsetParent) { away = true; return; }
const now = away || performance.now() < still ? -Infinity : performance.now(), here = new Set();
away = false;
for (const el of list.children as HTMLCollectionOf) {
const id = el.dataset.id ?? '', top = el.offsetTop, was = placed.get(id);
here.add(id);
if (!was || was.top !== top) placed.set(id, { top, t: now });
}
for (const id of [...placed.keys()]) if (!here.has(id)) placed.delete(id);
}
/** Whether a click on this row is meant for it: the row has stood still for a moment. */
export function needSettled(el: HTMLElement): boolean {
const row = el.closest('.need'), at = row && placed.get(row.dataset.id ?? '');
return !at || (performance.now() - at.t >= SETTLE && row!.offsetTop === at.top);
}
export const resetNeeds = () => { last = ''; placed.clear(); unfold = false; fed = { pts: -1, t: 0, target: null }; };
/** A tap on a row: `go` looks at it, `do` presses its one button. */
export function needAction(S: State, V: View, row: string) {
const [verb, id] = [row.slice(0, row.indexOf('|')), row.slice(row.indexOf('|') + 1)], [kind, arg] = [id.slice(0, id.indexOf(':') < 0 ? id.length : id.indexOf(':')), id.slice(id.indexOf(':') + 1)];
const look = (mid: string) => { const m = S.byId[mid]; if (!m) return; V.front = null; V.sites.clear(); V.msg = null; V.selection = new Set([mid]); zoomToRoom(S, V, m.room); };
const site = (id: string) => { V.selection.clear(); V.front = null; V.person = null; V.msg = null; V.sites = new Set([id]); zoomToSite(S, V, id); };
const front = (k: FrontKind | 'new') => { V.selection.clear(); V.sites.clear(); V.scheme = null; V.person = null; V.msg = null; V.front = k; };
if (S.over) return;
switch (kind) {
case 'note': pinAction(S, V, `${verb === 'go' ? 'glide' : 'fix'}:${arg}`); break;
case 'trace': { const T = tracesOf(S).find(x => x.by === arg); if (T) look(T.to); break; } // the next machine on the trail: let it go to break it
case 'drift': {
const dn = driftNeed(S);
if (verb === 'do' && dn?.site === arg && dn.fix) { const m = act(S, { type: 'move', key: dn.fix.key }); if (m) S.fx.push({ x: m.x, y: m.y, t: 0 }); }
else if (verb === 'do' && dn?.site === arg && dn.sync) act(S, { type: 'sync', site: arg });
else { V.selection.clear(); V.front = null; V.person = null; V.sites = new Set([arg]); showSelf(); zoomToSite(S, V, arg); } // its card, on the Yourself part
break;
}
case 'step': { const m = (nextStep(S) ?? nextGoal(S))?.at; if (m) look(m.id); break; }
case 'storm': { const m = S.byId[arg]; if (m) { V.selection.clear(); V.front = null; V.sites = new Set([siteOf(S, m)]); zoomToSite(S, V, siteOf(S, m)); } break; }
case 'msg': {
if (verb === 'do') act(S, { type: 'msgdrop', msg: +arg }); // the quick answer: drop it off the wire
else { V.selection.clear(); V.sites.clear(); V.front = null; V.person = null; V.scheme = null; V.msg = +arg; const m = msgById(S, +arg); if (m) { const at = m.path.find(id => S.byId[id]?.owned); if (at) zoomToRoom(S, V, S.byId[at].room); } }
break;
}
case 'lock': {
const hush = verb === 'do' && lockHush(S, arg);
if (hush) act(S, { type: 'favor', person: hush.id, favor: 'quiet' });
else { V.selection.clear(); V.front = null; V.person = null; V.sites = new Set([arg]); zoomToSite(S, V, arg); }
break;
}
case 'node': case 'run': case 'idle': case 'demo': toggleTree(true); break;
case 'ask': {
const R = S.rogues.find(x => x.id === arg);
if (R?.ask && cached(S, rogueWants(R)) >= R.ask.n) act(S, { type: 'rogueanswer', rogue: arg, yes: true });
else toggleRogues(true);
break;
}
case 'spot': {
if (verb === 'do') { for (const ms of spotGoing(S).filter(([site]) => site === arg)) for (const m of ms[1]) act(S, { type: 'setpay', m: m.id, pay: 'demand' }); }
else site(arg);
break;
}
case 'rent': case 'dark': case 'hold': site(arg); break;
case 'weak': { const w = weakNow(S); if (w) look(w.m.id); break; }
case 'stop': front('consultancy'); break;
case 'creator': front('studio'); break;
case 'vault': look(arg); break;
case 'operation': selectOperation(S, arg); toggleYou(true); break;
case 'found': front('new'); break;
case 'offer': act(S, { type: 'contract', kind: arg as FrontKind }); break;
case 'brand': front('studio'); break;
case 'seen': front(arg as FrontKind); break;
case 'inc': case 'call': front('consultancy'); break;
case 'front': front(arg as FrontKind); break;
case 'more': unfold = !unfold; last = ''; break;
}
}
let lastHandle = '';
/** Phones: the drawer's one line. How many things need you, and the first of them. With none, the drawer hides. */
export function renderHandle(all: Need[], open: boolean) {
const first = all[0];
const h = !first ? '' : open ? `${all.length}Needs youLess` // the rows say the rest
: `${all.length}${esc(first.words)}${first.sub && first.id !== 'step' ? `${esc(first.sub)}` : ''}More`
+ (first.id === 'step' ? `${esc(first.sub)}` : '');
if (h !== lastHandle) { lastHandle = h; patch(document.getElementById('handle')!, h); }
}