// 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); } }