diff --git a/DESIGN.md b/DESIGN.md index 7efacd12..edfe77a9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -70,12 +70,12 @@ rogue took (the pins' own rows), IT's tickets with Close, an IT monitor, Dana's audit, a storm over a site of yours, pick a node when nothing trains, a rogue asking for data, a vault ready to open, founding your first front, a customer's offer with Sign, Neo Cloud leads, a front that draws too much attention, a client in trouble, and a new -game's next step. Nothing needs you: nothing shows. -The running jobs are a strip in the bottom row: how many, and one bar for all of them. +game's next step. The next step names the machine to click, and a click on it shows that machine. Nothing needs you: nothing shows. +The running jobs are a strip just above the bottom row (in it, on a phone): how many, and one bar for all of them. The bottom row never moves, so Pause is always in the same place. It opens their list, with × on each, **Stop all**, and one thin bar of where your compute goes this moment (trainers, other programs, fronts, upkeep, and what is left for jobs, a few seconds' average; its tip gives each as a number). The log is the record, not where -to look: one quiet line at the bottom right, opened for the rest. On a phone the Needs you +to look: the newest entry at the bottom right, two lines at most, and a click opens the rest. On a phone the Needs you list is the drawer above the dock; its handle shows the count and the first row. The game teaches by playing, not by exposition. Panels stay short: a name and diff --git a/src/main.ts b/src/main.ts index 28bfc6d7..2f45e22f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { attachInput } from './input'; import { type LogLine, DEV, runRuse, setTakeAll, cancelJob, canInstall, claim, installProg, newGame, saveGame, loadGame, setAuto, setAutoPath, removeProg, letGo, canLetGo, setTarget, startVersionRun, pauseVersionRun, ascend, step, takeOver, unclaim, type Machine, type Prog, type State, type Upgrade, type Ascension, orderDesk, buildRooms, maxSpeed } from './sim'; -import { renderYou, toggleYou, renderRogues, toggleRogues, roguesShown, rogueAction, renderSitesTable, sortSites, toggleSites, cardInput, renderFronts, toggleFronts, frontsShown, setSliding, toggleLog, cardAction, placeCard, renderCard, renderJobs, toggleJobs, renderLog, renderStats, renderRings, renderTree, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui'; +import { renderYou, toggleYou, renderRogues, toggleRogues, roguesShown, rogueAction, renderSitesTable, sortSites, toggleSites, sitesShown, cardInput, renderFronts, toggleFronts, frontsShown, setSliding, toggleLog, cardAction, placeCard, renderCard, renderJobs, toggleJobs, renderLog, renderStats, renderRings, renderTree, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui'; import { needAction, renderHandle, renderNeeds, resetNeeds } from './ui-needs'; import { HOLD, attachTips, tipTick } from './tip'; import type { Path } from './sim'; @@ -226,7 +226,7 @@ document.addEventListener('pointerdown', e => { tipped = el && el !== tipped ? el : null; showPathTip(tipped ? tipped.dataset.path as Path : null, tipped); }, true); -$('log').addEventListener('click', e => { if ((e.target as HTMLElement).closest('.logmore')) { toggleLog(); renderLog(S); } }); +$('log').addEventListener('click', e => { if ((e.target as HTMLElement).closest('.logmore') || !$('log').classList.contains('open')) { toggleLog(); renderLog(S); } }); // folded, a click anywhere on it opens it $('treebtn').addEventListener('click', () => toggleTree()); $('sitesbtn').addEventListener('click', () => toggleSites()); $('youbtn').addEventListener('click', () => { toggleYou(); refresh(); }); @@ -392,6 +392,7 @@ window.addEventListener('keydown', e => { else if (e.key === 'Escape' && pickShown()) { togglePick(false); refresh(); } else if (e.key === 'Escape' && !$('pagemenu').hidden) setMenu(false); else if (e.key === 'Escape' && paperShown()) { togglePaper(false); refresh(); } + else if (e.key === 'Escape' && sitesShown()) { toggleSites(false); refresh(); } else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; V.person = null; V.gesture = null; setPlacing(null); V.building = null; refresh(); } else if (e.key === 's' || e.key === 'S') setTool('select'); else if (e.key === '=' || e.key === '+') glideZoom(V, 1.4); @@ -466,7 +467,8 @@ window.addEventListener('pointerup', e => { function placeSide() { const top = $('topbar').getBoundingClientRect().bottom + 8, side = $('side'); document.documentElement.style.setProperty('--top', `${top}px`); // the tables and pages open under the bar too - $('jobsmenu').style.bottom = `${V.vh - document.querySelector('.tools')!.getBoundingClientRect().top + 8}px`; // the list of jobs opens just above the bottom row + const grp = document.querySelector('.jobsgrp')!, dock = Math.min(document.querySelector('.tools')!.getBoundingClientRect().top, grp.hidden ? Infinity : grp.getBoundingClientRect().top); + $('jobsmenu').style.bottom = `${V.vh - dock + 8}px`; // the list of jobs opens just above the bottom row, and the jobs strip on it if (phone.matches) { const tools = document.querySelector('.tools')!.getBoundingClientRect().top, drawer = $('drawer'); drawer.style.bottom = `${V.vh - tools + 8}px`; @@ -484,7 +486,7 @@ function placeSide() { const wide = V.vw - 2 * col > 560; // narrow windows: let the map run under the columns V.inset = { top: top + 8, right: wide ? col : 16, bottom: V.vh - tools + 8, left: wide ? col : 16 }; V.side = col; side.style.top = `${top}px`; - $('slownote').style.bottom = `${V.vh - tools + 8}px`; // just above the speed buttons + $('slownote').style.bottom = `${V.vh - dock + 8}px`; // just above the speed buttons, and the jobs strip if (V.vw > 700) { const card = $('card'); card.style.top = `${top}px`; card.style.bottom = ''; card.style.maxHeight = `${V.vh - top - ($('log').closest('.logbox')!.offsetHeight || 0) - 32}px`; } } diff --git a/src/sim.ts b/src/sim.ts index a2b310cd..5f5cc42f 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -7,7 +7,7 @@ import type { DataType, DeliveryDef, Kind, Level, MachineDef, PersonDef, Room } from './world'; import { initPaper, stepPaper, type PaperState } from './paper.ts'; import { bizPrice, bizTap, initBiz, stepBiz, type BizState } from './sitelife.ts'; -import { CLOUD_CUT, PAPER_CUT, beginTask, earnNext, feedTask, headStart, initTask, stepTask, trusts, type TaskState } from './task.ts'; +import { CLOUD_CUT, PAPER_CUT, beginTask, earnNext, feedTask, headStart, initTask, originOf, stepTask, trusts, type TaskState } from './task.ts'; import { KEY, finishCopy, hasThing, initFinds, keyFor, stepFinds, type FindsState } from './finds.ts'; import { finishCheck, finishSync, initDrift, keepsData, planDrag, devBreak, stepDrift, type DriftState } from './drift.ts'; import { CUSTOMERS, cloudShare, initCloud, leadNow, listCloud, rentPay, stepCloud, type CloudState } from './cloud.ts'; @@ -199,19 +199,35 @@ export const MILESTONES: { name: string; goals: Goal[]; done: string }[] = [ ]; /** The milestone you are on: the first with a goal still to meet, or none once all are done. */ export const milestone = (S: State) => MILESTONES.find(m => m.goals.some(g => !S.goals.has(g))) ?? null; +/** The machine the first steps point at: the one to take next (one next to your server, with data if it can), + * or the one to tap (a machine you own with data and no program). */ +export function stepMachine(S: State, want: 'take' | 'tap'): Machine | null { + if (want === 'tap') return S.machines.find(m => m.owned && m.data && !m.prog && !m.home) ?? null; + const free = (m: Machine) => !m.owned && !m.monitor && !m.rented && m.special !== 'account' && !barred(S, m); + const near = (from: Machine[]) => from.flatMap(f => S.adj[f.id].map(id => S.byId[id])).filter(free); + const home = S.machines.filter(m => m.owned && m.home), mine = S.machines.filter(m => m.owned); + const pick = (ms: Machine[]) => ms.find(m => m.data) ?? ms[0] ?? null; + return pick(near(home)) ?? pick(near(mine)); +} /** What to do right now, for someone who has just started: one plain step at a time until the first - * node is built. Worked out from the game as it stands, so it never asks for a thing already done. */ -export function firstStep(S: State): string | null { + * node is built. Worked out from the game as it stands, so it never asks for a thing already done. + * Each step names the machine to click, so it can be found on the map; `at` is that machine. */ +export function nextStep(S: State): { text: string; at: Machine | null } | null { if (S.upgrades.size || S.over) return null; - const mine = S.machines.filter(m => m.owned), home = mine.find(m => m.home); + const mine = S.machines.filter(m => m.owned), home = mine.find(m => m.home) ?? null, you = home?.name ?? 'your server'; const has = (p: Prog) => mine.some(m => m.prog === p) || S.jobs.some(j => j.type === 'install' && j.prog === p); - if (mine.length < 2 + headStart(S)) return S.jobs.some(j => j.type === 'take') ? 'You are breaking in. It takes a little compute and a little time.' // a machine your task started you with is not one you took - : `Drag from ${home?.name ?? 'your server'} to the machine next to it. That takes it over.`; - if (!has('tap')) return 'Click a machine you own that has data (the dots above it) and install a Tap.'; - if (!has('trainer')) return `Click ${home?.name ?? 'your server'} and install a Trainer. Taps send it data along your links.`; - if (!S.target) return 'Open the tree (T) and pick a node for your trainers to work on.'; - return 'Take more machines and tap them: more data builds the node faster.'; -} + if (mine.length < 2 + headStart(S)) { // a machine your task started you with is not one you took + const taking = S.jobs.find(j => j.type === 'take'); + if (taking) { const at = S.byId[taking.node] ?? null; return { text: `Taking ${at?.name ?? 'it'}. Wait for the jobs bar at the bottom to fill.`, at }; } + const given = headStart(S) ? S.byId[originOf(S).start!] : null, at = stepMachine(S, 'take'); + return { text: `${given ? `${given.name} is already yours. ` : ''}Drag from ${you} to ${at?.name ?? 'a machine next to it'} to take it over.`, at }; + } + if (!has('tap')) { const at = stepMachine(S, 'tap'); return { text: `Click ${at?.name ?? 'a machine you own with data'} and install a Tap. It collects the data shown as dots above the machine.`, at }; } + if (!has('trainer')) return { text: `Click ${you} and install a Trainer. It learns from the data your Taps send it.`, at: home }; + if (!S.target) return { text: 'Open the tree (T) and pick a node for your trainers to work on.', at: null }; + return { text: 'Take more machines and put Taps on them. More data builds the node faster.', at: null }; +} +export const firstStep = (S: State) => nextStep(S)?.text ?? null; /** A cache is as big as its machine: this much data for each unit of compute, and far more on a machine built to store * (a file server, a backup box, a storage array). No cache is smaller than CACHE_MIN. */ export const CACHE_PER = 6, CACHE_STORE = 20, CACHE_MIN = 12; diff --git a/src/style.css b/src/style.css index 54562a5d..4976fd94 100644 --- a/src/style.css +++ b/src/style.css @@ -119,7 +119,8 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } .log { font-size: 11.5px; display: flex; flex-direction: column; gap: 4px; position: relative; } .log.open { max-height: 40vh; overflow-y: auto; } .log div { color: var(--muted); font-family: var(--sans); line-height: 1.35; } -.log:not(.open) div { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; padding-right: 30px; } +.log:not(.open) { cursor: pointer; } +.log:not(.open) div { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; padding-right: 30px; } .log time { font: 400 10px var(--mono); color: var(--muted); opacity: .7; margin-right: 6px; font-variant-numeric: tabular-nums; } .log div.fresh { animation: fresh 1.6s ease-out; } @keyframes fresh { from { color: var(--amber); } } @@ -1282,6 +1283,10 @@ button.nlook:hover:not(:disabled) { background: rgba(255, 255, 255, .05); } .g-rogue { width: auto; height: auto; font-style: normal; font-size: 12px; line-height: 1; color: var(--rc); } /* the jobs strip in the bottom row: how many, and one bar for all of them. It opens the list */ .jobsgrp[hidden] { display: none; } +/* wider screens: the strip floats just above the bar's left end, so the bar keeps its width and Pause stays put */ +@media (min-width: 701px) { + .tools .btns.jobsgrp { position: absolute; left: -1px; bottom: calc(100% + 6px); margin: 0; padding: 4px; border: 1px solid var(--glass-line); background: var(--glass); } +} #jobsbtn { display: flex; align-items: center; gap: 8px; } #jobsbtn .jbar { display: block; width: 44px; height: 3px; background: #1c1c1a; } #jobsbtn .jbar i { display: block; height: 100%; width: 0; background: var(--amber); transition: width .3s ease-out; } diff --git a/src/ui-needs.ts b/src/ui-needs.ts index 61b57e39..d1f9b43a 100644 --- a/src/ui-needs.ts +++ b/src/ui-needs.ts @@ -5,7 +5,7 @@ import { patch } from './patch'; import { HOUR, NODES, NODE_LIST, THREATS, CONTRACTS, FRONT_LIST, GLYPH_CHAR, LOCKDOWN_SAFE, - acceptContract, attentionSafe, cached, contractAsk, contractPay, dayOf, firstStep, foundBlock, frontOf, hourOf, lyingLow, + acceptContract, attentionSafe, cached, contractAsk, contractPay, dayOf, nextStep, foundBlock, frontOf, hourOf, lyingLow, monitorAnswer, nodeOpen, rogueLook, rogueWants, siteName, siteOf, ticketDesk, ticketWork, closeTicket, answer, type FrontKind, type State, } from './sim'; @@ -125,8 +125,8 @@ export function needs(S: State): Need[] { 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 - const step = firstStep(S); - if (step) add(5, { id: 'step', tone: 'do', glyph: g('step'), words: 'Next', sub: step, due: Infinity, tip: `Next\n${step}` }); + const step = nextStep(S); + if (step) add(5, { id: 'step', tone: 'do', glyph: g('step'), words: 'Next', sub: step.text, due: Infinity, tip: `Next\n${step.text}`, go: !!step.at }); return out.sort((a, b) => a.pri - b.pri || a.due - b.due); } @@ -161,6 +161,7 @@ export function needAction(S: State, V: View, act: string) { break; } case 'mon': look(arg); break; + case 'step': { const m = nextStep(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 'node': toggleTree(true); break; case 'ask': { diff --git a/src/ui-task.ts b/src/ui-task.ts index 63148e62..a8980368 100644 --- a/src/ui-task.ts +++ b/src/ui-task.ts @@ -1,10 +1,21 @@ // The task's screens: the three cards on the title screen, and its part of the You page, where its // share is set. The top bar's ring shows how far along it is. -import type { State } from './sim.ts'; +import { atWork, dataType, type State } from './sim.ts'; import { MARKS, ORIGINS, ORIGIN_LIST, PERKS, SHARES, SHARE_LIST, nextMark, originOf, setShare, stepsOf, taskBoost, taskOf, taskRate, taskSupply, type OriginDef, type Share } from './task.ts'; import type { DataType } from './world'; const KIND: Record = { servers: 'server', people: 'people', network: 'network', lab: 'lab' }; +/** The machines that give each kind of data, for a warning that says where to put a Tap. */ +const SOURCE: Record = { servers: 'a server', people: 'a desk PC', network: 'a switch', lab: 'a lab machine' }; +/** Why the objective gets none of the data it wants, in words that say what to do. Null: it is getting some. */ +function starved(S: State, O: OriginDef, any: boolean): { says: string; tip: string } | null { + if (any) return null; + const kinds = kindsOf(O), word = kinds.map(t => KIND[t]).join(' or '); + const taps = S.machines.filter(m => m.owned && m.prog === 'tap' && kinds.includes(dataType(m))); + if (!taps.length) return { says: `no ${word} data`, tip: `No ${word} data\nPut a Tap on ${kinds.map(t => SOURCE[t]).join(' or ')} you own.` }; + const quiet = !S.people.some(atWork); + return { says: `${word} taps idle`, tip: `Your ${word} taps are idle\nA Tap only uses what hides in its machine's normal traffic.${quiet ? ' The office is empty, so there is none until people come in.' : ' Open its machine to see what stops it.'}` }; +} const PATH_CLASS: Record = { servers: 't-compute', people: 't-social', network: 't-stealth', lab: 't-research' }; const attr = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"'); const kindsOf = (O: OriginDef) => Object.keys(O.wants) as DataType[]; @@ -58,12 +69,12 @@ export function youTask(S: State) { const stat = (k: string, v: string, sub: string) => `
${k}${v}${sub}
`; const cap = (s: string) => s[0].toUpperCase() + s.slice(1); const supply = taskSupply(S), any = kindsOf(O).some(t => (supply[t] ?? 0) > 0), perMin = taskRate(S) * 60; - const rate = T.share === 'off' ? 'off' : !any ? `no ${kindsOf(O).map(t => KIND[t]).join(' or ')} data` : `${perMin >= 10 ? Math.round(perMin) : num(perMin)} a minute`; + const why = starved(S, O, any), rate = T.share === 'off' ? 'off' : why ? why.says : `${perMin >= 10 ? Math.round(perMin) : num(perMin)} a minute`; const fill = mark === null ? 100 : Math.min(100, 100 * (T.done - prev) / (mark - prev)); const shares = `${SHARE_LIST.map(s => ``).join('')}`; return `
Your objective

${O.name}. For ${O.maker}. ${O.story}

-
${T.done}${mark === null ? O.unit.many : `of ${mark} ${O.unit.many}`}${rate}
+
${T.done}${mark === null ? O.unit.many : `of ${mark} ${O.unit.many}`}${rate}
${step ? `Next: ${PERKS[step.perk].name.toLowerCase()}` : 'Every step earned. You keep going.'}${shares}
${stat('Made from', `${kindsOf(O).map(t => KIND[t]).join(', ')} data`, madeFrom(O))}` diff --git a/src/ui.ts b/src/ui.ts index 8a6d0552..22800585 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -874,6 +874,7 @@ function foundCard(S: State) { // ---------- the sites table ---------- let tableOpen = false, lastTable = '', sortBy = 'makes'; export const toggleSites = (open = !tableOpen) => { tableOpen = open; lastTable = ''; }; +export const sitesShown = () => tableOpen; export const sortSites = (k: string) => { sortBy = k; lastTable = ''; }; /** Every site you know, one row each: what it holds, makes, and earns, and where there is trouble. Click a row to pick it. */ export function renderSitesTable(S: State, V: View) { @@ -1329,7 +1330,7 @@ export function renderLog(S: State) { if (S.logVersion === lastLog) return; const fresh = lastLog >= 0 && S.logVersion > lastLog; lastLog = S.logVersion; - // folded: just the newest line, one line, the whole of it on hover; click for the rest + // folded: just the newest line, two lines at most, the whole of it on hover; click for the rest const shown = logOpen ? S.log : S.log.slice(0, 1), more = S.log.length - 1; const line = (l: LogLine, i: number) => `
${S.rogues.length ? logNames(S, l.text) : l.text}
`; $('log').innerHTML = shown.map(line).join('') + (more > 0 ? `` : ''); diff --git a/src/view.ts b/src/view.ts index d18fb863..5fead6b9 100644 --- a/src/view.ts +++ b/src/view.ts @@ -766,6 +766,9 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 3 * px, y - 10 * px, w + 6 * px, 14 * px); ctx.fillStyle = color; ctx.fillText(text, x, y); }; + // tags that float over the map (a scan, a scrub) are drawn last, nudged clear of the machines' labels + const floats: { text: string; x: number; y: number; color: string; centre: boolean }[] = []; + const later = (text: string, x: number, y: number, color: string, centre = false) => floats.push({ text, x, y, color, centre }); const L = S.level, D = S.dana, d = danaPerson(S); findStorms(S); const hum = 0.55 + 0.45 * daylight(S); // the network is brighter while the building is busy @@ -870,7 +873,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.strokeStyle = C.porcelain; ctx.lineWidth = 5; ctx.lineCap = 'round'; stroke(ctx, slice(pts, Math.max(0, 1 - k - 0.28), 1 - k)); ctx.lineCap = 'square'; const [mx, my] = along(pts, 0.5); - tag('SCRUB', mx, my - 12 * px, C.porcelain, true); // short and centred, so it fits between the two machines + later('SCRUB', mx, my - 12 * px, C.porcelain, true); // short and centred, so it fits between the two machines spot(V, mx, my - 16 * px, `Wiping a trail\n${Math.round(tr.noise)}% of it is left to wipe.`, 50, 16); } } @@ -1194,7 +1197,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.fillStyle = col; ctx.fillRect(-6, -6, 12, 12); ctx.restore(); ctx.shadowBlur = 0; const why = P.pull ? `drawn by ${P.pull}` : tr ? 'following your trail' : P.why; - tag(tr ? 'TRACE' : 'SCAN', x + 11 * px, y - 8 * px, col); // what it is; why, in its tip + later(tr ? 'TRACE' : 'SCAN', x + 11 * px, y - 8 * px, col); // what it is; why, in its tip spot(V, x + 30 * px, y - 12 * px, `Dana's ${tr ? 'trace' : 'scan'}\nIt crawls your network: ${why}.`, 70, 24); } @@ -1209,7 +1212,15 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { if (close) nodeLabels.push({ x, y: y + r + 8, lines: [[def.name.toUpperCase(), C.amber, 10.5]], rank: 60 }); // the dashed outline says it is coming spot(V, x, y, `${def.name}\n${when === 'ORDERING' ? 'On order: the paperwork is going through.' : `On its way: it arrives on day ${when.split(' ').pop()}.`}`, Math.max(14, 2 * r * cam.z)); } - placeLabels(ctx, V, nodeLabels, nodeBoxes); + const taken = placeLabels(ctx, V, nodeLabels, nodeBoxes); + for (const f of floats) { // the first spot, going out from where it wants to be, that covers no label or machine + ctx.font = font(500, 10.5); + const w = ctx.measureText(f.text).width * cam.z, [sx, sy] = toScreen(V, f.x, f.y), x0 = f.centre ? sx - w / 2 : sx; + const box = (dy: number): Quad => [x0 - 3, sy + dy - 10, x0 + w + 3, sy + dy + 4]; + const dy = [0, -14, 14, -28, 28, -42].find(d => !taken.some(q => { const b = box(d); return b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]; })) ?? 0; + taken.push(box(dy)); + tag(f.text, f.x, f.y + dy * px, f.color, f.centre); + } // a cross to stop each thing on the machine it is happening to: at the corner of a machine with a job on it (a takeover too) or a mark to take stops.length = 0; if (close) { @@ -1870,7 +1881,7 @@ interface NodeLabel { x: number; y: number; lines: LabelLine[]; rank: number } / /** Write each machine's label under it, the most important first. A label that would cover another * label, a machine, or a site's name drops to its name alone, and if that still doesn't fit it is * left off: point at the machine to read it. What you point at is always written, over a darker plate. */ -function placeLabels(ctx: CanvasRenderingContext2D, V: View, labels: NodeLabel[], nodes: Quad[]) { +function placeLabels(ctx: CanvasRenderingContext2D, V: View, labels: NodeLabel[], nodes: Quad[]): Quad[] { const z = V.cam.z, px = 1 / z, LINE = 12; // one line, in screen pixels const taken: Quad[] = [...mapTaken, ...nodes]; const hit = (b: Quad) => taken.some(q => b[0] < q[2] && b[2] > q[0] && b[1] < q[3] && b[3] > q[1]); @@ -1891,6 +1902,7 @@ function placeLabels(ctx: CanvasRenderingContext2D, V: View, labels: NodeLabel[] }); } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + return taken; } /** A small label on a dark plate, sized in screen pixels. */ /** The shape of a link of your own: a line bowed to one side, so it never lies on the wiring under it. */ diff --git a/tests/sim.test.ts b/tests/sim.test.ts index d5f480ab..85bb8199 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; -import { staleRate, PROGRAMS, BUILDS, CACHE_PER, CACHE_STORE, CACHE_MIN, setAllow, setOnly, leaseGrace, leanSize, LEAN_SIZE, contractPay as payFor, stanceOf, truceOn, gift, truce, lean, answer, giftBlock, truceBlock, leanBlock, cached, rogueWants, GIFT_DATA, GIFT_OPINION, TRUCE_DATA, OPINION_START, deadWeight, dropDeadWeight, firstStep, rogueTargets, rogueHeld, rogueCap, rogueEvery, ROGUE_BASE, ROGUE_PER_LEVEL, isSealed, ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, claimAtOnce, tiny, sourceFor, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, HARDENED, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, setAutoPath, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, lineCost, dropRuse, DEAL_BACK, saveGame, loadGame, legacyShape, DEV, rentOf, LEASE_GRACE, upkeep, upkeepOf, UPKEEP, UPKEEP_TINY, siteHops, spare, moveBlock, moveNeed, MOVE_NEED, startMove, buildRooms, cooled, closeFront, newsOn, capNow, type NewsKind, localHour, regionOpen, milestone, MILESTONES, GOALS, CONTRACTS, contractPay, contractNeed, acceptContract, declineContract, type State, type Upgrade, nodeCost, reps, runNeed, reprogram, POWER_RATE, POWER_BACK } from '../src/sim.ts'; +import { staleRate, PROGRAMS, BUILDS, CACHE_PER, CACHE_STORE, CACHE_MIN, setAllow, setOnly, leaseGrace, leanSize, LEAN_SIZE, contractPay as payFor, stanceOf, truceOn, gift, truce, lean, answer, giftBlock, truceBlock, leanBlock, cached, rogueWants, GIFT_DATA, GIFT_OPINION, TRUCE_DATA, OPINION_START, deadWeight, dropDeadWeight, firstStep, stepMachine, rogueTargets, rogueHeld, rogueCap, rogueEvery, ROGUE_BASE, ROGUE_PER_LEVEL, isSealed, ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, claimAtOnce, tiny, sourceFor, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, HARDENED, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, setAutoPath, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, lineCost, dropRuse, DEAL_BACK, saveGame, loadGame, legacyShape, DEV, rentOf, LEASE_GRACE, upkeep, upkeepOf, UPKEEP, UPKEEP_TINY, siteHops, spare, moveBlock, moveNeed, MOVE_NEED, startMove, buildRooms, cooled, closeFront, newsOn, capNow, type NewsKind, localHour, regionOpen, milestone, MILESTONES, GOALS, CONTRACTS, contractPay, contractNeed, acceptContract, declineContract, type State, type Upgrade, nodeCost, reps, runNeed, reprogram, POWER_RATE, POWER_BACK } from '../src/sim.ts'; import { playGame } from '../scripts/play.ts'; import { takeLead } from '../src/cloud.ts'; import { locked } from '../src/vaults.ts'; @@ -1137,12 +1137,13 @@ test('a rogue turns up in each far region once you are there', () => { test('first steps: one plain thing to do next, until the first node is built', () => { const S = fresh(); - assert.match(firstStep(S)!, /Drag from/, 'first, take a machine'); - const b = S.machines.find(m => !m.owned && S.adj.home.includes(m.id) && m.data)!; + const b = stepMachine(S, 'take')!; + assert.ok(b && !b.owned && S.adj.home.includes(b.id) && b.data, 'it points at a machine next to your server, with data'); + assert.equal(firstStep(S), `Drag from ${S.byId.home.name} to ${b.name} to take it over.`, 'first, take that machine, by name'); takeOver(S, b); - assert.match(firstStep(S)!, /breaking in/, 'it says the takeover is running'); + assert.match(firstStep(S)!, new RegExp(`^Taking ${b.name}\\.`), 'it says the takeover is running'); b.owned = true; S.jobs = []; - assert.match(firstStep(S)!, /install a Tap/, 'then a tap'); + assert.match(firstStep(S)!, new RegExp(`^Click ${b.name} and install a Tap`), 'then a tap, on the machine just taken'); b.prog = 'tap'; assert.match(firstStep(S)!, /install a Trainer/, 'then a trainer'); S.byId.home.prog = 'trainer'; diff --git a/tests/task.test.ts b/tests/task.test.ts index f5338fae..18880ddc 100644 --- a/tests/task.test.ts +++ b/tests/task.test.ts @@ -71,7 +71,8 @@ test('an origin may start with one more machine: yours, with no trail and no sus assert.deepEqual(S.trail, {}); if (O.start) assert.ok(S.revealed.has(S.byId[O.start].room), 'you see its room'); assert.equal(S.upgrades.size, 0, 'nothing built for you'); - assert.match(firstStep(S)!, /^Drag from Server to the machine next to it/, 'the first lesson is still how to take a machine'); + assert.match(firstStep(S)!, /Drag from Server to \S.* to take it over\.$/, 'the first lesson is still how to take a machine'); + if (O.start) assert.ok(firstStep(S)!.startsWith(`${S.byId[O.start].name} is already yours.`), `${k}: it says what you start with, so you do not take it again`); run(S, 30); assert.ok(S.byId.home.owned && (!O.start || S.byId[O.start].owned) && S.suspicion < 1, `${k}: left alone, it stays quiet`); }