Keep money mattering at the top, and fix the instruments that hid it
Both settings now carry a charge levied by standing itself, which no purchase ends. Corporate Ladder's steps on money (1200/-60, 2400/-120, 3600/-240): a flat charge cannot flatten a slope, since late income scales with how well the player plays, and the flat charge needed to stop an optimiser accumulating put ordinary archetypes below zero for 44% of a run. Stepped, the balance settles around $4,250 and ordinary play comes out better than before it existed. The Frontier's is flat at -4 a run, because its late game never accumulated in the first place. Found in the first two human sessions of this work: - A career saved before a pack gained a stat had no path for it, and conditions compare numbers only, so every event, option and upkeep entry gated on a new stat silently did not exist for that run. Loading now fills those in. - 'Wrap up my career' sat immediately after the last option in tab order, so one Tab too many and Enter read as the game ending at day 31. It goes last, and the retrospective says plainly that it is a summary. - A 181-turn session ran against a stale bundle and the change under test never fired. Every logged turn now records app_version and build. - src/version.js had reported 0.3.0 since 0.3.0, while playtesters were told to check the status bar. A test now holds it to package.json. - 'Put money against the book' was shown locked to a player who owed nothing, six times. Options gated on a state the player cannot choose to enter are now hidden rather than greyed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
This commit is contained in:
co-authored by
Claude Opus 5
parent
5d645e6452
commit
d84ae495f4
@@ -114,12 +114,15 @@ Open questions, in the order they are likely to matter:
|
|||||||
evidence that was not enough the last two times. It needs a session before its
|
evidence that was not enough the last two times. It needs a session before its
|
||||||
numbers are believed.
|
numbers are believed.
|
||||||
|
|
||||||
- **Standing now costs money to keep, and nobody has played it.** Each setting
|
- **Standing costs money to keep, and the Frontier's version is unplayed.**
|
||||||
has a `lifestyle` tier bought on the second rung and charged for every cycle
|
Each setting has a `lifestyle` tier bought on the second rung and charged for
|
||||||
after — and a standing penalty, steepening as standing rises, for lagging
|
every cycle after, a standing penalty for lagging behind what the job expects,
|
||||||
behind what the job expects. A Dispatch coordinator keeping up fully runs at
|
and a charge levied by standing itself that no purchase ever ends. Corporate
|
||||||
-$21 a week and has to earn the difference. It is measured (DECISIONS #36)
|
Ladder's has had one 96-turn session: both purchases were found and taken, and
|
||||||
and, like everything else here, unplayed.
|
the standing pressure worked, but the run arrived at the top with nothing left
|
||||||
|
to spend on — which is where the never-stopping charge came from. The Frontier
|
||||||
|
carries the same system at its own scale and has never been in front of anyone.
|
||||||
|
See DECISIONS #36.
|
||||||
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
||||||
variety guard, but it has had far less play than the mailroom. Waits on more
|
variety guard, but it has had far less play than the mailroom. Waits on more
|
||||||
playtesting.
|
playtesting.
|
||||||
|
|||||||
@@ -170,6 +170,54 @@ export const corporateLadder = {
|
|||||||
],
|
],
|
||||||
effects: [{ path: 'reputation', op: 'add', value: -14 }],
|
effects: [{ path: 'reputation', op: 'add', value: -14 }],
|
||||||
},
|
},
|
||||||
|
// The charge that never stops, and the only one with no purchase that ends
|
||||||
|
// it. Everything above is a ladder with a top: buy both tiers, reach 100
|
||||||
|
// standing, and every charge switches off. A real 96-turn session arrived
|
||||||
|
// there at turn 84 with nothing left to spend on and a balance that only
|
||||||
|
// climbed.
|
||||||
|
//
|
||||||
|
// It steps on money rather than being flat, and that is the whole design.
|
||||||
|
// A flat charge cannot flatten a slope: late income comes from choices and
|
||||||
|
// scales with how well the player is playing, so a charge big enough to
|
||||||
|
// stop an optimiser accumulating (-360 a week, measured) put ordinary
|
||||||
|
// archetypes below zero for 44% of a run and ended them $1,385 in debt.
|
||||||
|
// Stepping on money lands it on whoever has it. Measured: the balance
|
||||||
|
// settles around $4,250 and stops climbing (-3 a week), while ordinary play
|
||||||
|
// gets *better* than before it existed — 13% of a run below zero against
|
||||||
|
// 17%, and a tier-2 run ending +$175 rather than -$25.
|
||||||
|
{
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 85 },
|
||||||
|
{ path: 'money', op: '>=', value: 1200 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -60 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 85 },
|
||||||
|
{ path: 'money', op: '>=', value: 2400 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -120 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 7,
|
||||||
|
offset: 7,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'dispatch' },
|
||||||
|
{ path: 'reputation', op: '>=', value: 85 },
|
||||||
|
{ path: 'money', op: '>=', value: 3600 },
|
||||||
|
],
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -240 }],
|
||||||
|
},
|
||||||
// The service charge steps up with the size of the debt, so the spiral
|
// The service charge steps up with the size of the debt, so the spiral
|
||||||
// tightens instead of flattening out. A step function in data costs the
|
// tightens instead of flattening out. A step function in data costs the
|
||||||
// engine nothing; a percentage would have needed a multiply operation.
|
// engine nothing; a percentage would have needed a multiply operation.
|
||||||
@@ -206,9 +254,10 @@ export const corporateLadder = {
|
|||||||
mailroom: [-45, 5],
|
mailroom: [-45, 5],
|
||||||
dispatch: [5, 125],
|
dispatch: [5, 125],
|
||||||
},
|
},
|
||||||
// Upkeep gated on these is excluded from that baseline: both are states a
|
// Upkeep gated on these is excluded from that baseline: a debt taken on, a
|
||||||
// player opts into, and the baseline is the floor underneath everybody.
|
// lifestyle bought, a standing earned. The baseline is the floor underneath
|
||||||
baselineExcludes: ['debt', 'lifestyle'],
|
// everybody — what a player faces having none of the three.
|
||||||
|
baselineExcludes: ['debt', 'lifestyle', 'reputation'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// How the interface names and formats this pack's state. The UI reads only
|
// How the interface names and formats this pack's state. The UI reads only
|
||||||
|
|||||||
@@ -299,6 +299,12 @@ export const routine = [
|
|||||||
{
|
{
|
||||||
id: 'pay_down',
|
id: 'pay_down',
|
||||||
label: 'Put money against the book.',
|
label: 'Put money against the book.',
|
||||||
|
// Hidden rather than greyed out when there is nothing owed. A locked
|
||||||
|
// door is worth showing when it is a door the player could open later
|
||||||
|
// by playing differently; "pay down what you owe" shown to a man who
|
||||||
|
// owes nothing is not that. It was the most-locked option in a real
|
||||||
|
// 181-turn session, six times, and told the player nothing each time.
|
||||||
|
whenLocked: 'hide',
|
||||||
requires: [
|
requires: [
|
||||||
{ path: 'owed', op: '>', value: 0 },
|
{ path: 'owed', op: '>', value: 0 },
|
||||||
{ path: 'money', op: '>=', value: 20 },
|
{ path: 'money', op: '>=', value: 20 },
|
||||||
|
|||||||
@@ -176,6 +176,24 @@ export const frontier = {
|
|||||||
],
|
],
|
||||||
effects: [{ path: 'standing', op: 'add', value: -14 }],
|
effects: [{ path: 'standing', op: 'add', value: -14 }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The charge that never stops, for the reason Corporate Ladder has one:
|
||||||
|
// without it a man who has bought everything and stands well with the
|
||||||
|
// whole town has nothing left that money is for. Being somebody in Cold
|
||||||
|
// Fork costs, every run, and nothing you can buy ends it.
|
||||||
|
label: 'What the standing costs',
|
||||||
|
every: 4,
|
||||||
|
offset: 4,
|
||||||
|
requires: [
|
||||||
|
{ path: 'stage', op: '==', value: 'deputy' },
|
||||||
|
{ path: 'standing', op: '>=', value: 85 },
|
||||||
|
],
|
||||||
|
// Four rather than six: this pack's late game is already flat — measured,
|
||||||
|
// a deputy who has bought everything drifts 0 a run either way — so the
|
||||||
|
// charge is here to keep money live, not to arrest an accumulation that
|
||||||
|
// does not happen. Six left an ordinary run at 55; four leaves 105.
|
||||||
|
effects: [{ path: 'money', op: 'add', value: -4 }],
|
||||||
|
},
|
||||||
// Store credit costs more the deeper in you are, the same step function
|
// Store credit costs more the deeper in you are, the same step function
|
||||||
// Corporate Ladder uses for its loan — a shape, not a setting.
|
// Corporate Ladder uses for its loan — a shape, not a setting.
|
||||||
{
|
{
|
||||||
@@ -213,9 +231,10 @@ export const frontier = {
|
|||||||
hand: [0, 12],
|
hand: [0, 12],
|
||||||
deputy: [10, 30],
|
deputy: [10, 30],
|
||||||
},
|
},
|
||||||
// States a player opts into, excluded from the baseline above: the floor
|
// States a player opts into or earns, excluded from the baseline above:
|
||||||
// is what a man faces owing nothing and keeping nothing.
|
// the floor is what a man faces owing nothing, keeping nothing, and being
|
||||||
baselineExcludes: ['owed', 'lifestyle'],
|
// nobody in particular.
|
||||||
|
baselineExcludes: ['owed', 'lifestyle', 'standing'],
|
||||||
},
|
},
|
||||||
|
|
||||||
display: {
|
display: {
|
||||||
|
|||||||
@@ -548,6 +548,32 @@ direction is that rising standing brings rising expenses — the car, the place
|
|||||||
live, the clothes a position expects. Upkeep entries are already gated
|
live, the clothes a position expects. Upkeep entries are already gated
|
||||||
conditions over state, so this is content work, not an engine change.
|
conditions over state, so this is content work, not an engine change.
|
||||||
|
|
||||||
|
## 37. A playtest is only as good as its instruments
|
||||||
|
|
||||||
|
The second session — 181 turns of the Frontier, the first time that pack has
|
||||||
|
been played by a human at all — ran against a **stale bundle**. The page had
|
||||||
|
been loaded before the last rebuild, and switching settings inside an open tab
|
||||||
|
does not re-fetch the script, so the run exercised everything except the change
|
||||||
|
it was meant to test. The charge that never stops fired zero times in ninety
|
||||||
|
turns where its conditions held.
|
||||||
|
|
||||||
|
Working that out took file timestamps and a simulation. It should have taken one
|
||||||
|
line of the log, so three things changed:
|
||||||
|
|
||||||
|
- **Every logged turn now records `app_version` and `build`.** The log says
|
||||||
|
which bundle produced it, and a stale session is visible immediately rather
|
||||||
|
than looking exactly like a feature that does not work.
|
||||||
|
- **`src/version.js` was lying.** It hardcoded `0.3.0` through the whole of the
|
||||||
|
0.3.1 and 0.3.2 work, while playtesters were told to check the status bar to
|
||||||
|
know what they were on. A version string that lies is worse than none, because
|
||||||
|
it is trusted. A test now holds it to `package.json`.
|
||||||
|
- **A locked door is only worth showing when it is a door.** `pay_down` — "put
|
||||||
|
money against the book" — was the most-locked option in the session, shown six
|
||||||
|
times to a player who owed nothing. Options gated on a state the player is not
|
||||||
|
in and cannot choose to enter now carry `whenLocked: 'hide'`. Gates worth
|
||||||
|
bouncing off, like a skill threshold, stay visible: that is the signal the log
|
||||||
|
exists to collect.
|
||||||
|
|
||||||
## 36. Standing costs money to keep
|
## 36. Standing costs money to keep
|
||||||
|
|
||||||
Built in both packs, with no engine change, exactly as #35 predicted.
|
Built in both packs, with no engine change, exactly as #35 predicted.
|
||||||
@@ -587,6 +613,42 @@ every lifestyle tier at once into a baseline that is supposed to mean "the floor
|
|||||||
a player faces having opted into nothing." Packs now name those paths in
|
a player faces having opted into nothing." Packs now name those paths in
|
||||||
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
|
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
|
||||||
|
|
||||||
|
**The top of the ladder was a plateau, so one charge never stops.** The first
|
||||||
|
human play of the lifestyle system — 96 turns, Corporate Ladder — found both
|
||||||
|
purchases (the car at turn 52, the address at 75) and showed the standing
|
||||||
|
pressure working exactly as intended: "Looking the part" took 56 reputation, and
|
||||||
|
standing sagged into the low seventies on the car alone before recovering to 100
|
||||||
|
once the address was bought. But from turn 84 the run had nothing left to spend
|
||||||
|
on: every charge switches off at the top tier, and the balance simply climbed.
|
||||||
|
Money stopped mattering again, one rung higher than before.
|
||||||
|
|
||||||
|
Both packs now carry a charge levied by standing itself, which no purchase ends.
|
||||||
|
They are shaped differently, and the difference is measured rather than stylistic:
|
||||||
|
|
||||||
|
- **Corporate Ladder steps on money.** A flat charge cannot flatten a slope —
|
||||||
|
late income comes from choices and scales with how well the player plays, so
|
||||||
|
the flat charge needed to stop an optimiser accumulating (-360 a week) put
|
||||||
|
ordinary archetypes below zero for 44% of a run and ended them $1,385 in debt.
|
||||||
|
Stepping on money ($1,200/-60, $2,400/-120, $3,600/-240) lands it on whoever
|
||||||
|
has it: the balance settles around $4,250 and stops climbing (-3 a week), and
|
||||||
|
ordinary play comes out *better* than before the charge existed — 13% of a run
|
||||||
|
below zero against 17%, a tier-2 run ending +$175 rather than -$25.
|
||||||
|
- **The Frontier is flat, at -4 a run.** Its late game never accumulated in the
|
||||||
|
first place — a deputy who owns everything drifts 0 a run at every charge
|
||||||
|
strength tested — so there is no slope to flatten. The charge is there to keep
|
||||||
|
money live, and break-even at the top is the intent.
|
||||||
|
|
||||||
|
**A save written before a stat existed silently loses the content gated on it.**
|
||||||
|
Found while working out how to playtest this: `loadLocal` returns the saved
|
||||||
|
state verbatim, and `conditions.js` compares numbers only — so for a career
|
||||||
|
saved before `lifestyle` existed, both `lifestyle < 2` and `lifestyle >= 1` are
|
||||||
|
false. The car lot never fires, the purchase never unlocks, the charge never
|
||||||
|
lands, and nothing says so. Resuming a save would have shown a playtester none
|
||||||
|
of this feature while reporting nothing wrong. Loading now fills in any path the
|
||||||
|
pack has gained since (`hydrate`, in `state.js`), writes the repair back, and
|
||||||
|
does the same for an imported file. This is the price of "adding content is just
|
||||||
|
adding data", and it applies to every stat any pack adds from here on.
|
||||||
|
|
||||||
**And a test that had passed for two releases was measuring the wrong thing.**
|
**And a test that had passed for two releases was measuring the wrong thing.**
|
||||||
"The promotion leaves a player better off" compared the end balances of promoted
|
"The promotion leaves a player better off" compared the end balances of promoted
|
||||||
and unpromoted players pooled across archetypes — but which archetypes get
|
and unpromoted players pooled across archetypes — but which archetypes get
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "theladder",
|
"name": "theladder",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+25
-1
@@ -4,7 +4,7 @@
|
|||||||
// expands that into a flat schema and an initial state object. The engine
|
// expands that into a flat schema and an initial state object. The engine
|
||||||
// names no stat of its own beyond its own bookkeeping.
|
// names no stat of its own beyond its own bookkeeping.
|
||||||
|
|
||||||
import { setPath } from './paths.js';
|
import { getPath, setPath } from './paths.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paths the engine owns. Content may *read* these in conditions — "only after
|
* Paths the engine owns. Content may *read* these in conditions — "only after
|
||||||
@@ -61,6 +61,30 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a loaded save any state the pack has gained since it was written.
|
||||||
|
*
|
||||||
|
* Adding a stat to a pack is meant to be data, but a run saved before that stat
|
||||||
|
* existed has no path for it, and `undefined` fails every numeric comparison in
|
||||||
|
* conditions.js — both `x >= 1` and `x < 1` are false. So content gated on a new
|
||||||
|
* stat does not misbehave in a saved run, it *vanishes* from it: the event never
|
||||||
|
* fires, the option never unlocks, the upkeep never charges, and nothing
|
||||||
|
* anywhere says so. Filling the gaps in on load is what makes "adding content is
|
||||||
|
* just adding data" true for players already mid-career.
|
||||||
|
*
|
||||||
|
* Returns the paths it filled, so a caller can say what it repaired.
|
||||||
|
*/
|
||||||
|
export function hydrate(pack, state, schema = resolveSchema(pack)) {
|
||||||
|
let next = state;
|
||||||
|
const added = [];
|
||||||
|
for (const [path, rule] of Object.entries(schema)) {
|
||||||
|
if (getPath(next, path) !== undefined) continue;
|
||||||
|
next = setPath(next, path, structuredClone(rule.initial));
|
||||||
|
added.push(path);
|
||||||
|
}
|
||||||
|
return { state: next, added };
|
||||||
|
}
|
||||||
|
|
||||||
/** The characters a pack defines, keyed by id. */
|
/** The characters a pack defines, keyed by id. */
|
||||||
export function indexCharacters(pack) {
|
export function indexCharacters(pack) {
|
||||||
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
|
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
// players rarely type into a comment box, but the gates they keep bouncing off
|
// players rarely type into a comment box, but the gates they keep bouncing off
|
||||||
// are recorded whether they say anything or not.
|
// are recorded whether they say anything or not.
|
||||||
|
|
||||||
|
import { APP, BUILD } from '../version.js';
|
||||||
|
|
||||||
const KEY = 'theladder.feedback.v1';
|
const KEY = 'theladder.feedback.v1';
|
||||||
const FORMAT = 1;
|
const FORMAT = 1;
|
||||||
|
|
||||||
@@ -44,6 +46,13 @@ export function logTurn(record, state, pack) {
|
|||||||
const entries = readRaw();
|
const entries = readRaw();
|
||||||
entries.push({
|
entries.push({
|
||||||
logged_at: new Date().toISOString(),
|
logged_at: new Date().toISOString(),
|
||||||
|
// Which build produced this turn. A session played on a page loaded before
|
||||||
|
// the last rebuild looks exactly like a feature that does not work: a real
|
||||||
|
// playtest ran 181 turns against a stale bundle and the charge it was meant
|
||||||
|
// to exercise never fired, which took file timestamps and a simulation to
|
||||||
|
// work out. The log should say so itself.
|
||||||
|
app_version: APP.version,
|
||||||
|
build: BUILD.commit,
|
||||||
pack_id: pack.id,
|
pack_id: pack.id,
|
||||||
pack_version: pack.version ?? null,
|
pack_version: pack.version ?? null,
|
||||||
seed: state.meta.seed,
|
seed: state.meta.seed,
|
||||||
|
|||||||
+15
-3
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import { packs, packById, defaultPack } from '../../content/index.js';
|
import { packs, packById, defaultPack } from '../../content/index.js';
|
||||||
import { validatePack } from '../engine/validate.js';
|
import { validatePack } from '../engine/validate.js';
|
||||||
import { resolveSchema } from '../engine/state.js';
|
import { resolveSchema, hydrate } from '../engine/state.js';
|
||||||
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
|
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
|
||||||
import { turnScreen } from './screens/turn.js';
|
import { turnScreen } from './screens/turn.js';
|
||||||
import { retrospectiveScreen } from './screens/retrospective.js';
|
import { retrospectiveScreen } from './screens/retrospective.js';
|
||||||
@@ -190,7 +190,10 @@ const actions = {
|
|||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const state = parseSave(await readFile(file), app.pack.id);
|
// Same repair as a local load: an exported file is exactly as old as
|
||||||
|
// the run it came from.
|
||||||
|
const imported = parseSave(await readFile(file), app.pack.id);
|
||||||
|
const { state } = hydrate(app.pack, imported, app.schema);
|
||||||
app.state = state;
|
app.state = state;
|
||||||
saveLocal(app.pack.id, state);
|
saveLocal(app.pack.id, state);
|
||||||
app.view = choosingView();
|
app.view = choosingView();
|
||||||
@@ -262,7 +265,16 @@ function enterPack(pack) {
|
|||||||
// The packId check is belt and braces now that slots are per pack, but an
|
// The packId check is belt and braces now that slots are per pack, but an
|
||||||
// imported or hand-edited save can still carry the wrong name.
|
// imported or hand-edited save can still carry the wrong name.
|
||||||
if (restored && restored.meta?.packId === pack.id) {
|
if (restored && restored.meta?.packId === pack.id) {
|
||||||
app.state = restored;
|
// A career saved before the pack gained a stat has no path for it, and
|
||||||
|
// every numeric comparison against `undefined` is false — so the events,
|
||||||
|
// options and upkeep gated on a new stat would quietly not exist for a
|
||||||
|
// player already mid-run. Fill them in, and write the repair back.
|
||||||
|
const repaired = hydrate(pack, restored, app.schema);
|
||||||
|
if (repaired.added.length > 0) {
|
||||||
|
console.info(`[save ${pack.id}] filled in new state: ${repaired.added.join(', ')}`);
|
||||||
|
saveLocal(pack.id, repaired.state);
|
||||||
|
}
|
||||||
|
app.state = repaired.state;
|
||||||
app.view = choosingView();
|
app.view = choosingView();
|
||||||
} else {
|
} else {
|
||||||
beginRun(pack);
|
beginRun(pack);
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
|||||||
h('p', { class: 'retro__eyebrow' }, pack.name),
|
h('p', { class: 'retro__eyebrow' }, pack.name),
|
||||||
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
||||||
h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)),
|
h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)),
|
||||||
|
// Said out loud because it did not read that way to a real player, who
|
||||||
|
// arrived here by keyboard at day 31 and took it for the end of the run.
|
||||||
|
h('p', { class: 'retro__note' },
|
||||||
|
`This is a summary, not an ending — "Keep going" picks up where you left `
|
||||||
|
+ `off, on ${unit} ${state.turn}.`),
|
||||||
),
|
),
|
||||||
|
|
||||||
h('section', { class: 'retro__section' },
|
h('section', { class: 'retro__section' },
|
||||||
|
|||||||
@@ -42,15 +42,21 @@ export function turnScreen(pack, state, schema, view, actions) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Order here is tab order. "Wrap up my career" used to sit first, directly
|
||||||
|
// after the last option — so a player tabbing to reach an option, going one
|
||||||
|
// past it, and pressing Enter got the retrospective instead of a turn. That
|
||||||
|
// happened in a real session at day 31 and read as the game ending. It goes
|
||||||
|
// last now, behind buttons that do nothing frightening if pressed by
|
||||||
|
// accident, and Import save is next to Export save where it belongs.
|
||||||
h('footer', { class: 'controls' },
|
h('footer', { class: 'controls' },
|
||||||
h('button', { class: 'button', onClick: actions.onRetrospective },
|
|
||||||
'Wrap up my career'),
|
|
||||||
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
|
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
|
||||||
h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'),
|
h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'),
|
||||||
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
||||||
'Export feedback log'),
|
'Export feedback log'),
|
||||||
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
|
|
||||||
h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'),
|
h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'),
|
||||||
|
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
|
||||||
|
h('button', { class: 'button', onClick: actions.onRetrospective },
|
||||||
|
'Wrap up my career'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ body {
|
|||||||
}
|
}
|
||||||
.retro__title { margin: 6px 0 4px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
|
.retro__title { margin: 6px 0 4px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; }
|
||||||
.retro__subtitle { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 17px; }
|
.retro__subtitle { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 17px; }
|
||||||
|
.retro__note { margin: 8px 0 0; color: var(--ink-soft); font-size: 13px; }
|
||||||
|
|
||||||
.retro__section { margin-top: 34px; }
|
.retro__section { margin-top: 34px; }
|
||||||
.retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
.retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,10 @@ import { BUILD } from './build-info.js';
|
|||||||
|
|
||||||
export const APP = {
|
export const APP = {
|
||||||
name: 'The Ladder',
|
name: 'The Ladder',
|
||||||
version: '0.3.0',
|
// Kept in step with package.json by a test, because it silently was not: the
|
||||||
|
// status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly
|
||||||
|
// the thing a playtester is told to check to know what they are playing.
|
||||||
|
version: '0.3.3',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
|
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js';
|
|||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
test('the version the app reports is the version in package.json', async () => {
|
||||||
|
// It silently was not. The status bar read 0.3.0 through the whole of the
|
||||||
|
// 0.3.1 and 0.3.2 work, while playtesters were being told to check it to know
|
||||||
|
// which build they were on — a version string that lies is worse than none,
|
||||||
|
// because it is trusted.
|
||||||
|
const { APP } = await import('../src/version.js');
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
|
||||||
|
assert.equal(APP.version, pkg.version,
|
||||||
|
'src/version.js and package.json disagree about what this is');
|
||||||
|
});
|
||||||
|
|
||||||
test('the bundle builds, and the bundled game boots and plays', () => {
|
test('the bundle builds, and the bundled game boots and plays', () => {
|
||||||
execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' });
|
execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' });
|
||||||
const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8');
|
const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8');
|
||||||
|
|||||||
@@ -107,6 +107,30 @@ test('standing costs money to keep, and the top of it cannot be worked for', ()
|
|||||||
assert.ok(address < 0,
|
assert.ok(address < 0,
|
||||||
`a coordinator keeping up nets ${address} a week and has to earn the difference`);
|
`a coordinator keeping up nets ${address} a week and has to earn the difference`);
|
||||||
|
|
||||||
|
// And the top of the ladder is not a plateau. A 96-turn session bought both
|
||||||
|
// tiers, reached 100 standing by turn 84, and from there had nothing left to
|
||||||
|
// spend on: every charge was switched off and the balance only climbed.
|
||||||
|
// Standing costs money to carry at every tier, with nothing to buy that ends
|
||||||
|
// it, so there is no position in this pack where money stops mattering.
|
||||||
|
const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 });
|
||||||
|
assert.ok(arrived < address,
|
||||||
|
`standing itself should cost something: ${arrived} against ${address} a week`);
|
||||||
|
|
||||||
|
// And it steepens with what there is to keep up, which is what stops the
|
||||||
|
// balance climbing forever. A flat charge could not: late income scales with
|
||||||
|
// how well the player plays, so flattening an optimiser's slope with one
|
||||||
|
// meant putting ordinary play 44% of a run below zero.
|
||||||
|
const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 });
|
||||||
|
assert.ok(wealthy < arrived,
|
||||||
|
`the charge should steepen with money: ${wealthy} against ${arrived} a week`);
|
||||||
|
assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`);
|
||||||
|
|
||||||
|
// At every tier, not only the top one: a coordinator who bought nothing is
|
||||||
|
// already paying for standing in standing, and pays in money besides.
|
||||||
|
const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 });
|
||||||
|
assert.ok(plainSomebody < plain,
|
||||||
|
`standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`);
|
||||||
|
|
||||||
// Simulated, controlled: the ceiling. Without the top step a player who
|
// Simulated, controlled: the ceiling. Without the top step a player who
|
||||||
// chased standing every turn held 99 while buying nothing, which made the
|
// chased standing every turn held 99 while buying nothing, which made the
|
||||||
// whole system a tax on inattention rather than a choice.
|
// whole system a tax on inattention rather than a choice.
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ test('the star costs money to wear, and the top of standing cannot be worked for
|
|||||||
assert.ok(room > 0,
|
assert.ok(room > 0,
|
||||||
`a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`);
|
`a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`);
|
||||||
|
|
||||||
|
// The top of the ladder is not a plateau here either: standing costs money
|
||||||
|
// to carry at every tier, and nothing you can buy switches it off.
|
||||||
|
// Flat here rather than stepped on money, as Corporate Ladder's is: this
|
||||||
|
// pack's late game does not accumulate in the first place — measured, a
|
||||||
|
// deputy who owns everything drifts 0 a run — so the charge exists to keep
|
||||||
|
// money live, and break-even at the top is the intent rather than a shortfall.
|
||||||
|
const somebody = netPerCycle({ stage: 'deputy', lifestyle: 2, standing: 90 });
|
||||||
|
assert.ok(somebody < room, `standing itself should cost: ${somebody} against ${room} a run`);
|
||||||
|
assert.ok(somebody <= 0,
|
||||||
|
`and leave a deputy who has everything no better than level: ${somebody} a run`);
|
||||||
|
|
||||||
const never = [];
|
const never = [];
|
||||||
const keeps = [];
|
const keeps = [];
|
||||||
for (let seed = 0; seed < 8; seed++) {
|
for (let seed = 0; seed < 8; seed++) {
|
||||||
|
|||||||
+52
-2
@@ -30,6 +30,26 @@ async function boot({ pack = 'corporateladder' } = {}) {
|
|||||||
const text = (root) => root.textContent;
|
const text = (root) => root.textContent;
|
||||||
const optionButtons = (root) => byClass(root, 'option');
|
const optionButtons = (root) => byClass(root, 'option');
|
||||||
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
|
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
|
||||||
|
const wrapUp = (root) => byTag(root, 'button').find((b) => /Wrap up/.test(b.textContent));
|
||||||
|
|
||||||
|
test('tabbing past the last option does not land on the button that ends the run', async () => {
|
||||||
|
// Document order is tab order. "Wrap up my career" sat immediately after the
|
||||||
|
// last option, so one Tab too many and Enter showed the retrospective — which
|
||||||
|
// a real player, on the keyboard, read as the game ending at day 31.
|
||||||
|
const dom = await boot();
|
||||||
|
try {
|
||||||
|
const buttons = byTag(dom.root, 'button');
|
||||||
|
const lastOption = buttons.map((b) => /option/.test(b.className ?? '')).lastIndexOf(true);
|
||||||
|
const next = buttons[lastOption + 1];
|
||||||
|
assert.ok(next, 'there is something after the options');
|
||||||
|
assert.doesNotMatch(next.textContent, /Wrap up/,
|
||||||
|
'the next thing Tab reaches should not be the one that leaves the turn');
|
||||||
|
assert.equal(wrapUp(dom.root), buttons[buttons.length - 1],
|
||||||
|
'it belongs last, behind everything harmless');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('the app boots to a playable turn screen', async () => {
|
test('the app boots to a playable turn screen', async () => {
|
||||||
const dom = await boot();
|
const dom = await boot();
|
||||||
@@ -213,9 +233,10 @@ test('the retrospective summarises the run and is not an ending', async () => {
|
|||||||
enabledOptions(dom.root)[0].click();
|
enabledOptions(dom.root)[0].click();
|
||||||
firstByClass(dom.root, 'button--next').click();
|
firstByClass(dom.root, 'button--next').click();
|
||||||
}
|
}
|
||||||
firstByClass(dom.root, 'button').click(); // "Wrap up my career"
|
wrapUp(dom.root).click();
|
||||||
|
|
||||||
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
|
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
|
||||||
|
assert.match(text(dom.root), /not an ending/, 'it says so, rather than implying it');
|
||||||
const body = text(dom.root);
|
const body = text(dom.root);
|
||||||
assert.match(body, /Career retrospective/);
|
assert.match(body, /Career retrospective/);
|
||||||
assert.match(body, /6 days in the mailroom/);
|
assert.match(body, /6 days in the mailroom/);
|
||||||
@@ -234,7 +255,7 @@ test('the retrospective summarises the run and is not an ending', async () => {
|
|||||||
test('a run with nothing notable still gets a retrospective', async () => {
|
test('a run with nothing notable still gets a retrospective', async () => {
|
||||||
const dom = await boot();
|
const dom = await boot();
|
||||||
try {
|
try {
|
||||||
firstByClass(dom.root, 'button').click(); // wrap up on turn one
|
wrapUp(dom.root).click(); // wrap up on turn one
|
||||||
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
|
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
|
||||||
assert.match(text(dom.root), /Nothing that anybody would write down/);
|
assert.match(text(dom.root), /Nothing that anybody would write down/);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -453,6 +474,35 @@ test('a run saved before settings were selectable is not lost', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a save written before a stat existed gains it, rather than losing the content', async () => {
|
||||||
|
// The failure this guards against is silent, which is what makes it worth a
|
||||||
|
// test: conditions.js compares numbers only, so with no `lifestyle` path both
|
||||||
|
// `lifestyle < 2` and `lifestyle >= 1` are false. Every event, option and
|
||||||
|
// upkeep entry gated on a stat added since the save was written simply does
|
||||||
|
// not exist for that career, and nothing anywhere says so.
|
||||||
|
const dom = installFakeDom();
|
||||||
|
try {
|
||||||
|
const { startGame } = await import('../src/engine/game.js');
|
||||||
|
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||||
|
const before = startGame(corporateLadder, { seed: 'pre-lifestyle' });
|
||||||
|
before.turn = 40;
|
||||||
|
delete before.lifestyle;
|
||||||
|
dom.storage.setItem('theladder.save.v1.corporateladder',
|
||||||
|
JSON.stringify({ format: 1, savedAt: null, state: before }));
|
||||||
|
dom.storage.setItem('theladder.pack.v1', 'corporateladder');
|
||||||
|
|
||||||
|
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
|
||||||
|
bootApp(dom.root);
|
||||||
|
|
||||||
|
assert.match(text(dom.root), /Day 40/, 'the career resumed where it was');
|
||||||
|
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
|
||||||
|
assert.equal(saved.state.lifestyle, 0,
|
||||||
|
'the stat the run predates was filled in, and the repair written back');
|
||||||
|
} finally {
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('the header reads the second pack\'s display map, not the first\'s', async () => {
|
test('the header reads the second pack\'s display map, not the first\'s', async () => {
|
||||||
// `display` is a per-pack contract with the interface, and a typo in it is
|
// `display` is a per-pack contract with the interface, and a typo in it is
|
||||||
// only visible at runtime. Corporate Ladder's version is covered above; this
|
// only visible at runtime. Corporate Ladder's version is covered above; this
|
||||||
|
|||||||
Reference in New Issue
Block a user