v0.7.3 — a game that asks before it ends, and a results screen worth reading
Two issues off the tracker, and they are halves of one thing: the end of a game. Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and these are not fixes people mid-playtest need. EXTENDED PLAY (#11). The official result is settled at the original game length and never changes: in a five-Day game extended to eight, the winner is whoever led at the end of Day 5. Extending grants exactly one Day and the question is put again at the end of it — solitaire the player decides alone, multiplayer it is unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4 collision breach is final, during an extended Day exactly as during the scheduled game. It could not be a client-side change. `check` refused every intent once `status` left `active`; the server never loads a `finished` game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a "continue" the history does not record did not happen. Hence a fourth status, `awaitingExtension`, and a `game.extend` intent. `config.days` never moves — `extraDays` counts the borrowed Days and `official` freezes the outcome, the standings and the statistics at the first ending. THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an internal enum interpolated into the page at the one moment the game has the player's whole attention. Every reason now has a sentence with the game's own numbers in it. Around it: the result and winner, standings, the rules the game was dealt under, a per-player breakdown, and the railroad — trains through the Division and how many worked en route, loads made up and broken, passengers, cars switched, trains destroyed. It shares the Day-end dialog's blocks rather than reimplementing them, and stays reopenable so continuing does not cost you the results. Statistics are folded, not recorded: `state.tally` counts what the event stream says happened, hooked at `applyIntent` and `advance` because `reduce` never sees the phase driver's events — and those are the interesting ones. Nothing in the rules reads it, and it rides the Frame, so multiplayer gets the same numbers as solitaire from one implementation. THREE BUGS FOUND IN TESTING, all of which would have shipped: - a saved game containing a vote could not be resumed (NO_ACTOR). A history is a flat Intent[] with no seat recorded; the replay derives who acted from the turn order, which cannot work for an intent every seat may send in any order. `game.extend` carries its voter, checked against the authenticated seat. - an all-bot game hung on the question for ever. `driveBots` loops on `currentActor`, null the moment the game stops, so it cannot cast a vote, and the bot-vote driver returned early with no humans to follow. - the balance harness became unbounded — `test/sim.test.ts` went from under a second to never finishing. `randomBot` took another Day about half the time, so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver, not in a policy, so it holds for bots not yet written. All three have regression tests. 832 tests pass, against 793 before this change. NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not: reading advance.ts, it fires once per game and only for a train whose profile sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a second. The streak was built, rendered "1 Stage at (0,0)", and was taken out again. There is no per-Stage "this train did not move" signal in the engine, so "longest an engine sat on a siding" needs one first; TODO.md #36 records what it would take, and the Circus set-up is reported instead. Badges remain the second pass #16 asks for (TODO.md #33), and because the statistics are derived rather than recorded, that pass can add any of them retroactively to games already played and saved. Extended play has not yet been played at a real table (TODO.md #35): the multiplayer vote has only been driven through `session.intent`, never through two browsers. Closes #11 Closes #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
This commit is contained in:
co-authored by
Claude Opus 5
parent
510e33bac7
commit
45580d8b61
+321
-38
@@ -168,52 +168,319 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
*/
|
||||
export function dayEndHtml(f: Frame): string {
|
||||
const ended = f.day - 1;
|
||||
const left = f.days - ended;
|
||||
const standings = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
||||
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
// table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
// target reads as a hopeless position when the table may be comfortably ahead.
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const target =
|
||||
f.minCombinedRevenue > 0
|
||||
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
|
||||
: '';
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
const collisions = scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
const left = f.days + f.extraDays - ended;
|
||||
|
||||
const ahead =
|
||||
left <= 0
|
||||
? '<p>That was the last Day on the timetable.</p>'
|
||||
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
: `<p><b>Day ${f.day} of ${f.days + f.extraDays}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||
ahead +
|
||||
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
||||
target +
|
||||
collisions
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared between the Day-end dialog and the end-of-game results screen.
|
||||
//
|
||||
// Gitea#16 asked for the results screen and Gitea#10's dialog had already assembled most of it. The
|
||||
// three blocks below are the overlap, factored out rather than written twice: the two screens report
|
||||
// the same numbers about the same game, and the one thing they must never do is disagree.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every player in Revenue order, the viewer marked.
|
||||
*
|
||||
* `winner` rings the player the OFFICIAL result named, which is not always the player at the top:
|
||||
* in an extended game the standings keep moving after the result is settled, and showing the leader
|
||||
* without saying who actually won would be the screen contradicting itself.
|
||||
*/
|
||||
function standingsHtml(f: Frame, winner: number | null = null): string {
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const marks =
|
||||
(p.index === f.viewer ? ' <span class="dim">(you)</span>' : '') +
|
||||
(p.index === winner ? ' <span class="wins">— winner</span>' : '');
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}${marks}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<table class="dayend-t"><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
* table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
* target reads as a hopeless position when the table may be comfortably ahead.
|
||||
*/
|
||||
function targetHtml(f: Frame): string {
|
||||
if (f.minCombinedRevenue <= 0) return '';
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const met = combined >= f.minCombinedRevenue;
|
||||
return (
|
||||
`<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>` +
|
||||
`${met ? ' — cleared.' : ' — short.'}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE GAME ENDED, as a sentence (Gitea#16).
|
||||
*
|
||||
* The page used to interpolate `outcome.reason` straight into the DOM, so a player who finished a
|
||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
||||
* the game has the player's whole attention. Each reason gets a sentence that says what actually
|
||||
* happened, with this game's own numbers in it.
|
||||
*/
|
||||
function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
switch (o.reason) {
|
||||
case 'daysElapsed':
|
||||
return `Day ${day} was the last on the timetable, and it ran out.`;
|
||||
case 'revenueFloor':
|
||||
return (
|
||||
`The Division closed short: <b>${combined}</b> Revenue between everyone, against a floor of ` +
|
||||
`<b>${f.minCombinedRevenue}</b>. §3.3 — miss the floor and the whole table loses, whoever ` +
|
||||
`earned the most.`
|
||||
);
|
||||
case 'collisionFloor':
|
||||
return (
|
||||
`Too many collisions — <b>${f.collisionsToday}</b> in one Day and <b>${f.collisionsTotal}</b> ` +
|
||||
`in all, against limits of ${f.maxCollisionsPerDay || '—'} and ${f.maxCollisionsTotal || '—'}. ` +
|
||||
`§3.4 — the railroad was declared unsafe and the game was stopped.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rules this game was actually dealt under — Gitea#16's "what the rules of the game were". */
|
||||
function rulesHtml(f: Frame): string {
|
||||
const mode =
|
||||
f.mode === 'coop' ? 'Co-op — the table scores together' :
|
||||
f.mode === 'competitive' ? 'Competitive — highest Revenue wins' :
|
||||
'Solitaire';
|
||||
const optional = [
|
||||
f.optionalRules.employeeRotation ? 'Employee Rotation' : null,
|
||||
f.optionalRules.reducedVisibility ? 'Reduced Visibility' : null,
|
||||
f.optionalRules.emergencyToolbox ? 'Emergency Toolbox' : null,
|
||||
].filter((x): x is string => x !== null);
|
||||
const r = f.houseRules.revenue;
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Scoring', mode],
|
||||
['Timetable', f.extraDays > 0
|
||||
? `${f.days} Days, extended by ${f.extraDays} more`
|
||||
: `${f.days} Day${f.days === 1 ? '' : 's'}`],
|
||||
['Revenue floor', f.minCombinedRevenue > 0 ? `${f.minCombinedRevenue} combined` : 'none'],
|
||||
['Collision limits', f.maxCollisionsPerDay > 0 || f.maxCollisionsTotal > 0
|
||||
? `${f.maxCollisionsPerDay || '—'} per Day, ${f.maxCollisionsTotal || '—'} in all`
|
||||
: 'not scored'],
|
||||
['Pay rates', `${r.freightPerLoad} per load, ${r.passengerPerCoach} per coach, ${r.trainPerTransit} per transit`],
|
||||
['Extras start', f.houseRules.extraStart === 'divisionPointsOnly' ? 'Division Points and the Interchange'
|
||||
: f.houseRules.extraStart === 'ownOffice' ? 'those, plus your own Control Point'
|
||||
: 'those, plus any Control Point'],
|
||||
['Timetabled trains', f.houseRules.discardTimetabled ? 'may be discarded' : 'are never discarded'],
|
||||
['Optional rules', optional.length ? optional.join(', ') : 'none'],
|
||||
];
|
||||
|
||||
return `<h4 class="res-h">The rules in play</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-column table of plain-text facts.
|
||||
*
|
||||
* BOTH HALVES ESCAPED, exactly once, which is the only reason this is a shared helper rather than a
|
||||
* template repeated twice. The rows it is given today are numbers and fixed phrases, but they are
|
||||
* assembled from the Frame — and the day somebody adds a row carrying a player's name, or a facility
|
||||
* label, the escaping has to already be here rather than be remembered.
|
||||
*/
|
||||
function factTable(rows: [string, string][]): string {
|
||||
return (
|
||||
'<table class="res-t"><tbody>' +
|
||||
rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('') +
|
||||
'</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE RAILROAD — what actually happened out there, from `GameState.tally` (Gitea#16).
|
||||
*
|
||||
* Rows that would read zero for a reason (no passenger work in a game that had none, no collisions
|
||||
* in a clean one) are dropped rather than printed as `0`: a screen of zeroes reads as a bug, and the
|
||||
* absence of a line is the same information more quietly. A zero that is genuinely interesting —
|
||||
* trains through the Division — stays.
|
||||
*/
|
||||
function tallyHtml(t: Frame['tally']): string {
|
||||
const rows: [string, string][] = [['Trains through the Division', String(t.trainsCompleted)]];
|
||||
|
||||
if (t.trainsCompleted > 0) {
|
||||
rows.push([
|
||||
'Of those, worked en route',
|
||||
`${t.trainsCompletedWithWork} of ${t.trainsCompleted}` +
|
||||
(t.trainsCompletedWithWork === t.trainsCompleted ? ' — every one' : ''),
|
||||
]);
|
||||
}
|
||||
const push = (label: string, n: number, detail = ''): void => {
|
||||
if (n > 0) rows.push([label, `${n}${detail}`]);
|
||||
};
|
||||
push('Loads made up', t.loadsCompleted);
|
||||
push('Loads broken', t.unloadsCompleted);
|
||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||
push('Passengers boarded', t.passengersBoarded);
|
||||
push('Passengers detrained', t.passengersDetrained);
|
||||
push('Cars coupled', t.carsCoupled);
|
||||
push('Cars set out', t.carsDropped);
|
||||
push('Extras run', t.extrasStarted);
|
||||
push('Second sections ordered', t.secondSections);
|
||||
push('Flying switches', t.flyingSwitches);
|
||||
push('Offices upgraded', t.officeUpgrades);
|
||||
push('Facilities unjammed', t.facilitiesUnjammed);
|
||||
push('Trains held', t.trainsHeld);
|
||||
push('Trains diverted', t.trainsDiverted);
|
||||
push('Expedite faults', t.expediteFaults);
|
||||
push('Dispatch bonuses used', t.dispatchBonusesUsed);
|
||||
if (t.clearancesRequested > 0) {
|
||||
rows.push(['Clearances', `${t.clearancesAllowed} allowed of ${t.clearancesRequested} asked`]);
|
||||
}
|
||||
if (t.trainsDestroyed > 0) {
|
||||
rows.push([
|
||||
'Trains destroyed',
|
||||
`${t.trainsDestroyed}, taking ${t.carsDestroyed} car${t.carsDestroyed === 1 ? '' : 's'} with them`,
|
||||
]);
|
||||
}
|
||||
// §X18 only — the one card in the deck that pays a train for standing still. Reported as what it
|
||||
// is rather than as "the longest an engine sat on a siding", which the engine cannot answer (see
|
||||
// `Tally.circusStops`).
|
||||
if (t.circusStops.length > 0) {
|
||||
rows.push([
|
||||
'Circus set-ups',
|
||||
t.circusStops.map((c) => `Train ${c.trainNumber} at ${c.where}`).join(', '),
|
||||
]);
|
||||
}
|
||||
push('Cards drawn', t.cardsDrawn);
|
||||
push('Cards played', t.cardsPlayed);
|
||||
|
||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/** Per-player work, for a table that wants to know who did what rather than only who won. */
|
||||
function perPlayerHtml(f: Frame): string {
|
||||
const t = f.tally;
|
||||
if (f.players.length < 2) return '';
|
||||
const head =
|
||||
'<tr><th></th><th class="num">Rev</th><th class="num">Loads</th><th class="num">Unloads</th>' +
|
||||
'<th class="num">Pass.</th><th class="num">Cards</th><th class="num">Crashes</th></tr>';
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const q = t.byPlayer[p.index];
|
||||
if (!q) return '';
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}</td>` +
|
||||
`<td class="num">${p.revenue}</td><td class="num">${q.loads}</td>` +
|
||||
`<td class="num">${q.unloads}</td>` +
|
||||
`<td class="num">${q.passengersBoarded + q.passengersDetrained}</td>` +
|
||||
`<td class="num">${q.cardsPlayed}</td><td class="num">${q.collisions}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<h4 class="res-h">Who did what</h4><table class="res-t res-wide"><tbody>${head}${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE END-OF-GAME RESULTS SCREEN — Gitea#16, first pass.
|
||||
*
|
||||
* Everything the Frame already knew plus everything the event tally counted, in the order a player
|
||||
* asks for it: what happened, who won, by how much, under what rules, and then what the railroad
|
||||
* actually did all game. Badges and the "what would make this exciting" brainstorm are the second
|
||||
* pass the issue asks for and are deliberately not here.
|
||||
*
|
||||
* THE OFFICIAL RESULT IS THE ONE AT THE TOP, always. In an extended game (Gitea#11) the standings go
|
||||
* on moving after the winner is settled, so this screen reports the frozen result first and puts
|
||||
* everything that happened afterwards in its own section, marked as informational. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days" (Jesse, 2026-08-28).
|
||||
*/
|
||||
export function resultsHtml(f: Frame): string {
|
||||
// `official` is written by the engine the moment any game ends, so it is present on every finished
|
||||
// game. The fallback keeps this rendering something sane for a Frame that predates it — a replay
|
||||
// of a save recorded before this release, which the replay viewer will happily hand us.
|
||||
const report = f.official;
|
||||
const o = report?.outcome ?? f.outcome;
|
||||
if (!o) return '<p class="dim">This game has not ended.</p>';
|
||||
|
||||
const officialDay = report?.day ?? f.days;
|
||||
const winnerName =
|
||||
o.winner === null ? null : (f.players.find((p) => p.index === o.winner)?.name ?? null);
|
||||
|
||||
const headline =
|
||||
o.result === 'loss'
|
||||
? 'The Division failed'
|
||||
: winnerName === null
|
||||
? 'The Division ran'
|
||||
: `${esc(winnerName)} takes the Division`;
|
||||
|
||||
/**
|
||||
* The result is reported against the standings AS THEY WERE at the official ending, not as they
|
||||
* are now — in an extended game those are different numbers, and the winner has to be shown
|
||||
* winning. `revenues` is frozen alongside the outcome for exactly this.
|
||||
*/
|
||||
const frozen = report
|
||||
? { ...f, players: f.players.map((p) => ({ ...p, revenue: report.revenues[p.index] ?? p.revenue })) }
|
||||
: f;
|
||||
|
||||
const result =
|
||||
o.result === 'loss'
|
||||
? '<p>Nobody wins this one.</p>'
|
||||
: o.winner === null
|
||||
? '<p>The table clears it together — a Co-op game has no individual winner.</p>'
|
||||
: `<p><b>${esc(winnerName ?? '')}</b> finishes ahead on Revenue.</p>`;
|
||||
|
||||
const extended =
|
||||
f.extraDays > 0
|
||||
? '<h4 class="res-h">After the timetable</h4>' +
|
||||
`<p>The table played on for ${f.extraDays} more Day${f.extraDays === 1 ? '' : 's'}, ` +
|
||||
`through Day ${f.days + f.extraDays}. None of it changed the result above — it is recorded ` +
|
||||
'here because it happened.</p>' +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f) +
|
||||
tallyHtml(f.tally)
|
||||
: '';
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h res-${o.result}">${headline}</h3>` +
|
||||
`<p>${reasonSentence(frozen, o, officialDay)}</p>` +
|
||||
result +
|
||||
standingsHtml(frozen, o.winner) +
|
||||
targetHtml(frozen) +
|
||||
collisionsHtml(frozen) +
|
||||
perPlayerHtml(frozen) +
|
||||
rulesHtml(f) +
|
||||
tallyHtml(report?.tally ?? f.tally) +
|
||||
extended
|
||||
);
|
||||
}
|
||||
|
||||
@@ -479,4 +746,20 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.dayend-t tr:first-child td{border-top:0}
|
||||
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||
.dayend-t .you td{color:#8fd6a0}
|
||||
.dayend-t .wins{color:#e8c56a;font-weight:700}
|
||||
|
||||
/* END-OF-GAME RESULTS (Gitea#16). Same family as the Day-end dialog above, which is the point —
|
||||
the two screens share their standings/target/collision blocks and should look like each other. */
|
||||
.res-h{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b95a3;
|
||||
margin:16px 0 6px;border-top:1px solid #2c333d;padding-top:10px}
|
||||
.res-win{color:#8fd6a0}
|
||||
.res-loss{color:#d98f8f}
|
||||
.res-t{border-collapse:collapse;margin:4px 0;width:100%}
|
||||
.res-t td,.res-t th{padding:3px 12px 3px 0;border-top:1px solid #232a33;vertical-align:top}
|
||||
.res-t tr:first-child td{border-top:0}
|
||||
.res-t td:first-child{color:#8b95a3;white-space:nowrap}
|
||||
.res-t th{color:#6d7783;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.res-t .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.res-wide td:first-child{color:#e6e9ee}
|
||||
.res-t .you td{color:#8fd6a0}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user