v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
+70
-7
@@ -474,6 +474,65 @@ export function officeSvg(
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* A 45° LEG, drawn as a smooth curve rather than two straight segments meeting at a hard corner.
|
||||
*
|
||||
* It is an EASEMENT, not an arbitrary curve: tangent to horizontal at `p0` (the east or west edge),
|
||||
* so an abutting straight card's through-rail still reads as one unbroken line, and tangent to
|
||||
* exactly 45° at `p3` (the north or south edge), so two stacked curves still read as one continuous
|
||||
* diagonal and the edge-crossing angle the matching rule depends on is unchanged. `p1`/`p2` are
|
||||
* cubic-Bezier control points chosen to hit those two tangents — see the call site.
|
||||
*
|
||||
* Sampled as a short polyline rather than left as one SVG path command, because the two parallel
|
||||
* rails and the tie marks all need points evenly spaced ALONG THE CURVE with the local tangent at
|
||||
* each one — `rail()`'s straight-line version does the same sampling, just on a line.
|
||||
*/
|
||||
const curvedRail = (
|
||||
p0: { x: number; y: number },
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number },
|
||||
p3: { x: number; y: number },
|
||||
): string => {
|
||||
const at = (t: number): { x: number; y: number } => {
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
|
||||
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
|
||||
};
|
||||
};
|
||||
const tangentAt = (t: number): { x: number; y: number } => {
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x),
|
||||
y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y),
|
||||
};
|
||||
};
|
||||
const N = 24;
|
||||
const left: string[] = [];
|
||||
const right: string[] = [];
|
||||
let ties = '';
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = i / N;
|
||||
const pt = at(t);
|
||||
const tan = tangentAt(t);
|
||||
const tlen = Math.hypot(tan.x, tan.y) || 1;
|
||||
const nx = (-tan.y / tlen) * 2.5;
|
||||
const ny = (tan.x / tlen) * 2.5;
|
||||
left.push(`${pt.x + nx} ${pt.y + ny}`);
|
||||
right.push(`${pt.x - nx} ${pt.y - ny}`);
|
||||
// Every third sample — the same rough 9px spacing `rail()` uses for a straight run of similar
|
||||
// length, not tied to `N` itself.
|
||||
if (i % 3 === 0) {
|
||||
ties += `<line class="bs-tie" x1="${pt.x + nx * 1.8}" y1="${pt.y + ny * 1.8}" x2="${pt.x - nx * 1.8}" y2="${pt.y - ny * 1.8}"/>`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<path class="bs-rail" fill="none" d="M${left.join(' L')}"/>` +
|
||||
`<path class="bs-rail" fill="none" d="M${right.join(' L')}"/>` +
|
||||
ties
|
||||
);
|
||||
};
|
||||
|
||||
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
|
||||
// so a straight run stays straight across the whole row; north and south are centred on the edge.
|
||||
const port = (p: string): { x: number; y: number } => {
|
||||
@@ -534,19 +593,23 @@ export function officeSvg(
|
||||
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
|
||||
} else {
|
||||
/**
|
||||
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
|
||||
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
|
||||
* A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the
|
||||
* old two-segment version bent at, so the underlying geometry (and the `frog` position used
|
||||
* elsewhere for the actual joining/matching rules) is unchanged; only the picture is.
|
||||
*
|
||||
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
|
||||
* travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2)
|
||||
* — below the rail on the assumption everything diverged downward — which drew a leg
|
||||
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
|
||||
* any angle the matching rule cares about.
|
||||
* travels half its height sideways. The control points sit halfway from each endpoint to the
|
||||
* frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an
|
||||
* abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching
|
||||
* the card above or below) — see `curvedRail`.
|
||||
*/
|
||||
const side = vertical === from ? to : from;
|
||||
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
||||
const start = port(side);
|
||||
const edge = port(vertical);
|
||||
out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y);
|
||||
const c1 = { x: start.x + 0.5 * (frog.x - start.x), y: start.y };
|
||||
const c2 = { x: edge.x + 0.5 * (frog.x - edge.x), y: edge.y + 0.5 * (frog.y - edge.y) };
|
||||
out += curvedRail(start, c1, c2, edge);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-11
@@ -31,6 +31,12 @@
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||
import type { BotPolicy, BotTweaks } from './bot.ts';
|
||||
@@ -56,17 +62,24 @@ export type PairedResult = {
|
||||
variantStats: GameStats[];
|
||||
};
|
||||
|
||||
const SOLO = (length: GameLength, mode: GameMode): GameConfig => ({
|
||||
mode,
|
||||
victory: 'highestAfterDays',
|
||||
length,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
});
|
||||
// Always one bot (`runOne` below), so the revenue floor is `collectiveRevenueFloor(1, days)`.
|
||||
const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
|
||||
const days = lengthProfile(length).days;
|
||||
return {
|
||||
mode,
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* One game, one seed, one policy.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Live per-seat Frame delta — Phase 2 of `docs/architecture/multiplayer.md`.
|
||||
*
|
||||
* `src/sim/replay.ts`'s `compress()` looked like the thing to reuse here (D2/D3's "2.9 KB per push
|
||||
* with the board omitted when unchanged" cites it) but it solves a different problem: it interns
|
||||
* card/description strings across a WHOLE recorded array of frames, which only pays off when
|
||||
* bundling many frames into one replay file. A live server pushes one frame at a time and has
|
||||
* nothing to intern against. The part that genuinely carries over is much smaller — a one-step-back
|
||||
* "null if unchanged since the last thing sent to THIS seat" check on the three fields that make up
|
||||
* almost all of a Frame's size: `cells`, `facilities`, `division` (`replay.ts`'s own `keys` array).
|
||||
*
|
||||
* Node-free by design, unlike `replay.ts` (which imports `node:fs`) — both the server and a browser
|
||||
* `RemoteSession` import this file directly.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView, FacilityView, Frame } from './view.ts';
|
||||
|
||||
/** A `Frame` with the three board-shaped fields replaced by `null` where unchanged since `previous`. */
|
||||
export type FrameDelta = Omit<Frame, 'cells' | 'facilities' | 'division'> & {
|
||||
cells: CellView[] | null;
|
||||
facilities: FacilityView[] | null;
|
||||
division: DivisionView[] | null;
|
||||
};
|
||||
|
||||
const BOARD_KEYS = ['cells', 'facilities', 'division'] as const;
|
||||
|
||||
/**
|
||||
* `previous` is the last Frame actually sent to THIS seat, or `null` for a first connect / a
|
||||
* reconnect after a gap — Phase 2 has no persistence to replay a gap against (Phase 3), so a
|
||||
* reconnect always gets a full Frame here rather than a delta.
|
||||
*/
|
||||
export function deltaFrame(previous: Frame | null, next: Frame): FrameDelta {
|
||||
const out = { ...next } as unknown as FrameDelta;
|
||||
for (const key of BOARD_KEYS) {
|
||||
const unchanged = previous !== null && JSON.stringify(previous[key]) === JSON.stringify(next[key]);
|
||||
(out as Record<string, unknown>)[key] = unchanged ? null : next[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The receiving side: merges a delta back onto the last full Frame this seat actually has. */
|
||||
export function applyDelta(previous: Frame | null, delta: FrameDelta): Frame {
|
||||
const out = { ...delta } as unknown as Frame;
|
||||
for (const key of BOARD_KEYS) {
|
||||
if (delta[key] === null) {
|
||||
if (previous === null) {
|
||||
throw new Error(`deltaFrame said "${key}" is unchanged, but there is no previous Frame to merge onto`);
|
||||
}
|
||||
(out as Record<string, unknown>)[key] = previous[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+15
-6
@@ -12,7 +12,12 @@
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../engine/content.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||
@@ -60,11 +65,15 @@ function statsOf(xs: number[]): Stats {
|
||||
};
|
||||
}
|
||||
|
||||
function configFor(mode: GameMode, length: GameLength): GameConfig {
|
||||
function configFor(mode: GameMode, length: GameLength, players: number): GameConfig {
|
||||
const days = lengthProfile(length).days;
|
||||
return {
|
||||
mode,
|
||||
victory: 'highestAfterDays',
|
||||
length,
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(players, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
@@ -83,7 +92,7 @@ export function simulate(opts: SimOptions): SimReport {
|
||||
const s = createGame({
|
||||
id: `sim-${i}`,
|
||||
seed,
|
||||
config: configFor(opts.mode, opts.length),
|
||||
config: configFor(opts.mode, opts.length, opts.players.length),
|
||||
playerNames: opts.players,
|
||||
});
|
||||
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
|
||||
@@ -154,7 +163,7 @@ export function formatReport(r: SimReport, length: GameLength, players: number):
|
||||
|
||||
// The provisional numbers this exists to test.
|
||||
out.push('\n against the provisional targets:');
|
||||
out.push(` target ${profile.target} over ${profile.days} Days`);
|
||||
out.push(` ${profile.days} Days`);
|
||||
out.push(
|
||||
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
|
||||
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
|
||||
|
||||
+16
-5
@@ -21,7 +21,14 @@ import { writeFileSync } from 'node:fs';
|
||||
import { advance } from '../engine/advance.ts';
|
||||
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
MAINLINE_PROFILES,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
@@ -51,10 +58,14 @@ import { playCue } from '../web/sound.ts';
|
||||
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
|
||||
|
||||
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
|
||||
const days = lengthProfile(length).days;
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length,
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
@@ -263,7 +274,7 @@ export function rehydrateCells(
|
||||
}
|
||||
|
||||
export function renderHtml(rec: Recording): string {
|
||||
const target = lengthProfile(rec.length);
|
||||
const profile = lengthProfile(rec.length);
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -360,7 +371,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</style></head><body>
|
||||
|
||||
<header>
|
||||
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
|
||||
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (${profile.days} Days) · ${esc(rec.outcome)}</h1>
|
||||
<div class="bar">
|
||||
<span class="dim">fedora <span id="super">—</span></span>
|
||||
<span>revenue <b class="big" id="rev">0</b></span>
|
||||
|
||||
+50
-8
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
ACTION_CARDS,
|
||||
ENHANCEMENT_CARDS,
|
||||
HAND_LIMIT,
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
houseRules,
|
||||
@@ -348,6 +348,18 @@ export type Frame = {
|
||||
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
|
||||
*/
|
||||
houseRules: HouseRules;
|
||||
/**
|
||||
* The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the
|
||||
* running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState`
|
||||
* and needs to show live progress ("2 of 3 collisions today") without guessing a default. `0` on
|
||||
* any `max*`/`minCombinedRevenue` field means that check is off.
|
||||
*/
|
||||
days: number;
|
||||
minCombinedRevenue: number;
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
/**
|
||||
@@ -360,6 +372,13 @@ export type Frame = {
|
||||
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
|
||||
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
|
||||
handCount: number;
|
||||
/**
|
||||
* True when the VIEWER's hand is over §6.2's limit and their turn cannot end until it is played
|
||||
* down. Duplicates `game.ts`'s `overHandLimit(game, seat)` at the engine-data level rather than
|
||||
* importing the web layer here — a `RemoteSession` (Phase 2) has no `GameState` to compute this
|
||||
* from, only a `Frame`, so it has to already be resolved on the wire.
|
||||
*/
|
||||
overHandLimit: boolean;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
@@ -1207,6 +1226,12 @@ export function snapshot(
|
||||
wasted,
|
||||
option: turnOf(s, viewer).option,
|
||||
houseRules: houseRules(s.config),
|
||||
days: s.config.days,
|
||||
minCombinedRevenue: s.config.minCombinedRevenue,
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
players: s.players.map((p) => ({
|
||||
@@ -1217,6 +1242,8 @@ export function snapshot(
|
||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||||
})),
|
||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||
overHandLimit:
|
||||
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
||||
objective: objectiveOf(s, viewer),
|
||||
runningRow: area.runningRow,
|
||||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||||
@@ -1633,21 +1660,36 @@ const SIMPLE_CARDS = [
|
||||
...ACTION_CARDS,
|
||||
];
|
||||
|
||||
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
|
||||
/**
|
||||
* The goal, and whether the VIEWER's score is keeping up with the clock.
|
||||
*
|
||||
* `target` is `config.minCombinedRevenue` now (2026-08-20) — the floor below which everyone loses,
|
||||
* not a per-player win threshold; `days` and `daysLeft` come off `config.days`. Paced against the
|
||||
* VIEWER's own Revenue, same as before: exact for solitaire (the viewer IS the whole table), an
|
||||
* approximation for competitive/coop until Phase 2 gives the objective panel a combined-progress
|
||||
* view of its own. `0` means no floor is configured — nothing to pace against.
|
||||
*/
|
||||
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
|
||||
const profile = lengthProfile(s.config.length);
|
||||
const { days, minCombinedRevenue: target } = s.config;
|
||||
const revenue = s.players[viewer]?.revenue ?? 0;
|
||||
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
|
||||
const elapsed = profile.days - daysLeft + 1;
|
||||
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
||||
const elapsed = days - daysLeft + 1;
|
||||
if (target <= 0) {
|
||||
const note =
|
||||
daysLeft === 0
|
||||
? 'the last Day is over'
|
||||
: `${revenue} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · no minimum this game`;
|
||||
return { target: 0, days, daysLeft, onPace: true, note };
|
||||
}
|
||||
// Straight-line pace: by the end of Day N you want N/days of the target.
|
||||
const expected = (profile.target * elapsed) / profile.days;
|
||||
const expected = (target * elapsed) / days;
|
||||
const onPace = revenue >= expected;
|
||||
const note =
|
||||
daysLeft === 0
|
||||
? 'the last Day is over'
|
||||
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
|
||||
: `${revenue} of ${target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
|
||||
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
|
||||
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
|
||||
return { target, days, daysLeft, onPace, note };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user