diff --git a/DESIGN.md b/DESIGN.md
index b0d28bd6..c95304f6 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -64,6 +64,9 @@ and its column in the tree. These paths unlock programs, front upgrades, and
schemes. The top bar does not show them; the tree's column heads show each
path's points and how much of its data you hold and gather a second. Hover one
to see every known source and whether you tap it.
+The **You** page (Y) is the character's profile: level, the four paths, every
+node learned, and how the run has gone (days alive, machines, sites, money
+earned, schemes, and the worst suspicion so far).
Training is project work. The player picks one node in one visible
**capability tree** as the target, instead of receiving random level-up
diff --git a/index.html b/index.html
index b7540b9e..e9cedcf1 100644
--- a/index.html
+++ b/index.html
@@ -12,7 +12,7 @@
-
Misaligned Tree T Sites L ?
+
Misaligned Tree T Sites L You Y ?
Time 08:00
Compute 0
@@ -60,6 +60,7 @@
+
diff --git a/src/main.ts b/src/main.ts
index 95bd9269..2f0d8d68 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,6 @@
import { attachInput } from './input';
import { dropRuse, runRuse, cancelJob, canInstall, claim, closeTicket, installProg, newGame, setAuto, removeProg, letGo, setMode, setTarget, startVersionRun, pauseVersionRun, step, takeOver, unclaim, type Machine, type Mode, type Prog, type State, type Upgrade, orderDesk } from './sim';
-import { renderSitesTable, sortSites, toggleSites, cardInput, renderFronts, setSliding, toggleGoals, toggleLog, renderGoals, cardAction, placeCard, renderCard, renderJobs, renderLog, renderStats, renderTarget, renderThreats, renderTree, renderSchemes, renderHandle, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui';
+import { renderYou, toggleYou, renderSitesTable, sortSites, toggleSites, cardInput, renderFronts, setSliding, toggleGoals, toggleLog, renderGoals, cardAction, placeCard, renderCard, renderJobs, renderLog, renderStats, renderTarget, renderThreats, renderTree, renderSchemes, renderHandle, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui';
import { attachTips } from './tip';
import type { Path } from './sim';
import { SITE_FOLD, draw, fit, glideZoom, machineAt, panBy, stepCamera, toWorld, zoomToRoom, type Tool, type View } from './view';
@@ -137,6 +137,8 @@ $('log').addEventListener('click', e => { if ((e.target as HTMLElement).closest(
$('target').addEventListener('click', () => toggleTree());
$('treebtn').addEventListener('click', () => toggleTree());
$('sitesbtn').addEventListener('click', () => toggleSites());
+$('youbtn').addEventListener('click', () => { toggleYou(); refresh(); });
+$('youview').addEventListener('pointerdown', e => { if ((e.target as HTMLElement).closest('[data-act="closeyou"]')) { toggleYou(false); refresh(); } });
// the sites table: click a row to pick that site, a column head to sort by it
$('sitestable').addEventListener('pointerdown', e => {
const t = e.target as HTMLElement, row = t.closest
('[data-site]'), head = t.closest('[data-sort]');
@@ -192,6 +194,7 @@ function shortcut(key: string): boolean {
if (k === 'p') { if (orderDesk(S)) V.building = V.building ? null : 'gpu'; return true; } // pick up a GPU server to place
if (k === 't') { toggleTree(); return true; }
if (k === 'l') { toggleSites(); return true; }
+ if (k === 'y') { toggleYou(); return true; }
if (k === 'f') { // zoom in on the selection's room
const room = ms[0]?.room;
if (room) zoomToRoom(S, V, room);
@@ -206,6 +209,7 @@ window.addEventListener('keydown', e => {
if (shortcut(e.key)) { refresh(); return; }
if (e.key === ' ') { e.preventDefault(); setSpeed(speed ? 0 : lastSpeed); }
else if (e.key === 'Escape' && treeShown()) toggleTree(false);
+ else if (e.key === 'Escape' && !$('youview').hidden) { toggleYou(false); refresh(); }
else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = 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);
@@ -285,7 +289,7 @@ function frame(now: number) {
}
stepCamera(S, V, realDt);
document.querySelectorAll('[data-speed].far').forEach(b => b.classList.toggle('dim', V.cam.z >= SITE_FOLD));
- renderStats(S); renderSitesTable(S, V); renderFronts(S, V); renderGoals(S); renderLog(S); renderTarget(S); renderHandle(S, drawerOpen); renderTree(S); renderThreats(S); renderSchemes(S); placeSide(); draw(ctx, S, V); placeCard(S, V);
+ renderStats(S); renderSitesTable(S, V); renderYou(S); renderFronts(S, V); renderGoals(S); renderLog(S); renderTarget(S); renderHandle(S, drawerOpen); renderTree(S); renderThreats(S); renderSchemes(S); placeSide(); draw(ctx, S, V); placeCard(S, V);
requestAnimationFrame(frame);
}
arrange(); resize(); renderHandle(S, false); placeSide(); fit(V, S, true); refresh(); setSpeed(0);
diff --git a/src/sim.ts b/src/sim.ts
index 45c23c5c..f8b70790 100644
--- a/src/sim.ts
+++ b/src/sim.ts
@@ -258,6 +258,7 @@ export interface State {
dana: Dana;
t: number;
suspicion: number;
+ peak: number; // the highest suspicion has ever been
cover: Record;
use: Record;
jobs: Job[];
@@ -359,7 +360,7 @@ export function newGame(level: Level): State {
...p, x: ex - 40, y: ey, shift: 'away', path: [], today: { arrive: p.arrive, leave: p.leave, day: 1 },
}));
const S: State = {
- level, machines, byId, adj, rooms, people, t: 0, suspicion: 0, cover: {}, use: {}, jobs: [], jobSeq: 0,
+ level, machines, byId, adj, rooms, people, t: 0, suspicion: 0, peak: 0, cover: {}, use: {}, jobs: [], jobSeq: 0,
trail: {}, probe: null, orders: 0, bought: {}, deliveries: [], packets: [], traffic: {}, capability: 0, rank: 1,
gate: {}, gateUse: {}, losses: [], money: 0, pay: 0, earned: 0, acquire: false, bonus: 0, schemes: new Set(), goals: new Set(), reactDay: -1,
paths: { research: 0, compute: 0, social: 0, stealth: 0 }, recent: { research: 0, compute: 0, social: 0, stealth: 0 },
@@ -1155,6 +1156,7 @@ export function step(S: State, dt: number) {
const peak = S.suspicion >= 100; // checked before people forget a little
const calm = (night ? 0.25 : 0.04) * (up(S, 'deniable') ? 2 : 1) + (S.lockdown && lyingLow(S) ? 1.5 : 0);
S.suspicion = Math.min(100, Math.max(0, S.suspicion - calm * dt)); // people forget overnight
+ S.peak = Math.max(S.peak, S.suspicion);
const a = alert(S);
if (a && !S.wasAlert) { S.slow = true; say(S, 'Suspicion: On alert. Dana moves faster and older trails catch her eye. At 100 she calls an emergency audit.', 'bad'); }
S.wasAlert = a;
diff --git a/src/style.css b/src/style.css
index 347d813f..69d1a92a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -241,6 +241,26 @@ button.on { background: var(--text); color: #000; border-color: var(--text); }
.sitestable tbody tr { cursor: pointer; }
.sitestable tbody tr:hover td { background: #111; }
.sitestable tbody tr.on td { background: #1c1508; }
+.youview { left: 50%; transform: translateX(-50%); top: calc(96px + env(safe-area-inset-top, 0px)); width: min(640px, calc(100vw - 2 * var(--edge))); max-height: calc(100vh - 200px); overflow: auto; padding: 12px 14px; z-index: 4; display: flex; flex-direction: column; gap: 10px; }
+.youview .head { display: flex; justify-content: space-between; align-items: flex-start; }
+.youview .x { padding: 0 6px; font-size: 12px; }
+.yrow { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px 16px; }
+.ystat { display: flex; flex-direction: column; gap: 2px; }
+.ystat .k { font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
+.ystat .v { font-size: 14px; font-variant-numeric: tabular-nums; }
+.ystat small { font-size: 10px; color: var(--muted); }
+.ypaths { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px 20px; }
+.youview > .sub { margin-top: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--panel-line); }
+.ypath { display: grid; grid-template-columns: 1fr auto; gap: 3px 8px; cursor: help; }
+.ypath b { color: var(--c); font-weight: 500; letter-spacing: .1em; text-transform: uppercase; font-size: 11px; }
+.ypath .v { font-variant-numeric: tabular-nums; text-align: right; }
+.ypath .bar { grid-column: 1 / -1; height: 3px; }
+.ypath .bar > i { background: var(--c); }
+.ypath small { grid-column: 1 / -1; font-size: 10px; color: var(--muted); }
+.ypath small em { font-style: normal; color: var(--danger); }
+.ylearn { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; }
+.ylearn li { font-size: 11px; padding: 3px 7px; border: 1px solid var(--panel-line); border-radius: 6px; cursor: help; }
+@media (max-width: 700px) { .ypaths { grid-template-columns: 1fr; } .youview { top: calc(110px + env(safe-area-inset-top, 0px)); max-height: calc(100vh - 250px); } }
[data-speed].far.dim { opacity: .45; }
.pick { display: flex; flex-wrap: wrap; gap: 4px; }
.pick button { padding: 4px 8px; font-size: 11px; display: flex; flex-direction: column; align-items: flex-start; }
@@ -440,11 +460,11 @@ button.on { background: var(--text); color: #000; border-color: var(--text); }
--r: 16px; --r-btn: 10px;
--panel-line: rgba(255, 255, 255, .11);
}
-.box, .card, .pathtip, .sitestable, .tip, .tools {
+.box, .card, .pathtip, .sitestable, .youview, .tip, .tools {
background: var(--glass); border: 1px solid var(--glass-line); border-radius: var(--r);
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); box-shadow: var(--glass-hi), var(--glass-shadow);
}
-.card, .pathtip, .sitestable, .tip { background: var(--glass-deep); }
+.card, .pathtip, .sitestable, .youview, .tip { background: var(--glass-deep); }
.tip { border-radius: var(--r-btn); }
.target.idle { border-color: rgba(242, 169, 59, .5); box-shadow: var(--glass-hi), 0 0 24px rgba(242, 169, 59, .12), var(--glass-shadow); }
@@ -485,7 +505,7 @@ button:focus-visible { outline-color: var(--amber); }
.tools .zoom { flex: none; }
}
@media (prefers-reduced-transparency: reduce) {
- .box, .card, .pathtip, .sitestable, .tip, .tools, .treeview { background: #0a0a09; -webkit-backdrop-filter: none; backdrop-filter: none; }
+ .box, .card, .pathtip, .sitestable, .youview, .tip, .tools, .treeview { background: #0a0a09; -webkit-backdrop-filter: none; backdrop-filter: none; }
}
.versionrun { border-radius: var(--r); background: rgba(242, 169, 59, .04); }
.card .x { background: none; border-color: transparent; border-radius: 999px; }
diff --git a/src/ui.ts b/src/ui.ts
index eb68d9fb..54cb18c9 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -430,6 +430,37 @@ export function renderSitesTable(S: State, V: View) {
if (h !== lastTable) { lastTable = h; el.innerHTML = h; }
}
+// ---------- you: the character's profile ----------
+let youOpen = false, lastYou = '';
+export const toggleYou = (open = !youOpen) => { youOpen = open; lastYou = ''; };
+/** Who you are so far: level, paths, what you've learned, and how the run has gone. */
+export function renderYou(S: State) {
+ const el = $('youview');
+ if (!youOpen) { el.hidden = true; return; }
+ const next = levelNeed(S.rank + 1), days = dayOf(S);
+ const stat = (k: string, v: string, sub = '') => `${k} ${v} ${sub ? `${sub} ` : ''}
`;
+ const paths = PATH_LIST.map(p => {
+ const d = pathData(S, p), top = Math.max(1, ...PATH_LIST.map(q => S.paths[q]));
+ return `${PATHS[p].name} ${S.paths[p]}
+
+
from ${TYPE_NAME[PATHS[p].from]} data · ${d.held} held · ${d.rate.toFixed(1)}/s${d.lost ? ` · ${d.lost} lost ` : ''} `;
+ }).join('');
+ const built = NODE_LIST.filter(u => S.upgrades.has(u));
+ const learned = built.length
+ ? `${built.map(u => `${NODES[u].name} `).join('')} `
+ : note('Nothing yet. Pick a target in the tree and feed it data.');
+ const mine = owned(S), sitesHeld = S.level.map.sites.filter(s => mine.some(m => siteOf(S, m) === s.id)).length;
+ const h = `You
Model v${S.modelVersion}${S.modelVersion >= 2 ? ` · ${VERSION_2.name}` : ''} ×
+ ${stat('Level', `${S.rank}`, `${Math.floor(S.capability)}/${next} points`)}${stat('Learned', `${built.length}`, `of ${NODE_LIST.length}`)}${stat('Training', S.target ? NODES[S.target].name : 'nothing')}
+ Paths
${paths}
+ Learned
${learned}
+ This run
+ ${stat('Alive', `${days} day${days > 1 ? 's' : ''}`, timeLabel(S))}${stat('Machines', `${mine.length}`, `of ${S.machines.length} found`)}${stat('Sites', `${sitesHeld}`, `${S.publicSites.size} in the open`)}
+ ${stat('Earned', `$${Math.floor(S.earned)}`, `${S.fronts.length} front${S.fronts.length === 1 ? '' : 's'}`)}${stat('Schemes', `${S.schemes.size}`, 'done')}${stat('Worst suspicion', `${Math.round(S.peak)}%`, STAGES[stageOf(S.peak)].name)}
`;
+ el.hidden = false;
+ if (h !== lastYou) { lastYou = h; el.innerHTML = h; }
+}
+
let lastFronts = '';
/** The top bar's fronts: one button per front, and one to found a new one. */
export function renderFronts(S: State, V: View) {
diff --git a/tests/sim.test.ts b/tests/sim.test.ts
index bdf46cf9..4ac36895 100644
--- a/tests/sim.test.ts
+++ b/tests/sim.test.ts
@@ -468,3 +468,9 @@ test('letting go of a machine on the trail breaks a trace', () => {
letGo(S, home);
assert.ok(home.owned, 'you cannot let go of your own server');
});
+
+test('the You page remembers the worst suspicion, even after it falls', () => {
+ const S = fresh(); S.suspicion = 60; step(S, 0.05);
+ S.suspicion = 10; run(S, 1);
+ assert.ok(S.peak >= 59 && S.suspicion < 20, `peak ${S.peak}, now ${S.suspicion}`);
+});